Loyola University Maryland, GB-736 Data Visualization

Introduction

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?

Dataset

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.

Data Preparation

I used seven of the columns for this exploration: Department, Object End Date, Gallery Number, Medium, AccessionYear, Country, and Artist Nationality. The raw file needed cleaning:
  • Filtering rows. I removed objects with no department or no end date. I also removed objects with end dates after 2026, which are errors.
  • Defining “on view.” The data has no on-view field, so I counted an object as on view if it has a gallery number. This also counts a few dozen objects whose gallery numbers are text and not numbers.
  • Fixing accession years. Accession years were written as a mix of years and full dates, so I kept the first four characters and converted them to numbers. About 220 records had accession years in the future, mostly in one department, which looks like a data entry error. I set them to 2026.
This initial cleaning reduced the number of objects to 502,907. Additional cleaning occurred for certain charts:
  • Cleaning place data. Country was filled in for only about 15% of objects, and the entries were messy (“probably southern Italy,” “Flanders,” “Republic of Congo”). Claude assisted in removing qualifiers, matching older and regional names to modern countries, and converting the names to ISO country codes. Where no country was recorded, I used the artist’s nationality instead. This gives a place for about 73% of objects.
  • Cleaning the medium data. The Medium column has thousands of different descriptions. Claude assisted in assessing and writing keyword rules (for example, “oil on,” “tempera” and “watercolor” become Painting) that sort each description into one of 12 groups. The rules run in order and the first match wins. Objects with no medium are labeled Unknown, and those that match no rule are labeled Other.
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()

Findings

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.

On View

Only 9% is on display

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()

Origins

Weighted toward the U.S., Europe, & Egypt

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')

Accessions

Building the collection over time

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()

Age

Older departments display more

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()

Medium

Durable materials fill the galleries

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()

Age x Medium

Age and medium decide what’s on view

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()

Conclusion

The Met is mostly in storage. Only 9% of its collection is on view, and what’s on view is not a random sample of what the museum owns. Three patterns stand out:
  • Age: Objects made before 1 CE are 12% of the collection but 57% of what’s on view. Egyptian Art alone accounts for 46% of all objects on view (20,689), and it displays 74% of its own holdings.
  • Material: Prints, drawings, photographs and objects with no recorded medium together make up 46% of the collection, but fewer than 500 of them are on view (0.2%). Stone and plaster (43%) and ceramics and glass (23%) are shown the most.
  • Collecting history: The largest departments grew through a few very large acquisitions. Drawings and Prints took in 40,161 objects in 1963 alone. It’s now 37% of the entire collection, with only 136 objects on view.

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:
  • “On view” is based on whether an object had a gallery number when the data was pulled on October 14, 2026.
  • The medium categories come from my own keyword grouping, assisted by Claude.
  • Only 73% of objects have a recorded country or artist nationality, so the map doesn’t show the whole collection.
knitr::include_graphics("https://cdn.sanity.io/images/cctd4ker/production/f3f99a2b3e7e83350dc452b7242bbd36f651b77c-5121x1707.jpg?w=1920&q=75&auto=format")
www.metmuseum.org

www.metmuseum.org