Dungeons and Dragons Monsters (2024)

Frequency of five senses and their combinations across all the characters.
Upset
seaborn
PyDyTuesday
TidyTuesday
Author

Manish Datt

Published

May 27, 2025

TidyTuesday dataset of 2025-05-27

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
monsters = pd.read_csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2025/2025-05-27/monsters.csv')
monsters
name category cr size type descriptive_tags alignment ac initiative hp ... wis_save cha_save skills resistances vulnerabilities immunities gear senses languages full_text
0 Aboleth Aboleth 10.000 Large Aberration NaN Lawful Evil 17 7 150 (20d10 + 40) ... 6 4 History +12, Perception +10 NaN NaN NaN NaN Darkvision 120 ft.; Passive Perception 20 Deep Speech; telepathy 120 ft. Aboleth\nLarge Aberration, Lawful Evil\nAC 17\...
1 Air Elemental Air Elemental 5.000 Large Elemental NaN Neutral 15 5 90 (12d10 + 24) ... 0 -2 NaN Bludgeoning, Lightning, Piercing, Slashing NaN Poison, Thunder; Exhaustion, Grappled, Paralyz... NaN Darkvision 60 ft.; Passive Perception 10 Primordial (Auran) Air Elemental\nLarge Elemental, Neutral\nAC 15...
2 Animated Armor Animated Objects 1.000 Medium Construct NaN Unaligned 18 2 33 (6d8 + 6) ... -4 -5 NaN NaN NaN Poison, Psychic; Charmed, Deafened, Exhaustion... NaN Blindsight 60 ft.; Passive Perception 6 NaN Animated Armor\nMedium Construct, Unaligned\nA...
3 Animated Flying Sword Animated Objects 0.250 Small Construct NaN Unaligned 17 4 14 (4d6) ... -3 -5 NaN NaN NaN Poison, Psychic; Charmed, Deafened, Exhaustion... NaN Blindsight 60 ft.; Passive Perception 7 NaN Animated Flying Sword\nSmall Construct, Unalig...
4 Animated Rug of Smothering Animated Objects 2.000 Large Construct NaN Unaligned 12 4 27 (5d10) ... -4 -5 NaN NaN NaN Poison, Psychic; Charmed, Deafened, Exhaustion... NaN Blindsight 60 ft.; Passive Perception 6 NaN Animated Rug of Smothering\nLarge Construct, U...
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
325 Venomous Snake Animals 0.125 Tiny Beast NaN Unaligned 12 2 5 (2d4) ... 0 -4 NaN NaN NaN NaN NaN Blindsight 10 ft.; Passive Perception 10 NaN Venomous Snake\nTiny Beast, Unaligned\nAC 12 \...
326 Vulture Animals 0.000 Medium Beast NaN Unaligned 10 0 5 (1d8 + 1) ... 1 -3 Perception +3 NaN NaN NaN NaN Passive Perception 13 NaN Vulture\nMedium Beast, Unaligned\nAC 10 \t\t ...
327 Warhorse Animals 0.500 Large Beast NaN Unaligned 11 1 19 (3d10 + 3) ... 3 -2 NaN NaN NaN NaN NaN Passive Perception 11 NaN Warhorse\nLarge Beast, Unaligned\nAC 11 \t\t ...
328 Weasel Animals 0.000 Tiny Beast NaN Unaligned 13 3 1 (1d4 − 1) ... 1 -4 Acrobatics +5, Perception +3, Stealth +5 NaN NaN NaN NaN Darkvision 60 ft.; Passive Perception 13 NaN Weasel\nTiny Beast, Unaligned\nAC 13 \t\t ...
329 Wolf Animals 0.250 Medium Beast NaN Unaligned 12 2 11 (2d8 + 2) ... 1 -2 Perception +5, Stealth +4 NaN NaN NaN NaN Darkvision 60 ft.; Passive Perception 15 NaN Wolf\nMedium Beast, Unaligned\nAC 12 \t\t ...

330 rows × 33 columns

monsters.columns
Index(['name', 'category', 'cr', 'size', 'type', 'descriptive_tags',
       'alignment', 'ac', 'initiative', 'hp', 'hp_number', 'speed',
       'speed_base_number', 'str', 'dex', 'con', 'int', 'wis', 'cha',
       'str_save', 'dex_save', 'con_save', 'int_save', 'wis_save', 'cha_save',
       'skills', 'resistances', 'vulnerabilities', 'immunities', 'gear',
       'senses', 'languages', 'full_text'],
      dtype='object')
