Skip to content

Bamboo Weekly #48: Aviation accidents (solutions)

Get better at: JSON, multiple files, comprehensions, applying functions, string operations, grouping, stack-unstack, and plotting

Bamboo Weekly #48: Aviation accidents (solutions)

[Programming note: Office hours for all Bamboo Weekly paid subscribers will take place this coming Sunday. I’ll send e-mail about it in the coming 24 hours. Please come with your questions and comments!]

At first glance, the last week hasn’t seemed very encouraging when it comes to aviation safety. Between a crash in Japan and the side coming off of an Alaska Airlines plane, you might feel justified in thinking of airline travel as unsafe. In many ways, I would argue the opposite, that if no one was hurt in either of these accidents, we should be impressed by and grateful for the engineers who invest so much time improving airplane safety.

But what do the numbers say? Have there been fewer injuries over time or more? Do we see any specific trends?

This week, we looked at data collected by the National Transportation Safety Board (NTSB), part of the US Department of Transportation (https://www.ntsb.gov/Pages/home.aspx). They collect data about airline (and other transportation) accidents, and also investigate when an accident takes place. Their analysis helps recommend ways for the airline industry to improve and to avoid repeating mistakes.

Data and seven questions

This week's data comes from the NTSB's "CAROL" database (https://data.ntsb.gov/carol-main-public/basic-search), which lets you query all sorts of transportation incidents. You can then download the result of your query in either JSON or CSV format.

I asked you to download data from January 1st, 1998 through today from CAROL. I’ll admit that this required doing a bit of annoying work, since CAROL only lets you download up to 10,000 records at a time. Through a bit of experimentation, I found that I could download all of these records in JSON format by choosing “Aviation” as the search mode, specifying five pairs of dates, and then clicking on “search”. Then, after the search was over, I would click on “download JSON data.”

The zipfile that I got from each download contained a JSON-formatted file as well as a readme containing my query.

I chunked the data into these date ranges:

The files that I got were named:

From what I can tell, the filenames are based on the date when you downloaded the data, so you likely got files with different names.

This week, I gave you seven tasks and questions. Here are my answers and explanations; a link to the full Jupyter notebook is at the bottom of this message:

Combine the five JSON files into a single data frame. You will only need the following columns: `cm_mkey`, `cm_eventDate`, `cm_vehicles`, `cm_fatalInjuryCount`, `cm_seriousInjuryCount`, and `cm_minorInjuryCount`. Treat the `cm_eventDate` column as a date. Set the `cm_mkey` column to be the index. How can you be sure that you downloaded all of the files, covering all years?

First, and before doing anything else, I have to load Pandas:

import pandas as pd

Then, with Pandas loaded, how can I turn the JSON files into a data frame? We’ve previously read numerous CSV files into Pandas using “read_csv”, and Excel files into Pandas using “read_excel”. The “read_json” function is similar, taking a filename as input and returning a data frame.

That’s fine for a single file and a single data frame, but how can we combine all five inputs into a single data frame? My preferred technique is to create a list of data frames using a list comprehension, invoking read_json on each file returned by “glob.glob”. I can then invoke “pd.concat” on that list of data frames, getting one data frame back:

import glob

all_dfs = [pd.read_json(one_filename)
           for one_filename in glob.glob('cases*json')]

The good news is that this will indeed give me a list of data frames, one per file. However, I wanted to turn the “cm_eventDate” column into a datetime column. To do that, I pass the “convert_dates” keyword argument to read_json, passing a list of columns (one, in this case) that should be parsed in this way:

import glob

all_dfs = [pd.read_json(one_filename,
                       convert_dates=['cm_eventDate'])
           for one_filename in glob.glob('cases*json')]

Now that we have a list of data frames, we can combine them with pd.concat:

df = (pd
      .concat(all_dfs)
     )

The resulting data frame is quite wide, with 38 columns — most of which aren’t really necessary for our analysis. I thus decided to keep only those columns that I needed:

df = (pd
      .concat(all_dfs)
      [['cm_mkey', 'cm_eventDate', 'cm_vehicles', 
        'cm_fatalInjuryCount', 'cm_seriousInjuryCount', 'cm_minorInjuryCount']]
     )

I had asked that you set the “cm_mkey” column to be the index. Normally, I would have used the “index_col” keyword when reading the file from disk to choose an index column. But for reasons that I don’t quite understand, read_json doesn’t have such an option. I thus decided to handle it after creating the large data frame, invoking “set_index”:

df = (pd
      .concat(all_dfs)
      [['cm_mkey', 'cm_eventDate', 'cm_vehicles', 
        'cm_fatalInjuryCount', 'cm_seriousInjuryCount', 'cm_minorInjuryCount']]
      .set_index('cm_mkey')
     )

I don’t know about you, but I made a number of mistakes when downloading the JSON files from the NTSB. How can we be sure that you haven’t forgotten any years when downloading the data?

I ran the following query:

(
    df['cm_eventDate'].dt.year
    .drop_duplicates()
    .sort_values()
    .diff()
)

The above query grabs the year (thanks to the “dt.year” accessor on datetime columns) from each of the records, then invokes “drop_duplicates”, returning the unique years that are in the system. I sorted the years with “sort_values”, and calculated the difference between them with “diff”. The result was a series in which each row told me by how much it differed from the previous row.

My thinking was that if a year was missing, then I would have seen a gap of more than 1 between two years. But since I only had 1s in the result (except for NaN on the first row), I could be sure that I had data from all of the years.

Count the number of vehicles involved in each incident. How often does each count occur?

How often does an accident only involve one vehicle, and how often does it include more? If we had an integer column indicating how many vehicles were involved in an accident, then we could easily run “value_counts” and find out.

But instead, the “cm_vehicles” column contains Python objects — specifically, lists of dictionaries, with one dict per vehicle involved in each accident. We need to calculate the length of each list. Then we can take those numbers and run them through value_counts.

The solution is to use the “apply” method, which lets us run an arbitrary function on each element in a series. Here, we’ll run the “len” function, which will return the length of each list. We’ll get a series of integers back — one on which we can invoke value_counts. Here, I passed the normalize=True keyword argument, so that I would get percentages returned, rather than the raw integer values:

df['cm_vehicles'].apply(len).value_counts(normalize=True)

The result:

cm_vehicles
1    0.985132
2    0.014805
3    0.000064
Name: proportion, dtype: float64

I’ll add that this makes sense to me; 1-airplane accidents seem far more common than 2- or 3-airplane accidents, even though we know, thanks to the accident in Japan, that they do occur.

There is another way to do this, which is a bit sneaky — but which I’ve used on numerous occasions. Basically, the “str” accessor lets us run string methods on series that contain strings. So if “s” contains string data, I can call “s.str.len()” and get back a new series containing integers, each representing the string’s length.

The thing is, Pandas doesn’t check what underlying types are in a column of dtype “object”. If the underlying object supports the “len” function, then we can run it via “str.len()”, too. We can thus say:

df['cm_vehicles'].str.len().value_counts(normalize=True)

I got the same results. Which raises the question of which is a better way to go. I used “%timeit” in my Jupyter notebook, and found that str.len ran in about 9 ms, whereas apply(len) ran in about 6.9 ms. So my sneaky trick of using str.len might be cool and interesting, but it’s also less efficient.

The `cm_vehicles` column contains a list of dictionaries. From the first element in each list, grab the `make`, `model`, and `operatorName` values and turn them into columns in the data frame.

As we’ve seen, the cm_vehicles column contains a list of dictionaries, with one dictionary for each vehicle in the accident. I asked you to add three columns to our data frame, one for each of the “make”, “model”, and “operatorName” keys in the first dictionary.

In other words, I want to apply something like “[0][‘make’]” to each of these lists, and assign the result to a new “make” column.

There are a few ways to do this, and in this case I decided to go with the sneaky trick that I mentioned earlier, namely using the “str” accessor. In particular, I decided to use “str.get”, which effectively invokes [] on a value. Here, I needed to use str.get twice — once for [0] to get the list’s first element, and again for the particular key.

I could have used str.get twice in a row. But instead, I decided to take advantage of “assign”, which lets us create new columns when we’re chaining methods. One of the nice things about assign is that it handles the keyword arguments in order. This means that later columns can refer to earlier columns, even in the same call to assign.

Let’s start by creating a new column, “first”, which will contain the first vehicle in the accident:

df = (
    df
    .assign(first=lambda df_: df_['cm_vehicles'].str.get(0))
)

With that in place, we can now refer to the “first” column and create the three columns we really wanted to add. We’re still effectively using str.get twice in a row, but at least it’s a bit more readable:

df = (
    df
    .assign(first=lambda df_: df_['cm_vehicles'].str.get(0),
            make=lambda df_: df_['first'].str.get('make'),
            model=lambda df_: df_['first'].str.get('model'),
            operatorName=lambda df_: df_['first'].str.get('operatorName'))
)

Finally, since the “first” column is just there so that we can create the other three, I decided to remove it using “drop”, specifying that I want to drop a column, rather than a row:

df = (
    df
    .assign(first=lambda df_: df_['cm_vehicles'].str.get(0),
            make=lambda df_: df_['first'].str.get('make'),
            model=lambda df_: df_['first'].str.get('model'),
            operatorName=lambda df_: df_['first'].str.get('operatorName'))
    .drop('first', axis='columns')
)

After running the above query, we now have three new columns with the make, model, and operator name of the first vehicle in the aviation accident.

What operator was involved in the greatest number of incidents each year?

Now that we have information about the operators — at least, the operator of the first vehicle in each incident — let’s find out which was involved in the greatest number of accidents per year.

Any time you hear “per year”, you know that we’ll want to perform a “groupby” operation. And any time you hear that we want to count on a per-value basis, you know that we want to run value_counts. Fortunately, value_counts is an aggregation method, so we can actually run it via groupby.

Moreover, we can run “groupby” on the years in our “cm_eventDate” column, thanks to the “dt.year” accessor:

(
    df
    .groupby(df['cm_eventDate'].dt.year)['operatorName']
    .value_counts()
)

Here’s what I got:

cm_eventDate  operatorName           
1998                                     1581
              CONTINENTAL AIRLINES          4
              TRANS WORLD AIRLINES          3
              AMERICAN AIRLINES, INC.       3
              UNKNOWN                       2
                                         ... 
2023          PABLO AIR CHARTERS LLC        1
2024          Japan Airlines                1
              HANSEN JARED L                1
              ALASKA AIRLINES INC           1
              U.S Bangla Airlines           1
Name: count, Length: 19678, dtype: int64

We have a series with a two-part multi-index. The outer part shows the years, and the inner part shows the number of incidents per operator in that year. That’s good, but we just want to know, per year, which operator had the maximum value.

One way to solve this is to turn our multi-indexed series into a pivot table. That is: We’ll move the years to be columns, and keep the inner part of the multi-index as the index. This is known as “unstacking,” and it’s a common Pandas idiom. We can even specify that we want to move the outer index level (aka, level=0) to be columns. The result:

(
    df
    .groupby(df['cm_eventDate'].dt.year)['operatorName']
    .value_counts()
    .unstack(level=0)
)

We now have a data frame whose 17,489 rows represent all of the operators known to the database, and whose 27 columns represent the years in which there were incidents.

Now we can ask Pandas to find, for each year (column), the index that corresponds to the maximum value. We can do that with the “idxmax” method:

(
    df
    .groupby(df['cm_eventDate'].dt.year)['operatorName']
    .value_counts()
    .unstack(level=0)
    .idxmax()
)

The result:

cm_eventDate
1998                         
1999                         
2000                         
2001    AMERICAN AIRLINES INC
2002                  Unknown
2003                  Unknown
2004                  On File
2005                  On File
2006                  On File
2007                  On File
2008                    Pilot
2009                    Pilot
2010                    Pilot
2011                    Pilot
2012                    Pilot
2013                    Pilot
2014                    Pilot
2015                    Pilot
2016                    Pilot
2017                    Pilot
2018                    Pilot
2019                    Pilot
2020                    Pilot
2021                    Pilot
2022                    Pilot
2023      UNITED AIRLINES INC
2024      ALASKA AIRLINES INC
dtype: object

From this, it would seem to me that yes, there have been incidents in which commercial airlines were responsible. For example, we can see the Alaska Airlines incident from last week already in the 2024 numbers. But for the most part, it seems like private, non-commercial pilots were far more likely to be involved in incidents than commercial airlines.

What 10 airplane makes + models were involved in the greatest number of incidents?

In the wake of the Alaska Airlines incident, there has been renewed talk about the safety of the Boeing 737 MAX. Have any particular makes and models of aircraft been involved in a high number of incidents?

I decided to find out by running “value_counts” on two of the columns, “make” and “model”:

df[['make', 'model']].value_counts().head(10)

Here are the results I got:

make    model    
CESSNA  172          547
BOEING  737          501
Cessna  152          448
        172          429
        172N         320
        172M         215
Piper   PA-28-140    214
CESSNA  152          210
        182          207
PIPER   PA28         206
Name: count, dtype: int64

I can see that the makes aren’t necessarily standardized for capitalization. Moreover, we can see that there are dashes and other characters in the model names that might not be standardized. I decided to use “str.lower” and “str.replace” on these columns, and then run value_counts again:

(
   df[['make', 'model']]
    .assign(make=lambda df_: df_['make'].str.lower(),
            model=lambda df_: df['model'].str.replace('\W', '', regex=True))
    .value_counts()
    .head(10)
)

Notice that I used a regular expression to search for any non-alphanumeric characters (\W), replacing them with empty strings. After running the above, here’s what I got:

make    model  
cessna  172        976
        152        658
boeing  737        550
cessna  172N       512
piper   PA28140    395
cessna  172S       386
        182        377
piper   PA18150    363
        PA28       361
cessna  172M       358
Name: count, dtype: int64

We can thus see that Cessna planes (models 172 and 152) were involved in far more incidents than others.

But wait a second: How much should we worry? (Not that I have a Cessna on back order, I’ll admit.) Maybe these are the most common makes and models to be in accidents, but it’s worth checking the percentages, thanks to normalize=True:

(
   df[['make', 'model']]
    .assign(make=lambda df_: df_['make'].str.lower(),
            model=lambda df_: df['model'].str.replace('\W', '', regex=True))
    .value_counts(normalize=True)
    .head(10)
)

The result:

make    model  
cessna  172        0.020735
        152        0.013979
boeing  737        0.011685
cessna  172N       0.010877
piper   PA28140    0.008392
cessna  172S       0.008201
        182        0.008009
piper   PA18150    0.007712
        PA28       0.007669
cessna  172M       0.007606
Name: proportion, dtype: float64

So yes, we see lots of Cessnas at the top of this list. But even the most common model to be involved in accidents, the Cessna 172, was involved in 2 percent of them.

Of course, we could just check makes rather than models:

df['make'].str.lower().value_counts(normalize=True).head(10)

The results from here are a bit less encouraging for Cessna fans:

make
cessna         0.261222
piper          0.145857
beech          0.055209
boeing         0.042957
bell           0.029664
robinson       0.018877
mooney         0.014163
air tractor    0.009301
hughes         0.008515
bellanca       0.008366
Name: proportion, dtype: float64

Wow. More than a quarter of all incidents in our data set involved Cessnas, and another 15 percent involved Pipers. That seems to fit the previous result we saw, which showed that the largest number of incidents in most years was listed as “pilot,” which I take to mean “private pilot.”

Has the number of injuries reported per year risen or declined over the years? Create a line plot from this data, showing all injury types.

I want to know whether the number of injuries reported per year has risen or fallen over time. I ran a “groupby” per year, asking for us to sum the count of fatal, serious, and minor injuries in each year:

(
    df
    .groupby(df['cm_eventDate'].dt.year)
    [['cm_fatalInjuryCount', 'cm_seriousInjuryCount', 'cm_minorInjuryCount']].sum()
)

The result is long, but a quick eyeballing of the data certainly looks like each type of injury has dropped. But it’ll definitely be easier to see it if we plot it:

(
    df
    .groupby(df['cm_eventDate'].dt.year)
    [['cm_fatalInjuryCount', 'cm_seriousInjuryCount', 'cm_minorInjuryCount']].sum()
    .plot.line()
)

Here’s what I got:

It sure looks to me like there has been a massive decline in injuries over time, with a nice trend down. Of course, there have been upticks along the way, but for the most part, it looks like flying in 2024 is far safer than it was in 2000.

At what hour of the day do most flights with injuries (of any sort) occur?

Finally, I would guess that most flight problems and accidents take place at night. But is that true? Let’s find out how many injuries (total) occurred in each reported incident, and then find out how often each hour of the day has injuries.

I’ll start by creating a new column, total_injury_count, which sums all of the fatal, serious, and minor injuries. Notice that I’m using the “sum” method here, but because I want to sum across columns rather than down rows, I indicate that I want axis=“columns”:

(
    df
    .assign(total_injury_count = lambda df_: df_[['cm_fatalInjuryCount', 
                                                   'cm_seriousInjuryCount', 
                                                   'cm_minorInjuryCount']].sum(axis='columns'))
)

With that in place, I can now perform a filter with “.loc”, keeping only those rows in which there was at least one injury:

(
    df
    .assign(total_injury_count = lambda df_: df_[['cm_fatalInjuryCount', 
                                                   'cm_seriousInjuryCount', 
                                                   'cm_minorInjuryCount']].sum(axis='columns'))
    .loc[lambda df_: df_['total_injury_count'] > 0]
)

Finally, I then grabbed the hour of the day with “dt.hour”, and counted how often each hour appeared:

(
    df
    .assign(total_injury_count = lambda df_: df_[['cm_fatalInjuryCount', 
                                                   'cm_seriousInjuryCount', 
                                                   'cm_minorInjuryCount']].sum(axis='columns'))
    .loc[lambda df_: df_['total_injury_count'] > 0]
    ['cm_eventDate'].dt.hour.value_counts()
)

The results:

cm_eventDate
14    1686
13    1672
16    1667
15    1647
12    1632
11    1625
17    1510
10    1433
18    1306
19    1201
9     1115
20     890
8      836
21     584
4      519
7      465
22     378
5      332
23     268
6      225
0      171
1      148
3       95
2       91
Name: count, dtype: int64

Turns out, the most common hours for airline incidents is mid-day, from 10 a.m. - 5 p.m. I’m guessing (and this is just a guess) that this might have something to do with the fact that airport are busiest at these times of day, so the chances of an accident rise. But I’d be curious to hear thoughts and suggestions from you!

This week’s Jupyter notebook is at https://drive.google.com/file/d/1LkxmjdSiHN9Sxop7W3aPNx-thfOOiR-k/view?usp=sharing.

I’ll be back next Wednesday with more Python and Pandas puzzles based on current events.

Reuven