library(tidyverse)
library(tidytext)
library(widyr)
library(igraph)
library(ggraph)
library(Matrix)
library(atrrr)
auth(
  user = "chinstigator.bsky.social",
  password = "3fvw-qplv-g53i-km3d"
)
## A token already exists on disk. Do you want to overwrite it? (yes/No/cancel)

Introduction

This project applies word co-occurrence analysis to Bluesky posts about the Nintendo Switch 2. A simple word count can show which words appear most often, but co-occurrence analysis gives more detail because it shows which words appear together in the same post. This helps explain how topics are connected in the conversation.

For example, if price often appears with expensive, that may suggest users are concerned about cost. If preorder appears with stock, sold, or available, that may suggest concerns about availability. If Mario, Zelda, or launch appear together, that may suggest excitement about Nintendo first-party games.

This matters from a business analytics perspective because companies can use social media text to understand what customers are paying attention to. For Nintendo, retailers, accessory companies, and game publishers, these patterns could help identify customer expectations, concerns, and marketing opportunities around the Nintendo Switch 2.

What is Word Co-occurrence Analysis?

Word co-occurrence analysis looks at which words tend to appear together within the same unit of text. In this project, each Bluesky post is treated as one document. If two words appear in the same post, they are counted as a co-occurring pair.

The basic workflow is:

  1. Collect posts from Bluesky.
  2. Clean and tokenize the text.
  3. Remove stop words and web artifacts.
  4. Normalize similar words.
  5. Filter for Nintendo-related words.
  6. Count word pairs.
  7. Build a co-occurrence matrix.
  8. Visualize the strongest relationships as a network graph.
  9. Interpret the results as business insight.

Part 2: Word Co-occurrence Analysis

Real-World Example: Nintendo Switch 2 on Bluesky

This analysis uses Bluesky posts collected with the search term “Nintendo Switch 2”. The goal is to identify which words appear together most often in public discussion around the console. I am especially interested in whether the conversation clusters around price, preorders, games, launch availability, hardware, accessories, or comparisons to other gaming products.

Step 1: Collect Posts from Bluesky API

posts <- search_post("Nintendo Switch 2", limit = 100)

posts_df <- posts %>%
  as_tibble() %>%
  filter(!is.na(text)) %>%
  distinct(text, .keep_all = TRUE)

cat("Total unique posts collected:", nrow(posts_df), "\n")
## Total unique posts collected: 99
posts_df %>%
  select(text) %>%
  head(5)
## # A tibble: 5 × 1
##   text                                                                          
##   <chr>                                                                         
## 1 "Nintendo Switch 2 Final Fantasy VII Rebirth #Mediamarkt\n\n👍🏻👍🏻 Precio Ahora: …
## 2 "🔴 MARIO TENNIS FEVER para Nintendo Switch 2...\n\n⭐️ ¡55 €!\n\ntinyurl.com/mn…
## 3 "For the time being, I'm going to play some of my Nintendo Switch and Nintend…
## 4 "🔴 YOSHI Y EL LIBRO MISTERIOSO para Nintendo Switch 2 (y viene en el cartucho…
## 5 "BREAKING NEWS:\n\nFormer PlayStation spokesperson and gamer, Maggie, is now …

Each post is treated as its own document. This means the analysis looks for words that appear together inside the same Bluesky post.

Step 2: Tokenize and Clean the Text

Real social media text contains URLs, usernames, hashtags, punctuation, numbers, emojis, and repeated search terms. These need to be cleaned so the final network focuses on meaningful words.

I removed common stop words and custom stop words such as nintendo, switch, switch2, https, www, and com. The words nintendo and switch are removed because they are part of the search topic itself. If they stayed in the dataset, they would dominate the graph and connect to almost everything.

data(stop_words)

custom_stop_words <- bind_rows(
  stop_words,
  tibble(
    word = c(
      "nintendo", "switch", "switch2", "2",
      "https", "http", "www", "com", "amp", "rt",
      "bsky", "social",
      "im", "ive", "dont", "didnt", "isnt", "cant", "wont",
      "people", "person", "thing", "things", "time", "day", "today",
      "just", "really", "like", "know", "think", "make", "made", "got",
      "get", "getting", "gonna", "wanna", "yeah", "lol"
    ),
    lexicon = "custom"
  )
)