monsters.describe()
cr ac initiative hp_number speed_base_number str dex con int wis cha str_save dex_save con_save int_save wis_save cha_save
count 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000 330.000000
mean 4.551136 14.287879 3.148485 86.669697 30.878788 15.384848 12.833333 15.178788 7.863636 11.815152 9.918182 2.675758 2.118182 2.784848 -1.093939 1.872727 0.003030
std 5.797444 3.149589 3.944803 102.140570 12.339566 6.520047 3.261563 4.404492 5.675860 2.966748 5.969220 3.532010 2.452213 2.869886 3.224190 2.967224 3.524554
min 0.000000 5.000000 -5.000000 1.000000 5.000000 1.000000 1.000000 8.000000 1.000000 3.000000 1.000000 -5.000000 -5.000000 -1.000000 -5.000000 -4.000000 -5.000000
25% 0.500000 12.000000 1.000000 18.250000 30.000000 11.000000 10.000000 12.000000 2.000000 10.000000 5.000000 0.000000 1.000000 1.000000 -4.000000 0.000000 -3.000000
50% 2.000000 14.000000 2.000000 52.000000 30.000000 16.000000 13.000000 14.500000 7.000000 12.000000 8.000000 3.000000 2.000000 2.000000 -2.000000 1.000000 -1.000000
75% 6.000000 17.000000 4.000000 119.000000 40.000000 19.000000 15.000000 17.000000 12.000000 13.000000 14.000000 4.000000 3.000000 4.000000 1.000000 3.000000 2.000000
max 30.000000 25.000000 20.000000 697.000000 60.000000 30.000000 28.000000 30.000000 25.000000 25.000000 30.000000 17.000000 10.000000 15.000000 12.000000 12.000000 12.000000
monsters.groupby(["senses"]).size().sort_values(ascending=False)
senses
Darkvision 60 ft.; Passive Perception 10                         24
Passive Perception 10                                            23
Darkvision 60 ft.; Passive Perception 14                         15
Darkvision 60 ft.; Passive Perception 13                         15
Darkvision 60 ft.; Passive Perception 15                         14
                                                                 ..
Darkvision 30 ft.; Passive Perception 11                          1
Darkvision 30 ft.; Passive Perception 13                          1
Darkvision 30 ft.; Passive Perception 9                           1
Darkvision 60 ft., Tremorsense 120 ft.; Passive Perception 16     1
Truesight 60 ft.; Passive Perception 19                           1
Length: 100, dtype: int64
monsters.groupby(["size"]).describe()["cr"] 
count mean std min 25% 50% 75% max
size
Gargantuan 15.0 21.066667 4.366539 11.000 20.0000 22.00 23.000 30.0
Huge 34.0 9.264706 5.088959 2.000 5.0000 8.50 13.750 19.0
Large 107.0 5.003505 4.626958 0.125 1.5000 4.00 7.500 21.0
Medium 90.0 2.183333 3.178238 0.000 0.2500 1.00 3.000 21.0
Medium or Small 36.0 3.524306 3.635124 0.000 0.8750 3.00 5.000 15.0
Small 23.0 0.271739 0.254650 0.000 0.0625 0.25 0.500 1.0
Tiny 25.0 0.235000 0.491225 0.000 0.0000 0.00 0.125 2.0
sns.heatmap(monsters.select_dtypes(include='number').corr())

monsters['senses'].values[:10]
array(['Darkvision 120 ft.; Passive Perception 20',
       'Darkvision 60 ft.; Passive Perception 10',
       'Blindsight 60 ft.; Passive Perception 6',
       'Blindsight 60 ft.; Passive Perception 7',
       'Blindsight 60 ft.; Passive Perception 6',
       'Darkvision 60 ft., Tremorsense 60 ft.; Passive Perception 11',
       'Passive Perception 16', 'Passive Perception 10',
       'Passive Perception 10', 'Passive Perception 10'], dtype=object)
