---
title: "The Sherlock Holmes Canon Thematic Word Networks"
subtitle: "Each of the 15 most-discussed stories is linked to its five most distinctive dialogue words. Cluster size reflects vocabulary uniqueness. Purple cluster highlights Sign of the Four's distinctive vocabulary."
description: "Network analysis of Sherlock Holmes stories using TF-IDF to identify distinctive dialogue vocabulary. Built with R, tidytext, and ggraph to visualize thematic word relationships across 15 stories from the Holmes canon."
date: "2025-11-16"
categories: ["TidyTuesday", "2025"]
tags: [
"Network Analysis",
"Text Mining",
"TF-IDF",
"Natural Language Processing",
"Literary Analysis",
"Sherlock Holmes",
"ggraph",
"tidytext",
"igraph",
"tidygraph",
"Network Visualization",
"Word Networks",
"Dialogue Analysis",
"Arthur Conan Doyle",
"ggplot2"
]
image: "thumbnails/tt_2025_46.png"
format:
html:
toc: true
toc-depth: 5
code-link: true
code-fold: true
code-tools: true
code-summary: "Show code"
editor_options:
chunk_output_type: inline
execute:
freeze: true
cache: true
error: false
message: false
warning: false
eval: true
---
{#fig-1}
### <mark> **Steps to Create this Graphic** </mark>
#### 1. Load Packages & Setup
```{r}
#| label: load
#| warning: false
#| message: false
#| results: "hide"
## 1. LOAD PACKAGES & SETUP ----
suppressPackageStartupMessages({
if (!require("pacman")) install.packages("pacman")
pacman::p_load(
tidyverse, # Easily Install and Load the 'Tidyverse'
ggtext, # Improved Text Rendering Support for 'ggplot2'
showtext, # Using Fonts More Easily in R Graphs
janitor, # Simple Tools for Examining and Cleaning Dirty Data
scales, # Scale Functions for Visualization
tidygraph, # A Tidy API for Graph Manipulation
igraph, # Network Analysis and Visualization
ggraph, # An Implementation of Grammar of Graphics for Graphs and Networks
ggrepel, # Automatically Position Non-Overlapping Text Labels with 'ggplot2'
tidytext # Text Mining using 'dplyr', 'ggplot2', and Other Tidy Tools
)
})
### |- figure size ----
camcorder::gg_record(
dir = here::here("temp_plots"),
device = "png",
width = 10,
height = 8,
units = "in",
dpi = 320
)
# Source utility functions
suppressMessages(source(here::here("R/utils/fonts.R")))
source(here::here("R/utils/social_icons.R"))
source(here::here("R/utils/image_utils.R"))
source(here::here("R/themes/base_theme.R"))
```
#### 2. Read in the Data
```{r}
#| label: read
#| include: true
#| eval: true
#| warning: false
tt <- tidytuesdayR::tt_load(2025, week = 46)
holmes <- tt$holmes |> clean_names()
tidytuesdayR::readme(tt)
rm(tt)
```
#### 3. Examine the Data
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false
glimpse(holmes)
skimr::skim(holmes) |> summary()
```
#### 4. Tidy Data
```{r}
#| label: tidy-fixed
#| warning: false
holmes_clean <- holmes |>
filter(!is.na(text), nchar(text) > 5) |>
filter(
!str_detect(text, "^[A-Z ]+$"),
!str_detect(text, "^(Table of|CHAPTER|Part [IVX]+|\\d+$)")
) |>
mutate(
has_quotes = str_detect(text, '"'),
speaker = case_when(
str_detect(text, "(?i)said Holmes|Holmes said|Holmes replied|Holmes answered|Holmes asked") ~ "Holmes",
str_detect(text, "(?i)said I|I said|I replied|I answered|I asked") ~ "Watson",
str_detect(text, "(?i)he said|she said|he replied|she replied") ~ "Other",
has_quotes ~ "Unknown",
TRUE ~ "Narrative"
),
dialogue_text = str_extract_all(text, '"([^"]*)"') |>
map_chr(~ {
if (length(.x) > 0) {
str_remove_all(.x, '"') |>
str_trim() |>
str_c(collapse = " ")
} else {
NA_character_
}
})
)
# Word counts & TF–IDF
book_words <- holmes_clean |>
filter(speaker %in% c("Holmes", "Watson")) |>
filter(!is.na(dialogue_text)) |>
select(book, dialogue_text) |>
unnest_tokens(word, dialogue_text) |>
anti_join(stop_words, by = "word") |>
filter(nchar(word) >= 4) |>
count(book, word, sort = TRUE)
book_tfidf <- book_words |>
bind_tf_idf(word, book, n) |>
arrange(desc(tf_idf))
top_books <- book_words |>
count(book, sort = TRUE) |>
slice_head(n = 15) |>
pull(book)
distinctive_words <- book_tfidf |>
filter(book %in% top_books) |>
group_by(book) |>
slice_max(tf_idf, n = 5) |>
ungroup()
edges <- distinctive_words |>
select(from = word, to = book, weight = tf_idf)
graph <- graph_from_data_frame(edges, directed = FALSE)
V(graph)$degree <- degree(graph)
tbl_graph <- as_tbl_graph(graph)
# Short book labels and node attributes
book_labels <- tibble(book = unique(distinctive_words$book)) |>
mutate(
book_label = book |>
str_remove("^The Adventure of the ") |>
str_remove("^The Adventure of ") |>
str_remove("^The ") |>
str_remove("^A ")
)
tbl_graph <- tbl_graph |>
activate(nodes) |>
left_join(book_labels, by = c("name" = "book")) |>
mutate(
node_type = if_else(name %in% top_books, "Book", "Word"),
size_metric = if_else(node_type == "Book", degree, 1),
node_type_fct = factor(node_type, levels = c("Book", "Word")),
display_label = if_else(node_type == "Book", book_label, NA_character_),
# Highlight Sign of the Four cluster
is_sign_cluster = name == "The Sign of the Four" |
str_detect(name, "Sign of the Four")
)
# Mark edges connected to "Sign of the Four"
tbl_graph <- tbl_graph |>
activate(edges) |>
mutate(
from_node = .N()$name[from],
to_node = .N()$name[to],
is_sign_edge = from_node == "The Sign of the Four" |
to_node == "The Sign of the Four"
)
# Create layout
set.seed(221)
layout_tbl <- ggraph::create_layout(tbl_graph, layout = "fr")
# Position for "Sign of the Four"
sign_pos <- layout_tbl |>
as_tibble() |>
filter(node_type == "Book", str_detect(name, "Sign of the Four")) |>
slice(1)
annotation_df <- tibble(
x = sign_pos$x + 0.5,
y = sign_pos$y + 3,
xend = sign_pos$x,
yend = sign_pos$y,
label = "'Sign of the Four' features<br>the most distinctive vocabulary."
)
```
#### 5. Visualization Parameters
```{r}
#| label: params
#| include: true
#| warning: false
### |- plot aesthetics ----
colors <- get_theme_colors(
palette = list(
book_col = "#0f9a8a",
word_col = "#b5beca",
highlight_col = "#9b59b6"
)
)
### |- titles and caption ----
title_text <- str_glue("The Sherlock Holmes Canon<br>Thematic Word Networks")
subtitle_text <- str_glue(
"Each of the 15 most-discussed stories is linked to its five most distinctive dialogue words.<br>",
"Cluster size reflects vocabulary uniqueness. Purple cluster highlights Sign of the Four's distinctive vocabulary."
)
caption_text <- create_social_caption(
tt_year = 2025,
tt_week = 46,
source_text = "{ sherlock R package }"
)
### |- fonts ----
setup_fonts()
fonts <- get_font_families()
### |- plot theme ----
# Start with base theme
base_theme <- create_base_theme(colors)
# Add weekly-specific theme elements
weekly_theme <- extend_weekly_theme(
base_theme,
theme(
# Text styling
plot.title = element_markdown(
face = "bold", family = fonts$title, size = rel(1.4),
color = colors$title, margin = margin(b = 10), hjust = 0
),
plot.subtitle = element_text(
face = "italic", family = fonts$subtitle, lineheight = 1.2,
color = colors$subtitle, size = rel(0.9), margin = margin(b = 20), hjust = 0
),
## Grid
# panel.grid.major.y = element_blank(),
# panel.grid.minor = element_blank(),
# panel.grid.major.x = element_line(color = "gray90", linewidth = 0.3),
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
panel.grid = element_blank(),
# Axes
axis.title = element_text(size = rel(0.8), color = "gray30"),
axis.text = element_text(color = "gray30"),
axis.text.y = element_text(size = rel(0.85)),
axis.ticks = element_blank(),
# Facets
strip.background = element_rect(fill = "gray95", color = NA),
strip.text = element_text(
face = "bold",
color = "gray20",
size = rel(1),
margin = margin(t = 8, b = 8)
),
panel.spacing = unit(2, "lines"),
# Legend elements
legend.position = "plot",
legend.title = element_text(
family = fonts$tsubtitle,
color = colors$text, size = rel(0.8), face = "bold"
),
legend.text = element_text(
family = fonts$tsubtitle,
color = colors$text, size = rel(0.7)
),
legend.margin = margin(t = 15),
# Plot margin
plot.margin = margin(20, 20, 20, 20)
)
)
# Set theme
theme_set(weekly_theme)
```
#### 6. Plot
```{r}
#| label: plot
#| warning: false
### |- main plot ----
p <-
ggraph(layout_tbl) +
# Geoms
geom_edge_link(
aes(
color = is_sign_edge,
alpha = is_sign_edge,
# width = is_sign_edge
)
) +
scale_edge_color_manual(
values = c("TRUE" = colors$palette$highlight_col, "FALSE" = "grey70"),
guide = "none"
) +
scale_edge_alpha_manual(
values = c("TRUE" = 0.28, "FALSE" = 0.25),
guide = "none"
) +
scale_edge_width_manual(
values = c("TRUE" = 0.28, "FALSE" = 0.30),
guide = "none"
) +
geom_node_point(
data = ~ filter(., node_type == "Book"),
aes(size = size_metric * 1.35),
color = if_else(
layout_tbl |> filter(node_type == "Book") |> pull(is_sign_cluster),
colors$palette$highlight_col,
colors$palette$book_col
),
alpha = 0.25,
show.legend = FALSE
) +
geom_node_point(
aes(size = size_metric),
shape = 21,
fill = case_when(
layout_tbl$is_sign_cluster & layout_tbl$node_type == "Book" ~ colors$palette$highlight_col, # Purple Sign book
layout_tbl$node_type == "Book" ~ colors$palette$book_col,
layout_tbl$is_sign_cluster & layout_tbl$node_type == "Word" ~ colors$palette$highlight_col, # Purple Sign words
TRUE ~ colors$palette$word_col
),
color = if_else(
layout_tbl$is_sign_cluster,
colors$palette$highlight_col,
colors$background
),
stroke = if_else(layout_tbl$is_sign_cluster, 0.8, 0.30),
alpha = 0.70,
show.legend = FALSE
) +
geom_node_text(
data = ~ filter(., node_type == "Book"),
aes(label = display_label),
repel = TRUE,
size = 3.0,
fontface = "bold",
family = "text",
color = "grey10",
box.padding = unit(0.3, "lines"),
point.padding = unit(0.3, "lines"),
segment.size = 0.25,
segment.color = "grey65"
) +
geom_richtext(
data = annotation_df,
aes(x = x, y = y, label = label),
family = "text",
size = 3.0,
color = "grey20",
fill = alpha(colors$background, 0.5),
label.colour = NA,
lineheight = 1.05,
label.padding = unit(0.1, "lines")
) +
# Scales
scale_size_continuous(range = c(2.4, 10), guide = "none") +
# Labs
labs(
title = title_text,
subtitle = subtitle_text,
caption = caption_text
) +
# Theme
theme(
plot.title = element_markdown(
size = rel(1.85),
family = fonts$title,
face = "bold",
color = colors$title,
lineheight = 1.15,
margin = margin(t = 8, b = 5)
),
plot.subtitle = element_markdown(
size = rel(0.85),
family = fonts$subtitle,
color = alpha(colors$subtitle, 0.88),
lineheight = 1.2,
margin = margin(t = 2, b = 15)
),
plot.caption = element_markdown(
size = rel(0.55),
family = "Arial",
color = colors$caption,
hjust = 0,
lineheight = 1.3,
margin = margin(t = 12, b = 5)
),
axis.text.y = element_blank(),
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
panel.grid = element_blank()
)
```
#### 7. Save
```{r}
#| label: save
#| warning: false
### |- plot image ----
save_plot(
plot = p,
type = "tidytuesday",
year = 2025,
week = 46,
width = 10,
height = 8,
)
```
#### 8. Session Info
::: {.callout-tip collapse="true"}
##### Expand for Session Info
```{r, echo = FALSE}
#| eval: true
#| warning: false
sessionInfo()
```
:::
#### 9. GitHub Repository
::: {.callout-tip collapse="true"}
##### Expand for GitHub Repo
The complete code for this analysis is available in [`tt_2025_46.qmd`](https://github.com/poncest/personal-website/blob/master/data_visualizations/TidyTuesday/2025/tt_2025_46.qmd).
For the full repository, [click here](https://github.com/poncest/personal-website/).
:::
#### 10. References
::: {.callout-tip collapse="true"}
##### Expand for References
1. **Data Source:**
- TidyTuesday 2025 Week 46: [The Complete Sherlock Holmes](https://github.com/rfordatascience/tidytuesday/blob/main/data/2025/2025-11-18/readme.md)
:::
#### 11. Custom Functions Documentation
::: {.callout-note collapse="true"}
##### 📦 Custom Helper Functions
This analysis uses custom functions from my personal module library for efficiency and consistency across projects.
**Functions Used:**
- **`fonts.R`**: `setup_fonts()`, `get_font_families()` - Font management with showtext
- **`social_icons.R`**: `create_social_caption()` - Generates formatted social media captions
- **`image_utils.R`**: `save_plot()` - Consistent plot saving with naming conventions
- **`base_theme.R`**: `create_base_theme()`, `extend_weekly_theme()`, `get_theme_colors()` - Custom ggplot2 themes
**Why custom functions?**\
These utilities standardize theming, fonts, and output across all my data visualizations. The core analysis (data tidying and visualization logic) uses only standard tidyverse packages.
**Source Code:**\
View all custom functions → [GitHub: R/utils](https://github.com/poncest/personal-website/tree/master/R)
:::