switch_text <- posts_df %>%
  mutate(
    post_id = row_number(),
    text = str_to_lower(text),
    text = str_replace_all(text, "https?://\\S+", " "),
    text = str_replace_all(text, "@[\\w\\.]+", " "),
    text = str_replace_all(text, "#", " "),
    text = str_replace_all(text, "[^[:alnum:][:space:]]", " ")
  )

switch_words <- switch_text %>%
  unnest_tokens(word, text, token = "words") %>%
  mutate(
    word = case_when(
      word %in% c("preorder", "preorders", "preordered", "preordering") ~ "preorder",
      word %in% c("games", "gaming", "gameplay", "gamer", "gamers") ~ "game",
      word %in% c("prices", "priced", "pricing") ~ "price",
      word %in% c("launches", "launched", "launching") ~ "launch",
      word %in% c("zelda", "totk", "botw") ~ "zelda",
      word %in% c("mario", "kart") ~ "mario",
      word %in% c("joycon", "joycons", "joy", "con") ~ "joycon",
      word %in% c("controllers", "controller") ~ "controller",
      word %in% c("consoles", "console") ~ "console",
      word %in% c("stocks", "stock", "restock", "restocks") ~ "stock",
      word %in% c("expensive", "cost", "costs") ~ "expensive",
      TRUE ~ word
    )
  ) %>%
  filter(!str_detect(word, "^[0-9]+$")) %>%
  filter(str_detect(word, "^[a-z]+$")) %>%
  anti_join(custom_stop_words, by = "word")

switch_words %>%
  count(word, sort = TRUE) %>%
  head(20)
## # A tibble: 20 × 2
##    word           n
##    <chr>      <int>
##  1 game          38
##  2 de            23
##  3 amazon        18
##  4 joycon        15
##  5 final         13
##  6 fantasy       12
##  7 para          12
##  8 em            11
##  9 link          11
## 10 cupom          9
## 11 edition        9
## 12 eshop          9
## 13 impossible     9
## 14 ad             8
## 15 el             8
## 16 en             8
## 17 frete          8
## 18 news           8
## 19 tinyurl        8
## 20 jogobara       7

Optional Comparison: Unfiltered Bluesky Word Network

The graph below keeps the broader cleaned Bluesky vocabulary before applying the Nintendo-related word filter. I included this version as a comparison because it shows why extra filtering is helpful. Without the Nintendo-related filter, the network can become very crowded and may include random words, usernames, store names, non-English words, or web artifacts.

This graph is still useful because it shows the raw conversation pattern, but it is harder to interpret for a business audience. The filtered graphs later in the report are cleaner and more focused on Nintendo Switch 2 topics.

switch_pairs_unfiltered <- switch_words %>%
  pairwise_count(word, post_id, sort = TRUE, upper = FALSE)

switch_pairs_unfiltered %>%
  head(20)
## # A tibble: 20 × 3
##    item1      item2          n
##    <chr>      <chr>      <dbl>
##  1 de         amazon         9
##  2 final      fantasy        7
##  3 amazon     em             7
##  4 de         joycon         7
##  5 news       game           6
##  6 amazon     cupom          6
##  7 final      impossible     6
##  8 fantasy    impossible     6
##  9 de         en             5
## 10 de         em             5
## 11 amazon     frete          5
## 12 em         frete          5
## 13 amazon     joycon         5
## 14 final      square         5
## 15 fantasy    square         5
## 16 impossible square         5
## 17 final      enix           5
## 18 fantasy    enix           5
## 19 impossible enix           5
## 20 square     enix           5
set.seed(123)

switch_pairs_unfiltered %>%
  filter(n >= 2) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = n), show.legend = FALSE) +
  geom_node_point(size = 3) +
  geom_node_text(aes(label = name), repel = TRUE, size = 3) +
  theme_void() +
  labs(
    title = "Unfiltered Word Co-occurrence Network: Nintendo Switch 2 on Bluesky",
    subtitle = "This broader version shows why additional Nintendo-related filtering is useful"
  )

Step 4: Count Word Pairs

The pairwise_count() function counts how often two words appear in the same post. Each pair represents a connection between two words.

