The Metropolitan Museum of Art in New York is one of the largest art museums in the world, with objects spanning over 5,000 years. Most people picture it as a building full of masterpieces: Egyptian tombs, Greek statues and European paintings. But the galleries only show a small part of what the Met owns. Of the roughly 503,000 objects in this dataset, only 45,000, about 9%, are currently on view.
What separates the objects people see from the objects that stay in storage?
The data comes from the Metropolitan Museum of Art’s Open Access dataset, which the museum publishes on GitHub. Each row is one object in the museum’s collection, with 54 columns covering details such as its department, title, artist name, artist gender, date, medium, dimensions, country of origin, accession year, and gallery location. The file I used had 502,913 objects and was downloaded on October 14, 2026.
import pandas as pd #Prep, Donut, Choropleth, Heat
import numpy as np #Donut, Choropleth, Scatter
import matplotlib.pyplot as plt #all
import textwrap #Donut
import seaborn as sns #Heatmap
import json #Choropleth
import pycountry #Choropleth
import plotly.express as px #Choropleth
import re #Prep, Choropleth
from matplotlib.colors import LogNorm #Scatter
from matplotlib.ticker import PercentFormatter #Scatter
from matplotlib.ticker import FuncFormatter #Scatter
from matplotlib.ticker import StrMethodFormatter #Line, Bar
path = "C:/Users/geroc/OneDrive - Loyola University Maryland/2026-08 Data Vis/M2 Python/Python Project/"
filename = path + "MetData.csv"
# ----- Main Dataframe -----
df = pd.read_csv(filename, usecols = ['Object End Date', 'Department', 'Gallery Number', 'Medium',
'AccessionYear', 'Country', 'Artist Nationality'],
dtype={'Gallery Number': str, 'AccessionYear': str, 'Country': str, 'Artist Nationality': str})
df = df[df['Object End Date'].notna() & df['Department'].notna()]
df = df[df['Object End Date'] <= 2026]
df['AccessionYear'] = pd.to_numeric(df['AccessionYear'].str[:4], errors='coerce')
df.loc[df['AccessionYear'] > 2026, 'AccessionYear'] = 2026
df["On View"] = df["Gallery Number"].notna()
The six charts below look at the collection’s size, origins and growth, and then at two factors that shape what reaches the galleries: how old an object is and what it is made of.
The Met holds 502,907 total objects across 19 curatorial departments. The outer ring shows each of the seven largest departments’ share of the collection with all other departments grouped. The inner ring splits each department into objects on view and objects in storage. An object counts as on view if it has a gallery number noted in the data. About 91% of the collection is not on view. Drawings and Prints is by far the largest department at 36.8% of everything the Met owns, and almost none of it is displayed. A visitor who walked through every gallery would see less than a tenth of the museum’s holdings.
# ----- Donut Chart Data -----
df5 = (df.groupby('Department')
.agg(DeptCount=('Department', 'count'),
OnViewCount=('On View', 'sum'))
.reset_index()
.sort_values('DeptCount', ascending=False)
.reset_index(drop=True))
top = 7
df5['Department'] = df5['Department'].where(df5.index < top, 'All Other Departments')
df5 = (df5.groupby('Department', as_index=False)
.agg(DeptCount=('DeptCount', 'sum'),
OnViewCount=('OnViewCount', 'sum'))
.sort_values('DeptCount', ascending=False)
.reset_index(drop=True))
df5 = pd.concat([df5[df5['Department'] != 'All Other Departments'],
df5[df5['Department'] == 'All Other Departments']], ignore_index=True)
df5['NotOnViewCount'] = df5['DeptCount'] - df5['OnViewCount']
# ------ Pie Chart Colors -----
outside_color_ref = np.arange(len(df5))*3
all_color_ref = np.arange(len(df5)*3)
inside_color_ref = []
for each in all_color_ref:
if each not in outside_color_ref:
inside_color_ref.append(each)
colormap = plt.get_cmap('plasma')(np.linspace(0, 0.9, len(df5)*3))
outer_colors = colormap[outside_color_ref]
inner_colors = colormap[inside_color_ref]
inner_colors[1::2, 3] = 0.7
# ----- Donut Plot -----
fig = plt.figure(figsize=(14, 12))
ax = fig.add_subplot(1,1,1)
total_works = df5.DeptCount.sum()
dept_labels = [textwrap.fill(name, width=20) for name in df5['Department']]
# Outer Ring
wedges, texts, autotexts = ax.pie(df5['DeptCount'], labels=dept_labels, colors=outer_colors,
radius=1, labeldistance=1.05, pctdistance=0.82,
wedgeprops=dict(width=0.35, edgecolor='white'),
textprops={'fontsize': 14},
autopct=lambda p: '{:.1f}%\n({:,.0f})'.format(p, (p/100)*total_works),
startangle=90, counterclock=False)
for wedge, label in zip(wedges, autotexts):
r, g, b, _ = wedge.get_facecolor()
brightness = 0.299*r + 0.587*g + 0.114*b
label.set_color('black' if brightness > 0.5 else 'white')
# ----- Inner Ring -----
inner_vals = df5[['OnViewCount', 'NotOnViewCount']].to_numpy().flatten()
wedges, _ = ax.pie(inner_vals, colors=inner_colors,
radius=0.65, wedgeprops=dict(width=0.3, edgecolor='white'),
startangle=90, counterclock=False)
for i, w in enumerate(wedges):
if i % 2 == 1:
row = df5.iloc[i // 2]
share = row['NotOnViewCount'] / row['DeptCount']
angle_span = w.theta2 - w.theta1
if angle_span > 4:
mid = np.deg2rad((w.theta1 + w.theta2) / 2)
ax.text(0.5 * np.cos(mid), 0.5 * np.sin(mid),
f'{share:.1%}\n not on view',
ha='center', va='center', fontsize=12, color='black')
plt.title('Total and On View Objects by Department', fontsize=18)
ax.text(0, 0, f'{total_works:,}\n Total Objects', ha='center', va='center', fontsize=16)
ax.axis('equal')
plt.tight_layout()
plt.show()
The Met holds objects from 167 countries, and nearly every inhabited region is represented. These counts lean heavily toward the United States, which leads with 113,415 objects, followed by France (53,306), Egypt (33,621), the United Kingdom (31,238) and Italy (27,134).
The map uses a logarithmic color scale, so each step darker means about ten times as many objects. It shows an object’s country of origin, or the artist’s nationality when no country is recorded, totaling about 73% of the collection. Many ancient and archaeological objects are recorded by culture or region (for example, “Maya” or “Mesopotamia”), so they don’t appear here and the map likely undercounts those parts of the world.
# ----- Data for Map ------
map_df = df[['Country', 'Artist Nationality']]
map_df = map_df[map_df['Country'].notna() | map_df['Artist Nationality'].notna()]
# ----- Clean Countries -----
fix_country = {
#Africa
'Byzantine Egypt':'Egypt', 'Cairo':'Egypt', 'Egypt possibly':'Egypt',
'Ivory Coast':"Côte d'Ivoire", "Cote d'Ivoire":"Côte d'Ivoire", 'Côte d’Ivoire':"Côte d'Ivoire",
'Republic of Benin':'Benin', 'Republic of Cameroon':'Cameroon',
'the Republic of Congo':'Republic of the Congo', 'the Republic of the Congo':'Republic of the Congo',
'Republic of Congo & Angola':'Republic of the Congo', 'Republic of Congo':'Republic of the Congo',
'Democratic Republic of Congo':'Congo, The Democratic Republic of the', 'Dem. Rep. Congo':'Congo, The Democratic Republic of the',
'Dem. Rep. of Congo':'Congo, The Democratic Republic of the', 'Democratic Republic of the Congo':'Congo, The Democratic Republic of the',
'Nubia (Sudan)':'Sudan', 'Nubia':'Sudan', 'African Republic':'Central African Republic',
'Zanzibar':'Tanzania, United Republic of', 'Tanzania':'Tanzania, United Republic of',
'Mail':'Mali', 'S. Mali':'Mali', 'South-central Mali':'Mali', 'Tunesia':'Tunisia', 'Guinea Bissau':'Guinea-Bissau',
'Southwest Nigeria':'Nigeria', 'Uganda-Sudan':'Uganda',
#Europe
'Lower Austria':'Austria', 'Tirana':'Albania', 'from Albania':'Albania', 'Flanders':'Belgium', 'Flemish':'Belgium', 'Brussels':'Belgium',
'Bosnia':'Bosnia and Herzegovina', 'Bosnia - Herzegovina':'Bosnia and Herzegovina',
'Czech Republic':'Czechia', 'Bohemia':'Czechia', 'North France':'France', 'South France':'France', 'Haute Garonne':'France',
'Crete':'Greece',
'Gemany':'Germany','German':'Germany', 'Niederbreisig':'Germany',
'Italian':'Italy', 'Sicily':'Italy', 'Rome':'Italy', 'South Italy':'Italy', 'Northeast Italy':'Italy', 'Macedonia':'North Macedonia',
'Montenegro Yugoslavia':'Montenegro',
'Holland':'Netherlands', 'The Netherlands':'Netherlands', 'South Netherlands':'Netherlands', 'North Netherlands':'Netherlands',
'Netherland':'Netherlands', 'Netherlandish':'Netherlands', 'the Netherlands':'Netherlands', 'South Netherland':'Netherlands',
'North Netherland':'Netherlands', 'South Netherlandish':'Netherlands', 'Nederlands':'Netherlands', 'Friesland':'Netherlands',
'Russian':'Russian Federation', 'Russia Federation':'Russian Federation', 'Russia':'Russian Federation', 'Republic of Georgia':'Georgia',
'nothern Spain':'Spain', 'Gotland':'Sweden',
'England':'United Kingdom', 'Scotland':'United Kingdom', 'Wales':'United Kingdom', 'Northern Ireland':'United Kingdom',
'Great Britain':'United Kingdom',
#Americas
'USA':'United States', 'United States of America':'United States', 'Bolivia':'Bolivia, Plurinational State of',
'Venezuela':'Venezuela, Bolivarian Republic of', 'Trinidad':'Trinidad and Tobago', 'Surinam':'Suriname',
'Coastal Ecuador':'Ecuador', 'West Mexico':'Mexico',
#Pacific & SE Asia
'Republic of the Philippines':'Philippines', 'Myanmar (formerly Burma)':'Myanmar', 'Burma':'Myanmar',
'Philippine Islands':'Philippines', 'Aotearoa New Zealand':'New Zealand', 'East Timor':'Timor-Leste',
'Republic of Timor-Leste':'Timor-Leste', 'Java':'Indonesia', 'Sumatra':'Indonesia', 'Sulawesi':'Indonesia',
'Sarawak':'Malaysia', 'Tahiti':'French Polynesia', 'Marquesas Islands':'French Polynesia',
'Mangareva':'French Polynesia', 'Wallis':'Wallis and Futuna',
#Asia
'Korea':'South Korea', 'Tibet':'China', 'Vietnam':'Viet Nam', 'Indiaal':'India',
'North India':'India', 'Sothern Turkmenistan':'Turkmenistan', 'modern-day Afghanistan':'Afghanistan',
#Middle East
'Iran (Persia)':'Iran', 'Northwest Iran':'Iran', 'Syria':'Syrian Arab Republic', 'Turkey':'Türkiye', 'Palestine':'Palestine, State of',
'Kirghiztan':'Kyrgyzstan', 'Southwest Iran':'Iran', 'Nishapur':'Iran', 'Syria Saudia Arabia':'Syrian Arab Republic',
'Constantinople':'Türkiye', 'Nicomedia':'Türkiye', 'Byzantium':'Türkiye', 'Lake Van region':'Türkiye',
'West Turkey':'Türkiye', 'West Central Turkey':'Türkiye', 'Northwest Turkey':'Türkiye'}
def clean_country(text):
if pd.isna(text):
return None
t = text.split('|')[0].strip()
t = re.sub(r'^(?:(?:northern|northwestern|northeastern|south central|southern|southwestern|southeast|southeastern|eastern|western|west-central|central|present-day|probably|possibly|perhaps|or west|or)\s+)+',
'', t, flags=re.I)
t = re.sub(r'\s*\(.*$', '', t).strip()
t = re.split(r'\s+(?:or|and|possibly|perhaps|present-day)\b|,|/|;|:', t)[0].strip()
t = re.sub(r'\?|\[.*$', '', t).strip().rstrip('.')
return fix_country.get(t, t)
def clean_nat(text):
if pd.isna(text):
return None
parts = [p.strip() for p in text.split('|') if p.strip()]
if not parts:
return None
t = re.split(r',|/| and | or ', parts[0])[0]
return t.replace('?', '').strip()
# ----- Add Clean Columns -----
map_df['CountryClean'] = map_df['Country'].map(clean_country)
map_df['NatClean'] = map_df['Artist Nationality'].map(clean_nat)
# ----- Add ISO codes ------
nat_to_iso = {
'American':'USA','Native American':'USA','French':'FRA','Alsatian':'FRA','Italian':'ITA','Roman':'ITA',
'British':'GBR','Scottish':'GBR','English':'GBR','Welsh':'GBR','German':'DEU','Prussian':'DEU',
'Japanese':'JPN','Netherlandish':'NLD','Dutch':'NLD','Flemish':'BEL','Belgian':'BEL','Spanish':'ESP',
'Chinese':'CHN','Mexican':'MEX','Austrian':'AUT','Bohemian':'CZE','Czech':'CZE','Swiss':'CHE',
'Russian':'RUS','Iranian':'IRN','Irish':'IRL','Danish':'DNK','Indian':'IND','Swedish':'SWE',
'Hungarian':'HUN','Cuban':'CUB','Canadian':'CAN','Papua New Guinea':'PNG','Finnish':'FIN',
'Polish':'POL','Greek':'GRC','Norwegian':'NOR','Australian':'AUS','Brazilian':'BRA',
'Puerto Rican':'PRI','Korean':'KOR','Argentinian':'ARG','Argentine':'ARG','South African':'ZAF',
'Israeli':'ISR','Portuguese':'PRT','Chilean':'CHL','Lebanese':'LBN','Baoulé':'CIV','Turkish':'TUR',
'Egyptian':'EGY','Peruvian':'PER','Colombian':'COL','Venezuelan':'VEN','Ukrainian':'UKR',
'Romanian':'ROU','Serbian':'SRB','Croatian':'HRV','Slovak':'SVK','Lithuanian':'LTU','Latvian':'LVA',
'Estonian':'EST','Icelandic':'ISL','New Zealander':'NZL','Nigerian':'NGA','Ghanaian':'GHA',
'Cameroonian':'CMR','Indonesian':'IDN','Filipino':'PHL','Thai':'THA','Vietnamese':'VNM',
'Pakistani':'PAK','Iraqi':'IRQ','Syrian':'SYR','Moroccan':'MAR','Algerian':'DZA','Tunisian':'TUN',
'Ethiopian':'ETH','Haitian':'HTI','Jamaican':'JAM','Guatemalan':'GTM','Ecuadorian':'ECU',
'Bolivian':'BOL','Uruguayan':'URY','Taiwanese':'TWN','Georgian':'GEO','Armenian':'ARM',
'Luxembourgish':'LUX','Bulgarian':'BGR','Slovenian':'SVN','Costa Rican':'CRI','Panamanian':'PAN',
'Dominican':'DOM',
}
def iso3(name):
try:
return pycountry.countries.lookup(name).alpha_3
except LookupError:
return None
map_df['NatISO'] = map_df['NatClean'].map(nat_to_iso)
map_df['CountryISO'] = map_df['CountryClean'].map(iso3)
# ----- Combine ISOs -----
map_df['CombinedISO'] = map_df['CountryISO'].fillna(map_df['NatISO'])
# ----- Group Countries -----
country_counts = (map_df.groupby('CombinedISO')
.agg(Count=('CombinedISO', 'size'),
CountryClean=('CountryClean', 'first'),
NatClean=('NatClean', 'first'))
.reset_index())
country_counts['Label'] = country_counts['CountryClean'].fillna(country_counts['NatClean'])
country_counts['LogCount'] = np.log10(country_counts['Count'])
# ----- Choropleth Map Plotly -----
with open(path + 'countries.json') as f:
world_geo = json.load(f)
met_map = px.choropleth_map(country_counts, locations='CombinedISO',
geojson=world_geo, featureidkey='id',
color='LogCount',
hover_name='Label',
custom_data=['Count'],
color_continuous_scale=['#e9e7fb', '#9d97ec', '#4b3fc9', '#0d0887'],
opacity=0.8,
zoom=1, center={'lat': 30, 'lon': 0},
map_style='white-bg')
met_map = met_map.update_layout(map_layers=[{"below": "traces",
"sourcetype": "raster",
"sourceattribution": "Tiles © Esri",
"source": ["https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}"]}],
coloraxis_colorbar = dict(title=dict(text='Number of Objects<br>(by place of origin or artist nationality)', side='top',
font=dict(size=13, color='black')),
orientation='h',
x=0.82, y=0.01,
xanchor='right', yanchor='bottom',
len=0.3, lenmode='fraction',
thickness=12,
tickfont=dict(size=10, color='black'),
tickvals=[1, 2, 3, 4, 5],
ticktext=['10', '100', '1,000', '10,000', '100,000']), # max = 113,415
autosize = True,
margin=dict(l=0, r=0, t=0, b=0))
met_map = met_map.update_traces(hovertemplate='<b>%{hovertext}</b><br>%{customdata[0]:,} objects')
#met_map.write_html(path + 'MetChoroplethPtly.html')
#met_map.show()
html = met_map.to_html(full_html=False, include_plotlyjs='cdn', default_width='100%', default_height='600px')
The Met was founded in 1870, and each department has grown in its own way since then. This chart shows the average number of objects acquired per year in each decade for the six largest departments. European Sculpture and Decorative Arts grew most in the museum’s early decades and Asian Art in the 1920s. Drawings and Prints peaked in the 1960s, driven by 40,161 objects acquired in 1963 alone, and Photographs, which barely existed as a collecting area before the 1930s, peaked in the 1990s.
# ----- Data for Line: Department Accession Dates -----
top = df['Department'].value_counts().head(6).index
yearly = (df[df['Department'].isin(top)]
.groupby(['Department', 'AccessionYear']).size()
.unstack('Department')
.reindex(range(1870, 2027))
.fillna(0))
yearly['Decade'] = (yearly.index // 10) * 10
decade_avg = yearly.groupby('Decade').mean()
# ----- Line plot -----
fig = plt.figure(figsize=(14, 8))
ax = fig.add_subplot(1, 1, 1)
colors = {'Asian Art':'#fdc627',
'European Sculpture and Decorative Arts':'#cc4778',
'Greek and Roman Art':'#8808a6',
'Drawings and Prints':'#0d0887',
'Photographs':'#ed7953',
'Costume Institute': 'lightgray'}
for dept in top:
ax.plot(decade_avg.index, decade_avg[dept],
color=colors[dept], label=dept, marker='o', linewidth=2.5)
ax.set_xticks(decade_avg.index)
ax.set_xticklabels([f"{d}s" for d in decade_avg.index], rotation=45)
ax.tick_params(labelsize=12)
handles, labels = ax.get_legend_handles_labels()
handles = [handles[3], handles[5], handles[0], handles[1], handles[4], handles[2]]
labels = [labels[3], labels[5], labels[0], labels[1], labels[4], labels[2]]
plt.legend(handles, labels, loc='best', fontsize=12, ncol=1, frameon=False)
ax.set_title('Average objects acquired per year, by decade', fontsize=18, pad=20)
ax.set_xlabel('Decade', fontsize=16, labelpad=15)
ax.set_ylabel('Average objects acquired per year', fontsize=16, labelpad=15)
ax.spines[['top', 'right']].set_visible(False)
ax.yaxis.set_major_formatter(StrMethodFormatter('{x:,.0f}'))
plt.tight_layout()
plt.show()
Each circle represents a department, and its size and color show how many objects the department holds in total. It’s placed by the median year its objects were made and by the share of its objects on view. Egyptian Art shows 73.5% and Greek and Roman 20% of their holdings. Departments centered on the 19th and 20th centuries, like Photographs, Drawings and Prints, and the Costume Institute, show only a small fraction. One department breaks the pattern: Ancient West Asian Art is very old but has only 0.4% on view.
The biggest circles sit near the bottom, so the largest collections are also the most hidden. Age alone doesn’t explain this, though. Modern departments are also the ones full of paper, photographs, and textiles, which leads to the next question: Does medium affect whether objects are on view?
# ----- Data for Scatter Plot: Department Age & Visibility -----
df2 = (df.groupby('Department')
.agg(Count=('Department', 'count'),
MedianDate=('Object End Date', 'median'),
PctOnView=('On View', 'mean'))
.reset_index())
df2['MedianDate'] = df2['MedianDate'].astype('int')
df2['count_hnd'] = round(df2['Count']/100, 0)
df2 = df2.sort_values('Count', ascending=False)
# -----Scatter Plot -----
plt.figure(figsize=(14, 10))
plt.scatter(df2['MedianDate'], df2['PctOnView'],
c = df2['Count'], norm = LogNorm(), s = df2['count_hnd']*1.5,
marker = 'o', cmap = 'plasma', edgecolors = 'white', linewidths = 1, alpha = 0.85)
offsets = {'Drawings and Prints': (-140, -14), 'Costume Institute': (16, -20), 'European Paintings': (-95, -15),
'The American Wing': (-100, 12), 'Medieval Art': (-65, 8), 'Islamic Art': (-65, 8),
'Arms and Armor': (-60, -4), 'The Libraries': (12, 0), 'Asian Art': (-65, -5),
'Musical Instruments': (-110, 8), 'Photographs': (-110, 6), 'Modern and Contemporary Art': (20, -8),
'Arts of Africa, Oceania, and the Americas': (20, 2), 'European Sculpture and Decorative Arts': (25, 0)}
custom = {'Arts of Africa, Oceania, and the Americas': 'Arts of Africa,\nOceania, &\nthe Americas',
'Modern and Contemporary Art': 'Modern &\nContemporary Art',
'European Sculpture and Decorative Arts': 'European Sculpture &\nDecorative Arts',
'Arms and Armor': 'Arms &\nArmor'}
for _, row in df2.iterrows():
name = row['Department']
dx, dy = offsets.get(name, (10, 10))
label = custom.get(name, name)
radius = np.sqrt(row['count_hnd']*1.5 / np.pi)
plt.annotate(label, (row['MedianDate'], row['PctOnView']),
xytext=(dx, dy), textcoords='offset points', fontsize=9,
arrowprops=dict(arrowstyle='-', color='#545454', shrinkB=radius))
plt.title("The Met's Departments: Age & Visibility", fontsize = 18, pad = 15)
plt.xlabel('Median year objects were made', fontsize = 16, labelpad = 10)
plt.ylabel('Percentage of objects on view in galleries', fontsize = 16, labelpad = 10)
plt.gca().yaxis.set_major_formatter(PercentFormatter(xmax=1))
plt.xlim(right=2499)
plt.ylim(bottom=-.05)
def year_label(x, pos):
if x < 0:
return f'{abs(x):.0f} BCE'
elif x == 0:
return '1 CE'
else:
return f'{x:.0f} CE'
plt.gca().xaxis.set_major_formatter(FuncFormatter(year_label))
cbar = plt.colorbar(pad=0.04, shrink=0.9, aspect=25)
cbar.set_label('Number of Works in Collection', fontsize = 16,
color = 'black', labelpad = 30)
cbar_ticks = [1000, 3000, 10000, 30000, 100000]
cbar.set_ticks(cbar_ticks)
cbar.set_ticklabels(['{:,}'.format(t) for t in cbar_ticks])
cbar.minorticks_off()
plt.tight_layout()
plt.show()
Of the 44,832 objects currently on view, this chart shows what they’re made of and which departments they belong to. Ceramic/Glass leads with 18,124 objects, followed by 8,887 stone, 8,744 metal, and 497 paper and photo objects on view. Works on paper make up nearly half of the collection but only a small share of the galleries. That’s partly a conservation choice: paper, photographs and textiles fade under light, so museums keep them in storage and rotate them for short exhibitions. Durable materials like stone, metal, and ceramic are less affected, so a decision about display is also a decision about material.
# ----- Cleaning Data for Medium -----
medium_df = df[['Medium', 'On View', 'Department', 'Object End Date']]
s = medium_df['Medium'].fillna('').str.lower().str.strip()
s = s.str.replace(r'\s+', ' ', regex=True)
medium_categories = {
'Photograph': r'photograph|daguerreotype|albumen|cyanotype|silver print|platinum print|film|negative|palladium print|salted paper|chromogenic|collotype|photomechanical|polaroid|photogravure|dye transfer|tintype',
'Print': r'lithograph|etching|woodblock|engraving|woodcut|aquatint|monotype|inkjet|commercial|machine roll-printed|serigraph|screenprint|pochoir|mezzotint|intaglio|drypoint|linocut|zincograph|linoleum cut|offset|letterpress|printed book|electrotype',
'Painting': r'oil on|tempera|acrylic|watercolor|gouache|encaustic',
'Earth/Clay (unfired)': r'\bmud\b|adobe|\bcob\b|rammed earth|unfired clay|sun-dried|stucco|clay',
'Ceramic/Glass':r'porcelain|earthenware|stoneware|terracotta|pottery|glass|faience|basalt|ceramic|enamel',
'Metal': r'gold|silver|bronze|copper|nickel|iron|steel|brass|pewter|\btin\b|metal|\blead\b|gilt',
'Wood': r'wood|\boak\b|mahogany|walnut|maple|\bpine\b|poplar|ebony|bamboo|lacquer|palm',
'Textile': r'silk|cotton|linen|wool|embroider|tapestry|lace|velvet|leather|cutwork|nylon|polyester|fiber|synthetic|rayon|\bfur\b|crochet|camelid hair|felt|straw',
'Stone/Plaster':r'marble|limestone|granite|sandstone|jade|lapis lazuli|amethyst|amber|jasper|rock crystal|nephrite|hematite|alabaster|stone|carnelian|steatite|flint|plaster|agate|chalcedony|\bsard\b',
'Drawing & Paper': r'graphite|pencil|charcoal|crayon|pastel|\bink\b|chalk|\bpen\b|paper|card|vellum|parchment|grapite|drawnwork',
'Organic Animal Materials': r'ivory|bone|shell|horn|feathers',
'Unknown': r'no medium available'
}
def categorize(text):
if text in ('', '[no medium available]'):
return 'Unknown'
for cat, pattern in medium_categories.items():
if re.search(pattern, text):
return cat
return 'Other'
lookup = {v: categorize(v) for v in s.unique()}
medium_df['medium_group'] = s.map(lookup)
# ----- Data frame for Stacked Bar -----
top_onview = medium_df.loc[medium_df['On View'], 'Department'].value_counts().head(5).index
paper_group = 'Works on Paper,\nPhotographs & Unknown'
merge = {'Drawing & Paper': paper_group, 'Print': paper_group, 'Photograph': paper_group, 'Unknown': paper_group}
df3 = (medium_df[medium_df['On View']]
.assign(Department=lambda d: d['Department'].where(d['Department'].isin(top_onview), 'All other departments'))
.groupby(['medium_group', 'Department']).size()
.unstack(fill_value=0))
df3 = df3[list(top_onview) + ['All other departments']]
df3 = df3.rename(index=merge).groupby(level=0).sum()
df3['Total'] = df3.sum(axis=1)
df3 = df3.sort_values('Total').drop(columns='Total')
# ----- Stacked Bar Chart: Works on View by Medium -----
fig = plt.figure(figsize=(14, 8))
ax = fig.add_subplot(1, 1, 1)
colors = {'Egyptian Art':'#2a0593',
'Greek and Roman Art':'#8808a6',
'European Sculpture and Decorative Arts':'#cc4778',
'The American Wing':'#f2844b',
'Asian Art':'#fdc627',
'All other departments': 'lightgray'}
df3.plot(kind='barh', stacked=True, ax=ax, color=colors, width=0.8)
totals = df3.sum(axis=1)
for i, n in enumerate(totals):
ax.text(n + totals.max() * 0.01, i, f'{n:,}', va='center', fontsize=12)
plt.title('Works on View by Medium', fontsize=18, pad=20)
plt.xlabel('Number of objects on view in galleries', fontsize=16, labelpad=15)
plt.ylabel('Medium', fontsize=16, labelpad=15)
plt.yticks(fontsize=12)
plt.xticks(fontsize=12)
ax.xaxis.set_major_formatter(StrMethodFormatter('{x:,.0f}'))
ax.spines[['top', 'right']].set_visible(False)
plt.legend(title='Department', title_fontsize=12, loc='lower right', fontsize=12, frameon=False)
plt.tight_layout()
plt.show()
This heatmap combines the last two charts. Each cell shows the share of objects of a given material and period that are on view, and materials are sorted from most to least visible overall, with Other last. I put each object’s end date into ten periods, from 3000–1001 BCE to the 2000s. Read across a row and you see that age matters within a material. The oldest stone objects (3000–1001 BCE) are 68.7% on view and the oldest metal objects 58.3%, but only 6.5% of 1800s metalwork is shown. Read down a column and you see that material matters within a period: paper and photographs stay dark blue in most eras, and the lightest, warmest cells are concentrated in ancient, durable objects.
# ----- Data Frame for Heatmap: On View by Age & Material -----
df7 = medium_df[medium_df['Object End Date'].between(-3000, 2026)]
bins = [-3001, -1001, -1, 499, 999, 1499, 1699, 1799, 1899, 1999, 2026]
labels = ['3000-1001 BCE', '1000-1 BCE', '0-499 CE', '500-999 CE', '1000-1499',
'1500-1699', '1700s', '1800s', '1900s', '2000s']
df7['Era'] = pd.cut(df7['Object End Date'], bins=bins, labels=labels)
paper_group = 'Works on Paper,\nPhotographs & Unknown'
merge = {'Drawing & Paper': paper_group, 'Print': paper_group, 'Photograph': paper_group, 'Unknown': paper_group}
df7['medium_group'] = df7['medium_group'].replace(merge)
overall = df7.groupby('medium_group')['On View'].mean()
hm_df = df7.pivot_table(index='medium_group', columns='Era', values='On View',
aggfunc='mean', observed=False)
order = overall.drop('Other').sort_values(ascending=False).index.tolist() + ['Other']
hm_df = hm_df.loc[order]
# ----- Heatmap -----
fig = plt.figure(figsize=(14, 10))
ax = fig.add_subplot(1, 1, 1)
sns.heatmap(hm_df, cmap='plasma', ax=ax,
linewidths = 0.5, linecolor='white',
annot=True, fmt = '.1%', annot_kws = {'size': 10},
cbar_kws={'format': '{x:.0%}', 'pad': 0.03})
plt.title("Objects on View by Age and Medium", fontsize=18, pad=20)
plt.xlabel('Period objects were made', fontsize=16, labelpad=15)
plt.xticks(rotation=45)
plt.ylabel('Medium', fontsize=16, labelpad=15)
cbar = ax.collections[0].colorbar
cbar.set_label("Percentage of objects on view", fontsize=16, labelpad=20)
plt.tight_layout()
plt.show()
So is the Met about masterpieces? European Paintings, the department most may connect with that idea, shows 42% of its works, but it holds only 2,650 objects, about half a percent of the collection. The galleries show the old and the durable. The real size of the collection, though, sits in storage, in hundreds of thousands of prints, photographs and drawings that are rarely seen.
This analysis has some limits:knitr::include_graphics("https://cdn.sanity.io/images/cctd4ker/production/f3f99a2b3e7e83350dc452b7242bbd36f651b77c-5121x1707.jpg?w=1920&q=75&auto=format")
www.metmuseum.org