The data I used for these visualizations was obtained from the nyc.gov website
The data was collected from 2023 to 2026 and captures information across all five boroughs of the city. New York City’s Department of Health and Mental Hygiene inspects every restaurant in the city and publishes it all.
To compare restaurants fairly, every chart uses a restaurants first inspection only. A restaurants first inspection is usually the harshest, as re-inspections often score better.
Inspections are scored by points. Inspectors will add a point for each violation they find. The scores are: An A score is 0-13, B is 14-17, and a C score (the worst) is 28 and higher.
The goal of my report is to ask the question: How well do NYC restaurants hold up to health inspections? Does it very by borough, time of year, or type of restaurant?
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
import seaborn as sns
import matplotlib.patches as mpatches
path = "U:\DataVisualizationFiles\Python_datafiles/"
filename = 'NYC_Food_Inspections.csv'
df = pd.read_csv(path + filename, usecols=['CAMIS', 'DBA', 'BORO', 'ZIPCODE', 'CUISINE DESCRIPTION', 'INSPECTION DATE', 'VIOLATION CODE', 'VIOLATION DESCRIPTION', 'CRITICAL FLAG', 'SCORE', 'GRADE', 'INSPECTION TYPE', 'Latitude', 'Longitude'])
df['Latitude'] = df['Latitude'].replace(0, np.nan)
df['Longitude'] = df['Longitude'].replace(0, np.nan)
df['INSPECTION DATE'] = pd.to_datetime(df['INSPECTION DATE'], format = '%m/%d/%Y')
df = df[df['INSPECTION DATE'].dt.year != 1900].copy()
df['year'] = df['INSPECTION DATE'].dt.year
df['quarter'] = df['INSPECTION DATE'].dt.quarter
df['month'] = df['INSPECTION DATE'].dt.month
df['day']= df['INSPECTION DATE'].dt.day
df['day of the week'] = df['INSPECTION DATE'].dt.dayofweek
df['day of the year'] = df['INSPECTION DATE'].dt.dayofyear
df['month name'] =df['INSPECTION DATE'].dt.strftime('%B')
df['month name abbrev'] = df['INSPECTION DATE'].dt.strftime('%b')
df['day name'] = df['INSPECTION DATE'].dt.strftime('%A')
df['day name abbrev'] = df['INSPECTION DATE'].dt.strftime('%a')
df =df[df['BORO'] != '0'].copy()
inspections = df.groupby(['CAMIS', 'INSPECTION DATE'], as_index=False).first()
initial = inspections[inspections['INSPECTION TYPE'] == 'Cycle Inspection / Initial Inspection'].copy()
This chart show how often each inspection score occurs through the city. This chart uses the initial inspections of restaurants of the city.
A sharp cliff from A to B can be seen, with most restaurants scoring an A. It is interesting that this drop off occurs from A to B, but not B to C. The drop off might be from restaurants eliminating enough violations to stay just above an A score for a better rating. While the data cannot explicitly explain the drop off, it is very unlikely to be natural variation.
counts =initial['SCORE'].value_counts().sort_index()
x = pd.DataFrame({'score': counts.index, 'count': counts.values}, index=counts.index)
def pick_colors_according_to_grade(this_data):
colors = []
for each in this_data['score']:
if each <= 13:
colors.append('green')
elif each <=27:
colors.append('black')
else:
colors.append('lightcoral')
return colors
bottom1 = 0
top1 = 60
d1 = x.loc[bottom1:top1]
my_colors1 = pick_colors_according_to_grade(d1)
bottom2 = 8
top2 = 20
d2 = x.loc[bottom2:top2]
my_colors2 = pick_colors_according_to_grade(d2)
fig = plt.figure(figsize=(18, 16))
fig.suptitle('Frequency of Initial Inspection Scores:\nScores ' + str(bottom1) + ' to ' + str(top1)
+ ' and a Closer Look at Scores ' + str(bottom2) + ' to ' + str(top2),
fontsize=18, fontweight='bold')
A = mpatches.Patch(color = 'green', label='A grade (0 to 13)')
B = mpatches.Patch(color ='black', label='B grade (14 to 27)')
C = mpatches.Patch(color = 'lightcoral', label='C grade (28 and up)')
ax1 = fig.add_subplot(2, 1, 1)
ax1.bar(d1['score'], d1['count'], label='Inspections', color=my_colors1)
ax1.legend(fontsize=14, handles=[A, B, C])
ax1.axvline(13.5, color='black', linestyle='dashed')
ax1.axvline(27.5, color='black', linestyle='dashed')
ax1.spines['right'].set_visible(False)
ax1.spines['top'].set_visible(False)
ax1.set_title('Scores ' + str(bottom1) + ' to ' + str(top1), size=20)
ax1.set_xlabel('Inspection score (lower is better)', fontsize=14)
ax1.set_ylabel('Number of inspections', fontsize=14)
ax2 = fig.add_subplot(2, 1, 2)
ax2.bar(d2['score'], d2['count'], label='Inspections', color=my_colors2)
ax2.legend(fontsize=14, handles=[A, B, C])
ax2.axvline(13.5, color='black', linestyle='solid')
ax2.spines['right'].set_visible(False)
ax2.spines['top'].set_visible(False)
ax2.set_title('Scores ' + str(bottom2) + ' to ' + str(top2), size=20)
ax2.set_xlabel('Inspection score (lower is better)', fontsize=14)
ax2.set_ylabel('Number of inspections', fontsize=14)
ax2.text(14, x.loc[13, 'count'] - 500,
'Score 13: ' + str(x.loc[13, 'count']) + ' inspections\nScore 14: ' + str(x.loc[14, 'count']) + ' inspections',
rotation=0, fontsize=14)
fig.subplots_adjust(hspace=0.35)
plt.show()
This next chart breaks up the inspections and their grades by borough. Because each boroughs grade range adds up to 100%, boroughs of different sizes can be judged fairly.
Scores through the boroughs are more alike than they are different. Manhattan and Brooklyn have the highest A-range inspections. Queens has the largest share of C-Range inspections and Staten Island has the least amount of A-Range inspections.
initial['GRADE RANGE'] = pd.cut(initial['SCORE'], bins=[-1, 13, 27, np.inf],
labels = ['A-Range (0-13)', 'B-Range (14-27)', 'C-Range (28+)'])
stacked_df = initial.groupby(['BORO', 'GRADE RANGE'], observed = True).size().reset_index(name='count')
stacked_df = stacked_df.pivot(index='BORO', columns = 'GRADE RANGE', values = 'count')
stacked_df = stacked_df.div(stacked_df.sum(axis=1), axis=0) * 100
my_boro_order = ['Manhattan', 'Brooklyn', 'Queens', 'Bronx', 'Staten Island']
my_range_order = ['A-Range (0-13)', 'B-Range (14-27)', 'C-Range (28+)']
stacked_df = stacked_df.reindex(index=my_boro_order, columns=my_range_order)
fig = plt.figure(figsize = (18, 10))
ax = fig.add_subplot(1, 1, 1)
stacked_df.plot(kind ='bar', stacked= True, ax=ax, color=['royalblue', 'darkorange', 'beige'])
for container, text_color in zip(ax.containers, ['white', 'black', 'black' ]):
ax.bar_label(container, label_type = 'center', fmt = '%.0f%%', fontsize = 14, color = text_color)
plt.ylabel('Share of First Inspections', fontsize=18, labelpad=10)
plt.title('Share of First Inspections in Each Grade Range by Borough', fontsize=18)
plt.xticks(rotation=0, horizontalalignment='center', fontsize=14)
plt.yticks(rotation=0, fontsize=14)
ax.set_xlabel('Borough', fontsize= 18)
handles, labels = ax.get_legend_handles_labels()
handles = [handles[2], handles[1], handles[0]]
labels = [labels[2], labels[1], labels[0]]
plt.legend(handles, labels , loc='upper left', bbox_to_anchor=(1, 1), fontsize=14)
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: ('%.0f%%') % (x)))
plt.show()
This heat-map shows which violations are behind the scores. Each cell gives the percentage of a boroughs first inspection that cities one of the most common violations.
The color shows the boroughs relation to the city average. Red being more common and blue being less common. Brooklyn and Manhattan follow citywide averages fairly well while The Bronx is above average on many violations.
Some boroughs have distinct patterns. The Bronx is above avergae in plumbing violations. This might represent a structural problem of the borough as a whole as compared to the whole city.
viol_initial = df[df['INSPECTION TYPE'] == 'Cycle Inspection / Initial Inspection'].copy()
viol_initial = viol_initial.drop_duplicates(subset=['CAMIS', 'INSPECTION DATE', 'VIOLATION CODE'])
top_codes = viol_initial['VIOLATION CODE'].value_counts().head(8).index
viol_top = viol_initial[viol_initial['VIOLATION CODE'].isin(top_codes)].copy()
x3 = viol_top.groupby(['BORO', 'VIOLATION CODE'], as_index=False).size()
hm_counts = pd.pivot(x3, index ='BORO', columns='VIOLATION CODE', values= 'size')
boro_totals = initial['BORO'].value_counts()
hm_df = hm_counts.div(boro_totals, axis=0) * 100
hm_df = hm_df[top_codes]
city_rate = viol_top['VIOLATION CODE'].value_counts() / len(initial) * 100
diff_df = hm_df - city_rate[top_codes]
short_names = {'10F': 'Non-Food Surfaces', '08A': 'Pest-Friendly Conditions', '06D': 'Food-Contact Surface Unsanitized', '02G': 'Cold Food Too Warm' , '06C': 'Food Not Protected', '10B': 'Plumbing / Drainage', '02B': 'Hot Food Too Cold', '04L': 'Mice'}
diff_df = diff_df.rename(columns=short_names)
annot_df = hm_df.rename(columns = short_names)
fig = plt.figure(figsize = (18, 10))
ax = fig.add_subplot(1, 1, 1)
comma_fmt = FuncFormatter(lambda x, p: format(int(x), ','))
ax = sns.heatmap(diff_df, linewidths=0.2, annot=annot_df, cmap='coolwarm', fmt=',.0f', square=True,
center=0, vmin=-8,vmax=8,
annot_kws={'size': 11},
cbar_kws={'format': comma_fmt, 'orientation': 'vertical'})
plt.title('First-Inspection Violation Rates by Borough, Compared With the Citywide Rate', fontsize=18, pad=15)
plt.xlabel('Violation', fontsize=18, labelpad=10)
plt.ylabel('Borough', fontsize=18, labelpad=10)
plt.yticks(rotation=0, size=14)
plt.xticks(size=12, rotation=30, ha='right')
cbar = ax.collections[0].colorbar
my_colorbar_ticks = [*range(-8, 9, 2)]
cbar.set_ticks(my_colorbar_ticks)
my_colorbar_tick_labels = ['{:+} pts'.format(each) for each in my_colorbar_ticks]
cbar.set_ticklabels(my_colorbar_tick_labels)
cbar.set_label('Percentage Points Above / Below Citywide Rate', rotation=270, fontsize=14, color='black', labelpad=20)
plt.show()
This chart shows the average first inspection score for each month, with one line per borough. The red dashed line marks the A-Grade cutoff of 13
There is a clear seasonal pattern. Every borough scores worst in July or August, likely due to increased tourism in the summer that stresses restaurants. Food might also be harder to keep in safe temperatures during summer months and warm weather might make pests more active.
Scores by borough are averaged up which is why none of them come close to the A-Grade 13 line.
initial['MONTH'] = initial['INSPECTION DATE'].dt.month
score_df = initial.groupby(['MONTH', 'BORO'])['SCORE'].mean().reset_index(name ='Avg Score')
my_colors = {'Manhattan': 'navy', 'Brooklyn': 'dimgrey', 'Queens': 'darkgreen', 'Bronx': 'Purple', 'Staten Island' : 'gold'}
fig = plt.figure(figsize =(18, 10))
ax = fig.add_subplot(1, 1, 1)
for key, grp in score_df.groupby('BORO'):
grp.plot(ax=ax, kind='line', x='MONTH', y='Avg Score',
color=my_colors[key], label = key, marker= '8')
ax.axhline(13, color = 'red', linestyle='--', label = 'A-grade cutoff (13)')
plt.title('Average First-Inspection Score by Month and Borough', fontsize=18)
ax.set_xlabel('Month', fontsize=18)
ax.set_ylabel('Avererage Score (Lower = Cleaner)', 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(np.arange(1, 13), labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', "Dec"])
handles, labels = ax.get_legend_handles_labels()
handles = [handles[2], handles[1], handles[3], handles[0], handles[4], handles[5]]
labels = [labels[2], labels[1], labels[3], labels[0], labels[4], labels[5]]
plt.legend(handles, labels, loc='best', fontsize=14, ncol=1)
plt.show()
This chart compares average first inspections scores across cuisine types.
The best scoring categories are donuts and hamburgers and worst scoring are Thai and Asian.
It is important to note the business types that these cuisines operate under when viewing this chart. Hamburger and donuts operate as chain style businesses that have corporate food safety programs, standardized checks and equipment, and staff that is trained to deal with inspections. Many of the highest violators in this chart are cuisines that usually operate as a small business or are family owned with no corporate overhead to keep health protocols in check.
cuisines = initial.groupby('CUISINE DESCRIPTION')['SCORE'].agg([('avg score', 'mean'), ('inspections', 'count')]).reset_index()
cuisines = cuisines[cuisines['inspections'] >= 500].copy()
def pick_colors_according_to_mean_score(this_data):
colors = []
avg = this_data['avg score'].mean()
for each in this_data['avg score']:
if each > avg * 1.01:
colors.append('lightcoral')
elif each < avg * 0.99:
colors.append('green')
else:
colors.append('black')
return colors
d3 = cuisines.sort_values('avg score', ascending = True)
d3.reset_index(inplace = True, drop= True)
my_colors3 = pick_colors_according_to_mean_score(d3)
print(d3)
fig = plt.figure(figsize=(18, 14))
ax1 = fig.add_subplot(1, 1, 1)
ax1.barh(d3['CUISINE DESCRIPTION'], d3['avg score'], color = my_colors3)
ax1.set_title('Average First-Inspection Score for the ' + str(len(d3)) + ' Most Inspected Cuisines', size=20)
ax1.set_xlabel('Average score (lower is better)', fontsize=16)
ax1.set_ylabel('Cuisine', fontsize=16)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
above = mpatches.Patch(color='lightcoral', label='Above average (worse)')
at = mpatches.Patch(color='black', label='Within 1% of the average')
below = mpatches.Patch(color='green', label='Below average (better)')
ax1.legend(loc='center right', handles=[above, at, below], fontsize=14)
plt.axvline(d3['avg score'].mean(), color ='black', linestyle='dashed')
ax1.text(d3['avg score'].mean() + 0.2, 0, 'Mean = ' + str(round(d3['avg score'].mean(), 2)),
rotation=0, fontsize=14)
for row_counter, value_at_row_counter in enumerate(d3['avg score']):
if value_at_row_counter > d3['avg score'].mean() * 1.01:
color = 'lightcoral'
elif value_at_row_counter < d3['avg score'].mean() * 0.99:
color = 'green'
else:
color = 'black'
ax1.text(value_at_row_counter + 0.2, row_counter, str(round(value_at_row_counter, 2)),
color=color, size=12, fontweight='bold',
horizontalalignment='left', verticalalignment='center', backgroundcolor='white')
plt.xlim(0, d3['avg score'].max() * 1.1)
plt.show()
Most NYC restaurants pass their first inspection with an A-Range score, where the cutoff has a visible effect how scores are distributed. Performance is mostly similar across boroughs but their specific violations differ. Scores get worse in the summer. Specific problems that boroughs face might impact what violations their resturants have.