switch_pairs <- switch_words_filtered %>%
  pairwise_count(word, post_id, sort = TRUE, upper = FALSE)

switch_pairs %>%
  head(20)
## # A tibble: 20 × 3
##    item1  item2         n
##    <chr>  <chr>     <dbl>
##  1 amazon joycon        5
##  2 game   console       4
##  3 target link          3
##  4 game   amazon        3
##  5 game   physical      3
##  6 game   launch        2
##  7 link   amazon        2
##  8 amazon zelda         2
##  9 link   console       2
## 10 amazon console       2
## 11 game   link          1
## 12 game   exclusive     1
## 13 mario  amazon        1
## 14 link   zelda         1
## 15 amazon digital       1
## 16 zelda  digital       1
## 17 game   graphics      1
## 18 launch graphics      1
## 19 game   donkey        1
## 20 amazon donkey        1

Step 5: Build the Co-occurrence Matrix

A co-occurrence matrix shows how often the most common words appear together. To keep the matrix readable, I limited it to the top 10 most frequent Nintendo-related words.

top_words <- switch_words_filtered %>%
  count(word, sort = TRUE) %>%
  slice_max(n, n = 10) %>%
  pull(word)

switch_matrix <- switch_pairs %>%
  filter(item1 %in% top_words, item2 %in% top_words) %>%
  bind_rows(
    switch_pairs %>%
      rename(item1 = item2, item2 = item1) %>%
      filter(item1 %in% top_words, item2 %in% top_words)
  ) %>%
  cast_sparse(item1, item2, n) %>%
  as.matrix()

switch_matrix
##            joycon console link amazon physical launch zelda pokemon controller
## amazon          5       2    2      0        0      0     2       1          1
## game            0       4    1      3        3      2     0       1          0
## target          0       0    3      0        0      0     0       0          0
## link            1       2    0      2        0      0     1       0          1
## mario           1       0    0      1        0      0     0       0          0
## console         1       0    2      2        0      0     0       0          0
## pokemon         0       0    0      1        0      0     0       0          1
## joycon          0       1    1      5        0      0     0       0          0
## physical        0       0    0      0        0      0     0       0          0
## launch          0       0    0      0        0      0     0       0          0
## zelda           0       0    1      2        0      0     0       0          0
## controller      0       0    1      1        0      0     0       1          0
##            game target mario
## amazon        3      0     1
## game          0      0     0
## target        0      0     0
## link          1      3     0
## mario         0      0     0
## console       4      0     0
## pokemon       1      0     0
## joycon        0      0     1
## physical      3      0     0
## launch        2      0     0
## zelda         0      0     0
## controller    0      0     0

Step 6: Visualize the Co-occurrence Network

The graph below shows word pairs that appeared together at least twice. The thicker the edge, the more often the two words appeared together in the same Bluesky posts.

set.seed(123)

switch_pairs %>%
  filter(n >= 2) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = n, edge_width = n), show.legend = FALSE) +
  geom_node_point(size = 4) +
  geom_node_text(aes(label = name), repel = TRUE, size = 3.5) +
  theme_void() +
  labs(
    title = "Word Co-occurrence Network: Nintendo Switch 2 on Bluesky",
    subtitle = "Filtered to Nintendo-related words appearing together at least twice"
  )

Step 7: Compare a Higher Filter Threshold

A higher threshold makes the graph less crowded and highlights only the strongest relationships. This graph only shows word pairs that appeared together more than five times.

set.seed(123)

switch_pairs %>%
  filter(n > 5) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = n, edge_width = n), show.legend = FALSE) +
  geom_node_point(size = 4) +
  geom_node_text(aes(label = name), repel = TRUE, size = 3.5) +
  theme_void() +
  labs(
    title = "Strongest Word Co-occurrences: Nintendo Switch 2 on Bluesky",
    subtitle = "Filtered to word pairs appearing together more than five times"
  )

# Interpretation No network graph was produced using the threshold of n > 5 because none of the Nintendo-related word pairs appeared together more than five times in the sample of 100 Bluesky posts.

This suggests that the conversation was spread across a variety of Nintendo Switch 2 topics rather than being dominated by a few highly repeated word pairs. Because the dataset is relatively small, increasing the threshold removed all remaining connections from the network.

