import kagglehub
import os
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.ticker import FuncFormatter
import seaborn as sns
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 1000)
os.environ["KAGGLE_API_TOKEN"] = "KGAT_e14a31c1c5c3a0a0c90fc319042b5fe6"
path = kagglehub.dataset_download("terminal-security-agency/tsa-claims-database");
This report analyzes the Transportation Security Administration(TSA) Claims Database from kaggle (https://www.kaggle.com/datasets/terminal-security-agency/tsa-claims-database). The dataset contains claims filed by travelers between 2002 and 2015 for property that was lost, stolen or damaged during screening along with a small number of injury claims. Each row is a single claim with 13 columns describing the airport, airline, claim type, claim site, item, amount claimed, amount paid and the final outcome. The goal of this report is to describe where and when claims happen, and how often those claims get approved.
df = pd.read_csv(os.path.join(path, "tsa_claims.csv"), low_memory = False)
head_df = df.head(5).T.fillna("")
head_df.columns = ["Claim 1", "Claim 2", "Claim 3", "Claim 4", "Claim 5"]
print(head_df.to_markdown(tablefmt = "grid", maxcolwidths = [None, 15, 15, 15, 15, 15]))
| Claim 1 | Claim 2 | Claim 3 | Claim 4 | Claim 5 | |
|---|---|---|---|---|---|
| Claim Number | 0909802M | 0202417M | 0202445M | 0909816M | 2005032379513 |
| Date Received | 4-Jan-02 | 2-Feb-02 | 4-Feb-02 | 7-Feb-02 | 18-Feb-02 |
| Incident Date | 12/12/2002 0:00 | 1/16/2004 0:00 | 11/26/2003 0:00 | 1/6/2003 0:00 | 2/5/2005 0:00 |
| Airport Code | EWR | SEA | STL | MIA | MCO |
| Airport Name | Newark International Airport | Seattle-Tacoma International | Lambert St. Louis International | Miami International Airport | Orlando International Airport |
| Airline Name | Continental Airlines | American Airlines | American Airlines | Delta (Song) | |
| Claim Type | Property Damage | Property Damage | Property Damage | Property Damage | Property Damage |
| Claim Site | Checkpoint | Checked Baggage | Checked Baggage | Checkpoint | Checkpoint |
| Item | Other | Luggage (all types including footlockers) | Cell Phones | Luggage (all types including footlockers) | Baby - Strollers; car seats; playpen; etc. |
| Claim Amount | $350.00 | $100.00 | $278.88 | $50.00 | $84.79 |
| Status | Approved | Settled | Settled | Approved | Approved |
| Close Amount | $350.00 | $50.00 | $227.92 | $50.00 | $84.79 |
| Disposition | Approve in Full | Settle | Settle | Approve in Full | Approve in Full |
The first 5 rows of the data set are pictured above which contains 204,267 claims 13 variables. Each claim has a unique claim number along with the date received by the TSA and the incident date when the problem happened. Airport code and airport name identify where the claim came from and airline name shows which airline that the traveler flew. Claim type describes the kind of claim such as passenger property loss, property damage, personal injury …. Claim site records where the incident took place which is most often checked baggage or the checkpoint. Item lists what was lost or damaged and claim amount is how much money the traveler asked for. Status and disposition show how the claim ended which outcomes like approved in full, deny or settle. Close amount is what the TSA actually paid out. In the code there are also created variables for the incident year and month.
df["Claim Amount"] = pd.to_numeric(
df["Claim Amount"].astype(str).str.replace(r'[$,;\s]', '', regex = True),
errors = "coerce")
df["Close Amount"] = pd.to_numeric(
df["Close Amount"].astype(str).str.replace(r'[$,;\s]', '', regex = True),
errors = "coerce")
desc = df.describe(include = "all").T
text_desc = desc[["count", "unique", "top", "freq"]].dropna(subset = ["unique"])
text_desc.columns = ["Count", "Unique", "Most Common", "Frequency"]
num_desc = desc[["count", "mean", "std", "min", "25%", "50%", "75%", "max"]].dropna(subset = ["mean"])
num_desc.columns = ["Count", "Mean", "Std Dev", "Min", "25%", "Median", "75%", "Max"]
num_desc["Count"] = num_desc["Count"].map("{:,.0f}".format)
print(text_desc.to_markdown(floatfmt = ",.0f", intfmt = ",") + "\n\n" +
num_desc.to_markdown(floatfmt = ",.2f"))
| Count | Unique | Most Common | Frequency | |
|---|---|---|---|---|
| Claim Number | 204,267 | 204,244 | 2013123010324 | 2 |
| Date Received | 204,004 | 4,007 | 5-Dec-03 | 682 |
| Incident Date | 202,084 | 26,807 | 12/27/2004 0:00 | 144 |
| Airport Code | 195,743 | 465 | LAX | 9,596 |
| Airport Name | 195,743 | 485 | John F. Kennedy International | 9,232 |
| Airline Name | 169,893 | 318 | American Airlines | 17,553 |
| Claim Type | 196,354 | 11 | Passenger Property Loss | 117,868 |
| Claim Site | 203,527 | 6 | Checked Baggage | 159,753 |
| Item | 200,301 | 4,704 | Other | 36,908 |
| Status | 204,262 | 15 | Denied | 68,339 |
| Disposition | 131,359 | 3 | Deny | 68,382 |
| Count | Mean | Std Dev | Min | 25% | Median | 75% | Max | |
|---|---|---|---|---|---|---|---|---|
| Claim Amount | 187,472 | 16,096,380.44 | 6,928,745,215.17 | 0.00 | 26.65 | 115.00 | 350.00 | 3,000,000,000,000.00 |
| Close Amount | 135,315 | 98.29 | 925.83 | 0.00 | 0.00 | 0.00 | 70.00 | 250,000.00 |
Above, the summary gives an overview of every variable in the data set. Most text variables are nearly complete, but Airline Name is missing for about 34,000 claims since not every claim involves a specific carrier. The claims come from 465 airports with Los Angles having the most at 9,596. American Airlines is the most common carrier with 17,552 claims. There are claim types and passenger property loss makes up 117,868 of them. Checked baggage is the most common claim site with 159,753 claims which is far more than checkpoint. The item column has 4,704 unique entries because travelers often listed several items together. Denial is the most common outcome with 68,382 claims denied out of the 131,359 that have a final disposition. Claim amounts have a median of $115 and the middle half falls between $26.65 and $350. The mean is over $16 million because a few extreme claims reach as high as $3 trillion. This is why the top $1 of claims was removed before building some of the visualizations. Close amounts have a median of $0 which means more than half of the travelers received nothing. The average payout was $98.29 and the largest single payout was $250,000.
Below are the visualizations and findings! Click through the tabs to see each.
def pick_colors_mean_count(data):
colors = []
avg = data.mean()
for i in data:
if i > avg * 1.01:
colors.append("red")
elif i < avg * 0.99:
colors.append("green")
else:
colors.append("black")
return colors
counts = df["Airport Code"].value_counts()
top_250 = counts.head(250)
top_10 = counts.head(10)
Above = mpatches.Patch(color = "red", label = "Above Average")
At = mpatches.Patch(color = "black", label = "Within 1% of the Average")
Below = mpatches.Patch(color = "green", label = "Below Average")
comma_formatter = FuncFormatter(lambda x, pos: format(int(x), ","))
fig = plt.figure(figsize = (18, 16))
fig.suptitle("Frequency of Claims Analysis by Airport:\n Top 250 and Top 10", fontsize = 22)
ax1 = fig.add_subplot(2, 1, 1)
ax1.bar(range(len(top_250)), top_250, color = pick_colors_mean_count(top_250))
ax1.legend(handles = [Above, At, Below], fontsize = 14)
ax1.axhline(top_250.mean(), color = "black", linestyle = "dashed")
ax1.spines["right"].set_visible(False)
ax1.spines["top"].set_visible(False)
ax1.axes.xaxis.set_visible(False)
ax1.yaxis.set_major_formatter(comma_formatter)
ax1.tick_params(axis = "y", labelsize = 14)
ax1.set_title("Top 250 Airports", fontsize = 20)
ax1.text(len(top_250)-1, top_250.mean() + 250, "Mean = " + format(top_250.mean(), ",.2f"), ha = "right", fontsize = 14)
ax2 = fig.add_subplot(2, 1, 2)
ax2.bar(top_10.index, top_10, color = pick_colors_mean_count(top_10))
ax2.legend(handles = [Above, At, Below], fontsize = 14)
ax2.axhline(top_10.mean(), color = "black", linestyle = "solid")
ax2.spines["right"].set_visible(False)
ax2.spines["top"].set_visible(False)
ax2.yaxis.set_major_formatter(comma_formatter)
ax2.tick_params(axis = "both", labelsize = 14)
ax2.set_title("Top 10 Airports", fontsize = 20)
ax2.text(len(top_10)-0.5, top_10.mean() * 1.02, "Mean = " + format(top_10.mean(), ",.2f"), ha = "right", fontsize = 14)
plt.tight_layout()
plt.subplots_adjust(top = 0.9, hspace = 0.3)
plt.show()
The top chart shows how many claims were filed at each of the top 250
airports. Bars are colored red if an airport is above average, black if
it is within 1% of the average and green if it is below. The average for
the top 250 is 775.59 claims but only a small group of airports sits
above the average line. The bars drop very sharply after the first few
airports and then flatten into a long tail where most airports only have
a few hundred claims, not a few thousand. This shows that claims are
concentrated a the large hubs. The second chart zooms in on this. It
focuses on the top 10 airports where the average jumped to 6,677.40
claims. Los Angles International Airport leads with 9,596 claims
followed closely by JFK with roughly 9,200, Newark and Chicago are also
above the average. The other six airports fell below the average with
Las Vegas and Phoenix near the bottom. Overall the airports with the
most claims are the busiest travels hubs in the country, which suggests
claims volume is driven mostly by passenger traffic.
df["Claim Amount"] = pd.to_numeric(
df["Claim Amount"].astype(str).str.replace(r'[$,;\s]', '', regex = True), errors = "coerce")
claims = df[df["Claim Amount"] <= df ["Claim Amount"].quantile(0.99)]
claims = claims[claims["Claim Type"] != "-"]
type_stats = claims.groupby("Claim Type").agg(
Count = ("Claim Number", "count"),
avg_claim = ("Claim Amount", "mean")
).sort_values("Count", ascending = False). reset_index()
type_stats = type_stats[type_stats.Count >= 50].reset_index(drop = True)
def autolabel(these_bars, this_ax, place_of_decimals, symbol):
for i in these_bars:
height = i.get_height()
this_ax.text(i.get_x() + i.get_width()/2, height * 1.01, symbol + format(height, place_of_decimals),
fontsize = 12, color = "black", ha = "center", va = "bottom")
fig = plt.figure(figsize = (18, 10))
ax1 = fig.add_subplot(1, 1, 1)
ax2 = ax1.twinx()
bar_width = 0.4
x_pos = np.arange(len(type_stats))
count_bars = ax1.bar(x_pos - (0.5 * bar_width), type_stats.Count, bar_width, color = "gray", edgecolor = "black", label = "Claim Count")
claim_bars = ax2.bar(x_pos + (0.5 * bar_width), type_stats.avg_claim, bar_width, color = "green", edgecolor = "black",
label = "Average Claim Amount")
ax1.set_xlabel("Claim Type", fontsize = 18)
ax1.set_ylabel("Claim Count", fontsize = 18, labelpad = 20)
ax2.set_ylabel("Avg Claim Amount", fontsize = 18, rotation = 270, labelpad = 20)
ax1.tick_params(axis = "y", labelsize = 14)
ax2.tick_params(axis = "y", labelsize = 14)
ax1.yaxis.set_major_formatter(comma_formatter)
ax2.yaxis.set_major_formatter(comma_formatter)
plt.title("Claim Count and Average Claim Amount Analysis\nBy Claim Type", fontsize = 18)
ax1.set_xticks(x_pos)
ax1.set_xticklabels(type_stats["Claim Type"], fontsize = 14)
count_color, count_label = ax1.get_legend_handles_labels()
claim_color, claim_label = ax2.get_legend_handles_labels()
legend = ax1.legend(count_color + claim_color, count_label + claim_label, loc = "upper center", frameon = True, ncol = 2,
borderpad = 1, fontsize = 14, bbox_to_anchor = (0.5, -0.08))
ax1.set_ylim(0, type_stats.Count.max() * 1.50);
autolabel(count_bars, ax1, ",.0f", " ")
autolabel(claim_bars, ax2, ",.2f", "$")
plt.tight_layout()
plt.show()
This chart compares how often each type of claim is filed with how much
money travelers ask for on average. The gray bars show the number of
claims on the left axis and the green bars show the average claim amount
on the right axis. Before building the chart the top 1% of claim amounts
were removed along with blank claim types and any type with fewer than
50 claims. Passenger property loss and property damage make up almost
every claim with 107,313 and 70,223 claims respectively. Their average
amounts are fairly low at $397.62 and $255.46. The other four claim
types are rare but tend to be much larger. Motor vehicle claims have the
highest average at $1,145.71 across 616 claims. Passenger theft and
employee loss claims filed under the Military and Civilian Employees’
Act fall in between $427,72 and $365.05. Overall the chart shows an
inverse pattern where the most common claims are the smallest and the
rarest claims ask for the most money.
claims["Incident Date"] = pd.to_datetime(claims["Incident Date"], format = "mixed", errors = "coerce")
claims["Month"] = claims["Incident Date"].dt.month
top_7_months = counts.head(7).index
month_df = (claims[claims["Airport Code"].isin(top_7_months)].groupby(["Month", "Airport Code"])["Claim Amount"].sum().reset_index(name = "TotalClaims"))
fig = plt.figure(figsize = (18,10))
ax = fig.add_subplot(1, 1, 1)
all_airports = sorted(month_df["Airport Code"].unique())
color_list = ["blue", "red", "green", "orange", "gray", "purple", "cyan"]
my_colors = {apt: color_list[i % len(color_list)] for i, apt in enumerate(all_airports)}
for key, grp in month_df.groupby(["Airport Code"]):
grp.plot(ax = ax, kind = "line", x = "Month", y = "TotalClaims", color = my_colors[key[0]], label = key[0], marker = "8")
plt.title("Total Claim Amount by Month", fontsize = 18)
ax.set_xlabel("Month", fontsize = 18)
ax.set_ylabel("Total Claim Amount", fontsize = 18, labelpad = 20)
ax.tick_params(axis = "x", labelsize = 14, rotation = 0)
ax.tick_params(axis = "y", labelsize = 14, rotation = 0)
ax.set_xticks(range(1, 13))
ax.set_xticklabels(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: "${:,.0f}".format(x)))
handles, labels = ax.get_legend_handles_labels()
sorted_pairs = sorted(zip(labels, handles))
labels, handles = zip(*sorted_pairs)
plt.legend(handles, labels, loc = "best", fontsize = 14, ncol = 1)
plt.tight_layout()
plt.show()
This chart tracks the total amount claimed each month at the seven
airports with the most claims from the first chart. Each line is one
airport and the totals combine every year in the data set with the top
1% of the claims removed once again. Los Angles stays on top every
single month and peaks in January at about $435,000. JFK is second for
most of the year and Newark climbs steadily in the fall to finish the
year above #$300,000. Several airports share the same seasonal pattern.
Totals are high in December and January during holiday travel and then
dip in February which is also the shortest month of the whole year.
September is a low point for many airports with Orlando dropping to
under $100,000 which is the lowest point on the chart. Atlanta stays
near the bottom early in the year before rising in the spring. Overall
the chart shows claim amounts rise and fall with travel seasons while
the ranking of the biggest airports stays fairly consistent.
top_sites = df["Claim Site"].value_counts().head(2).index
site_df = df[df["Claim Site"].isin(top_sites) & df["Disposition"].isin(["Approve in Full", "Settle", "Deny"])]
pie_df = (site_df.groupby(["Claim Site", "Disposition"])["Claim Number"].count().reset_index(name = "TotalClaims"))
number_outside_colors = len(pie_df["Claim Site"].unique())
outside_color_ref_number = np.arange(number_outside_colors) * 4
number_inside_colors = len(pie_df)
all_color_ref_number = np.arange(number_outside_colors + number_inside_colors)
inside_color_ref_number = []
for each in all_color_ref_number:
if each not in outside_color_ref_number:
inside_color_ref_number.append(each)
fig = plt.figure(figsize = (10, 10))
ax = fig.add_subplot(1, 1, 1)
colormap = plt.get_cmap("tab20c")
outer_colors = colormap(outside_color_ref_number)
all_claims = pie_df.TotalClaims.sum()
pie_df.groupby(["Claim Site"])["TotalClaims"].sum().plot(kind = "pie", radius = 1, colors = outer_colors, pctdistance = 0.85,
labeldistance = 1.1, wedgeprops = dict(edgecolor = "w"), textprops = {"fontsize": 18},
autopct = lambda p: "{:.2f}%\n({:,.0f})".format(p, (p / 100) * all_claims),
startangle = 90)
inner_colors = colormap(inside_color_ref_number)
pie_df.TotalClaims.plot(kind = "pie", radius = 0.7, colors = inner_colors, pctdistance = 0.55, labeldistance = 0.8,
wedgeprops = dict(edgecolor = "w"), textprops = {"fontsize": 13}, labels = pie_df.Disposition,
autopct = "%1.2f%%", startangle = 90)
hole = plt.Circle((0, 0), 0.3, fc = "white")
fig1 = plt.gcf()
fig1.gca().add_artist(hole)
ax.yaxis.set_visible(False)
plt.title("Total Claims by Claim Site and Disposition", fontsize = 18)
ax.text(0, 0, "Total Claims\n" + format(all_claims, ","),
size = 18, ha = "center", va = "center")
ax.axis("equal");
plt.tight_layout()
plt.show()
This nested pie chart breaks down how claims ended at the two most
common claim sites. The outer ring shows the share of claims from each
site and the inner ring spilts each site into its final outcome of
approve in full, settle or deny. Only claims with a final outcome are
included which leaves 129,502 claims in the center. Checked baggage
makes up 81.84% of these claims with 105,980 while the checkpoint
accounts for the other 18.16% with 23,522. Denial is the most common
outcome at both sites and covers 44.16% of all claims from checked
baggage alone. The inner percentages are shares of all claims so
comparing within each site tells a clear story. About 54% of checked
baggage claims were denied and about 24% were approved in full.
Checkpoint claims did better with about 38% approved in full and 44%
denied. This may be becayse problem at the checkpoint happen in front of
the traveler and officers which makes them easier to prove than items
that go missing from a checked bag.
df["Incident Date"] = pd.to_datetime(df["Incident Date"], format = "mixed", errors = "coerce")
df["IncYear"] = df["Incident Date"].dt.year
df["IncMM"] = df["Incident Date"].dt.month
x3 = (df[df["IncYear"].between(2002, 2015)].groupby(["IncYear", "IncMM"])["Claim Number"].count().reset_index(name = "count"))
x3["IncYear"] = x3["IncYear"].astype(int)
x3["IncMM"] = x3["IncMM"].astype(int)
hm_df = pd.pivot_table(x3, index = "IncYear", columns = "IncMM", values = "count")
fig = plt.figure(figsize = (14, 14))
ax = fig.add_subplot(1, 1, 1)
comma_formatter = FuncFormatter(lambda x, p: format(int(x), ","))
ax = sns.heatmap(hm_df, linewidth = 0.2, annot = True, cmap = "coolwarm", fmt = ",.0f", square = True, annot_kws = {"size": 11},
cbar_kws = {"format": comma_formatter, "orientation": "vertical"})
plt.title("Heatmap of the Number of Claims by Incident Year and Month", fontsize = 18, pad = 15)
plt.xlabel("Incident Month", fontsize = 18, labelpad = 10)
plt.ylabel("Incident Year", fontsize = 18, labelpad = 10)
plt.yticks(rotation = 0, size = 14);
plt.xticks(size = 14);
ax.invert_yaxis()
cbar = ax.collections[0].colorbar
max_count = int(np.nanmax(hm_df.to_numpy()))
my_colorbar_ticks = [*range(500, max_count, 500)]
cbar.set_ticks(my_colorbar_ticks)
my_colorbar_tick_labels = ["{:,}".format(each) for each in my_colorbar_ticks]
cbar.set_ticklabels(my_colorbar_tick_labels)
cbar.set_label("Number of Claims", rotation = 270, fontsize = 14, color = "black", labelpad = 20)
plt.show()
This heatmap shows the number of claims for every month from 2002 to
2015 with each row as a year and each column as a month. Red cells mean
more claims and blue cells mean fewer. In 2002 claims start at almost 0
with only 6 in January and then climb to 794 by December as the TSA took
over screening at airports across the country. Claims peak in 2003 and
2004 when most months have well over 2,000 claims. The singles busiest
month is December 2004 with 2,756 claims and August 2003 is close behind
with 2,715. After 2004 the colors shoft steadily from red to blue as
claims decline each year. By 2011 most months fall below 1,000 claims
and by 2015 they sit between about 500 to 800. A few cells stand out as
unusually low such as December 2009 with 372 claims and December 2015
with 254. These drops are likley caused by incomplete recorsd near the
end of the year rather than a real change. Overall the heatmap shows
that claims rose quickly in the early years of the agency and then fell
steadily for over a decade.
This report explored more than 200,000 claims filed with the TSA between 2002 and 2015. The descriptive statistics showed that most claims are small with a median of $115, but a few extreme claims pushed the average into the millions. The visualization then loked at where claims happen, what kinds of claims are filed, when they occur and how they end.
A few clear patterns came out of the analysis. Claims are heavily concentrated at the busiest airports with Los Angles and JFK, two of the busiest and largest airports in the United States, leading the country. Passenger property and property damage make up almost every claim but rare types like motor vehicle and personal injury ask for far more money on average. Claim amounts at the largest airports rise and fall with travel seasons and tend to be highest around the winter holidays. Most claims come from checked baggage and denial is the most common outcome at both claim sites. Checkpoint claims are approved in full more often than checked baggage claims. Over time the number of claims peaked in 2003 and 2004 and then declined steadily for more than a decade.
All in all, taking these findings together suggests that claim volume follows passenger traffic while claim outcomes depend heavily on how easy a problem is to prove. For travelers the data shows that filing a claim is no guarantee of payment since more than half of claims with a recorded payout recieved nothing. A useful next step would be to look at why claims declined so sharply after 2004 and whether changes in the screening procedures or reporting played a role.