monsters[monsters['senses'].astype(str).str.contains('unimpeded', case=False, na=False)]
name category cr size type descriptive_tags alignment ac initiative hp ... wis_save cha_save skills resistances vulnerabilities immunities gear senses languages full_text
14 Barbed Devil Barbed Devil 5.0 Medium Fiend Devil Lawful Evil 15 3 110 (13d8 + 52) ... 5 5 Deception +5, Insight +5, Perception +8 Cold NaN Fire, Poison; Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Infernal; telepathy 120 ft. Barbed Devil\nMedium Fiend (Devil), Lawful Evi...
16 Bearded Devil Bearded Devil 3.0 Medium Fiend Devil Lawful Evil 13 2 58 (9d8 + 18) ... 0 4 NaN Cold NaN Fire, Poison; Frightened, Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Infernal; telepathy 120 ft. Bearded Devil\nMedium Fiend (Devil), Lawful Ev...
29 Bone Devil Bone Devil 9.0 Large Fiend Devil Lawful Evil 16 7 161 (17d10 + 68) ... 6 7 Deception +7, Insight +6 Cold NaN Fire, Poison; Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Infernal; telepathy 120 ft. Bone Devil\nLarge Fiend (Devil), Lawful Evil\n...
42 Chain Devil Chain Devil 8.0 Medium Fiend Devil Lawful Evil 15 5 85 (10d8 + 40) ... 4 2 NaN Bludgeoning, Cold, Piercing, Slashing NaN Fire, Poison; Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Infernal; telepathy 120 ft. Chain Devil\nMedium Fiend (Devil), Lawful Evil...
117 Horned Devil Horned Devil 11.0 Large Fiend Devil Lawful Evil 18 7 199 (19d10 + 95) ... 7 8 NaN Cold NaN Fire, Poison; Poisoned NaN Darkvision 150 ft. (unimpeded by magical Darkn... Infernal; telepathy 120 ft. Horned Devil\nLarge Fiend (Devil), Lawful Evil...
120 Imp Imp 1.0 Tiny Fiend Devil Lawful Evil 13 3 21 (6d4 + 6) ... 1 2 Deception +4, Insight +3, Stealth +5 Cold NaN Fire, Poison; Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Common, Infernal Imp\nTiny Fiend (Devil), Lawful Evil\nAC 13 \t...
128 Lemure Lemure 0.0 Medium Fiend Devil Lawful Evil 9 -3 9 (2d8) ... 0 -4 NaN Cold NaN Fire, Poison; Charmed, Frightened, Poisoned NaN Darkvision 120 ft. (unimpeded by magical Darkn... Understands Infernal but can’t speak Lemure\nMedium Fiend (Devil), Lawful Evil\nAC ...

7 rows × 33 columns

import re
def extract_senses(sense_str):
    result = {}
    if not isinstance(sense_str, str):
        return result

    # Senses to extract
    sense_names = ['darkvision', 'blindsight', 'tremorsense', 'truesight']
    
    # General pattern for senses with ft.
    for match in re.findall(r'([a-zA-Z]+)\s+(\d+)\s*ft*\.?', sense_str, flags=re.IGNORECASE):
        name, value = match
        name = name.strip().lower()
        if name in sense_names:
            result[name] = int(value)
    
    # Passive Perception (no ft.)
    pp_match = re.search(r'Passive Perception\s+(\d+)', sense_str, flags=re.IGNORECASE)
    if pp_match:
        result['passive perception'] = int(pp_match.group(1))
    
    return result
senses_df = monsters['senses'].apply(extract_senses).apply(pd.Series).fillna(0).astype(int)
senses_df
#(senses_df['passive perception'] == 0).any()
#senses_df[(senses_df == 0).sum(axis=1) == 2]
#senses_df[senses_df["tremorsense"] > 0]
darkvision passive perception blindsight tremorsense truesight
0 120 20 0 0 0
1 60 10 0 0 0
2 0 6 60 0 0
3 0 7 60 0 0
4 0 6 60 0 0
... ... ... ... ... ...
325 0 10 10 0 0
326 0 13 0 0 0
327 0 11 0 0 0
328 60 13 0 0 0
329 60 15 0 0 0

330 rows × 5 columns

senses_df_filtered = senses_df[(senses_df == 0).sum(axis=1) != 2]
senses_df_filtered
darkvision passive perception blindsight tremorsense truesight
0 120 20 0 0 0
1 60 10 0 0 0
2 0 6 60 0 0
3 0 7 60 0 0
4 0 6 60 0 0
... ... ... ... ... ...
325 0 10 10 0 0
326 0 13 0 0 0
327 0 11 0 0 0
328 60 13 0 0 0
329 60 15 0 0 0

279 rows × 5 columns

monsters_mod = pd.concat([monsters, senses_df], axis=1)
monsters_mod = monsters_mod[["size"]+list(monsters_mod.columns[-5:])]
import seaborn as sns
long_df = pd.melt(
    senses_df, #filtered
    id_vars=['passive perception'], #,'size'],             
    value_vars=['darkvision', 'blindsight', 'tremorsense', 'truesight'], 
    var_name='sense_type',
    value_name='distance'
)
long_df #[(long_df["sense_type"] == "tremorsense") & (long_df["distance"] != 0)]
passive perception sense_type distance
0 20 darkvision 120
1 10 darkvision 60
2 6 darkvision 0
3 7 darkvision 0
4 6 darkvision 0
... ... ... ...
1315 10 truesight 0
1316 13 truesight 0
1317 11 truesight 0
1318 13 truesight 0
1319 15 truesight 0

1320 rows × 3 columns

Two issues with the plot below.

  1. Cases with only passive perception are not there. If only long_df is used then there are lot many extra points.
  2. Two points for cases with more than two senses.
sns.set_theme(style="dark", font="Comic Sans MS")
current_style = sns.axes_style()
colors = ["grey", "orange", "dodgerblue", "salmon"]
plot1 = sns.catplot(data=long_df[long_df["distance"] != 0], x="distance", y="passive perception", hue="sense_type",\
            kind="strip", dodge=True, height=4, aspect=2, size=4, native_scale=True,\
                   jitter=0.25, palette=colors)
plot1._legend.remove()
plot1.add_legend(title='', ncol=4, bbox_to_anchor=(0.5, 1.05))
for ind, text in enumerate(plot1._legend.texts):
    text.set_color(colors[ind]) 
for handle in plot1._legend.legend_handles:
    handle.set_visible(False)
plot1.fig.set_facecolor(current_style['axes.facecolor'])
sns.despine(left=True, bottom=True, right=True, top=True)
for ax in plot1.axes.flat:
    ax.grid(axis='y', which='major')

plt.xticks(ticks=range(0,151,30))
plt.xlabel("Distance (feet)")
#plt.savefig("senses.png", dpi=300, bbox_inches="tight")
plt.show()

#plot1 = sns.catplot(data=long_df, x="distance", y="passive perception", hue="size",\
#            kind="strip", dodge=True, height=4, aspect=2, size=4, native_scale=True,\
#                   jitter=1, col="sense_type", col_wrap=2)
#plot1._legend.remove()
#plot1.add_legend(title='', ncol=7, bbox_to_anchor=(0.5, 1.05))
#plt.xticks(ticks=range(0,151,30))
#plt.xlabel("Distance (feet)")
#plt.tight_layout()
#plt.show()
sns.dark_palette("xkcd:golden", 4)

Upset plot

from upsetplot import plot
from upsetplot import UpSet
import textwrap
sns.reset_defaults()
binary_df = senses_df > 0
counts = binary_df.value_counts().sort_values(ascending=False)
upset = UpSet(counts, sort_by= "cardinality", show_percentages=True, facecolor="dodgerblue")
upset.style_subsets(present="truesight", edgecolor="lightgreen", linewidth=1)
upset.style_subsets(present="tremorsense", edgecolor="salmon", linewidth=1)

#upset.add_catplot(value="progression", kind="strip", color="blue")
upset.plot()
for ind, ax in enumerate(plt.gcf().get_axes()):
    if(ind == 3):
        ax.set_yticks(range(0,151,50))
        ax.set_facecolor("whitesmoke")
        ax.yaxis.grid(True, color="#D0D0D0")
    if(ind == 2):
        ax.xaxis.grid(True, color="#D0D0D0")
    ax.spines['left'].set_visible(False)
    ax.tick_params(axis='both', length=0)

    for text in ax.texts:
        if "%" in text.get_text():  
            text.set_fontsize(9)
#                text.set_fontfamily("Consolas")
title_text = "Frequency of five senses and their combinations across all the characters in Dungeons and Dragons Monsters (2024)"
plt.suptitle("\n".join(textwrap.wrap(title_text, width=20)), x=0.075, y=0.8, ha="left", fontfamily="Serif")
plt.savefig("senses_comb.png", dpi=300, facecolor="whitesmoke", bbox_inches='tight')
plt.show()