The graph created using the lower threshold (n ≥ 2) provides a more meaningful visualization because it captures the strongest relationships that actually exist within this sample. If a much larger collection of Bluesky posts were analyzed, stronger and more frequent co-occurrence patterns would likely appear, making a higher threshold more useful.

Assignment Questions

1. What are the most common keywords?

The most common keywords are the Nintendo-related words that appeared most often in the Bluesky posts. These may include words related to games, launch timing, price, preorders, availability, hardware, and Nintendo franchises.

The exact results depend on the 100 posts collected at the time the code is run. This is important because social media data changes over time. If the code is run on a different day, the most common words may change.

2. How did changing the filter threshold affect the graph?

Changing the filter threshold affects how crowded the network graph looks. A low threshold includes more word pairs, including weaker relationships that may have only appeared a few times. This can make the graph dense and harder to interpret.

A higher threshold removes less frequent word pairs and keeps only the strongest repeated patterns. For this project, the higher threshold is usually better for communication because it makes the Nintendo Switch 2 discussion easier to understand. A business audience would likely prefer the cleaner graph because the main relationships are easier to see.

3. What does the network graph tell you?

The network graph shows which Nintendo Switch 2 topics are discussed together most often on Bluesky. Instead of only showing which words are common, it shows how ideas are connected.

For example, the graph can show whether users connect the console with price, preorders, stock, launch games, hardware features, or Nintendo franchises. These connections can reveal what people care about most in the conversation.

4. Were any word pairs surprising?

Some word pairs may be surprising if they reveal unexpected customer concerns. For example, if price appears often with expensive, that suggests affordability is an important issue. If preorder appears with stock or sold, then availability may be a major concern. If mario or zelda appears with launch, that suggests users are connecting the console to expected first-party games.

These relationships are useful because they show not just what words are common, but what ideas users connect together.

5. Why is token normalization important?

Token normalization is important because social media users often refer to the same idea in different ways. For example, preorder, preorders, preordered, and preordering all refer to the same concept. Similarly, games, gaming, and gameplay are closely related.

If these are left separate, the co-occurrence signal gets split across multiple nodes. Normalizing them makes the final graph cleaner and more accurate because related versions of the same term are grouped together.

6. What are the limitations of this analysis?

This analysis has several limitations. First, only 100 Bluesky posts were collected, which is a small sample. Second, the sample is query-seeded, meaning it only includes posts that matched the search phrase “Nintendo Switch 2”. Third, Bluesky users are not necessarily representative of all Nintendo customers or all social media users.

Another limitation is that the Nintendo-related word filter focuses the graph but may also remove some useful words that were not included in the list. Because of this, the findings should be described as exploratory rather than definitive.

7. How could businesses use this information?

Businesses can use this type of analysis to identify popular topics, customer interests, and emerging concerns. Nintendo could monitor whether people are focused on price, games, launch timing, hardware features, or availability. Retailers could use preorder and stock discussion to understand demand. Accessory companies could look for discussion around controllers, docks, storage, and other hardware needs.

This type of analysis can also help businesses compare public reaction before and after major events, such as a Nintendo Direct, launch date announcement, preorder opening, or major game reveal.

Conclusion

Word co-occurrence analysis helps turn messy Bluesky posts into clearer business insight. For Nintendo Switch 2 discussion, this method shows not only which words are common, but also how ideas are connected.

By filtering for Nintendo-related terms, the final graph becomes more focused and easier to interpret. The results can help show whether Bluesky users are mainly discussing games, price, preorders, availability, hardware, or Nintendo franchises. While the analysis is limited by sample size and platform, it still provides a useful example of how text analysis can be applied to real social media data.

References

Silge, J., & Robinson, D. (2017). Text Mining with R: A Tidy Approach. O’Reilly Media. https://www.tidytextmining.com/

Csardi, G., & Nepusz, T. (2006). The igraph software package for complex network research. InterJournal, Complex Systems, 1695. https://igraph.org

Pedersen, T. L. (2024). ggraph: An implementation of grammar of graphics for graphs and networks [R package documentation]. https://CRAN.R-project.org/package=ggraph

Robinson, D. (2021). widyr: Widen, process, then re-tidy data [R package documentation]. https://CRAN.R-project.org/package=widyr