Load Packages

library(tidyverse)
library(tidytext)
library(widyr)
library(igraph)
library(ggraph)
library(Matrix)
library(checkdown)
library(readr)
library(dplyr)
library(stringr)
library(tidytext)

# my_reviews for Knitter
my_reviews <- read_csv("commentswatches.csv")

Raw Text (Comments)

comments_clean <- read_csv("commentswatches.csv", col_select = textOriginal, show_col_types = FALSE)
head(comments_clean)
## # A tibble: 6 × 1
##   textOriginal                                                                  
##   <chr>                                                                         
## 1 "Loved this breakdown of watches under $1,000? Let us know YOUR one-watch pic…
## 2 "please tipe seiko?"                                                          
## 3 "For this price Orient Star is better than all of them! In the class UP TO 70…
## 4 "Don't understand this thought process. It's jewelry, branding, and quality a…
## 5 "Ditch the TRUE GMT term, use TRAVELLER'S GMT instead. That's because you don…
## 6 "‏‪22:19‬‏ i have mr.jon silent theft  and the golden hour"

Step 1: Tokenize and Remove Stop Words

library(widyr)

custom_stop_words <- bind_rows(
  stop_words,
  # Your custom watch stopwords for Question 1!
  tibble(word = c("im", "ive", "video", "dont", "didnt", "isnt", "lol", "watch", "watches", "time"), lexicon = "custom")
)

my_clean_words <- my_reviews %>%
  mutate(id = row_number()) %>% 
  mutate(textOriginal = iconv(textOriginal, to = "UTF-8", sub = "")) %>%
  mutate(textOriginal = str_replace_all(textOriginal, "[^\\x01-\\x7F]", " ")) %>%
  mutate(textOriginal = str_squish(textOriginal)) %>%
  mutate(textOriginal = str_replace_all(textOriginal, "[^[:alnum:][:space:]$]", " ")) %>%
  unnest_tokens(word, textOriginal, token = "words") %>%
  filter(!str_detect(word, "^[0-9]+$")) %>%   
  anti_join(custom_stop_words, by = "word")

# Top Words
my_clean_words %>% count(word, sort = TRUE) %>% head(15)
## # A tibble: 15 × 2
##    word        n
##    <chr>   <int>
##  1 gmt        15
##  2 love       12
##  3 don        10
##  4 true       10
##  5 baltic      8
##  6 certina     8
##  7 list        8
##  8 bulova      7
##  9 rolex       7
## 10 seiko       7
## 11 traska      7
## 12 brands      6
## 13 buy         6
## 14 videos      6
## 15 bought      5

Step 2: Count Word Pairs Within Each Sentence

custom_stop_words <- bind_rows(
  stop_words,
  tibble(word = c("im", "ive", "dont", "didnt", "isnt", "lol", "watch", "watches", "time"), lexicon = "custom")
)

my_clean_words <- my_reviews %>%
  # 1. Fix encoding: Strip out invalid multibyte characters
  mutate(textOriginal = iconv(textOriginal, to = "UTF-8", sub = "")) %>%
  
  # 2. Strip out emojis and invisible formatting tags (keeps only basic text)
  mutate(textOriginal = str_replace_all(textOriginal, "[^\\x01-\\x7F]", " ")) %>%
  
  # 3. Proceed with the standard cleaning pipeline
  mutate(textOriginal = str_squish(textOriginal)) %>%
  mutate(textOriginal = str_replace_all(textOriginal, "[^[:alnum:][:space:]$]", " ")) %>%
  unnest_tokens(word, textOriginal, token = "words") %>%
  filter(!str_detect(word, "^[0-9]+$")) %>%   
  anti_join(custom_stop_words, by = "word")

# View the top 15 most used meaningful words
my_clean_words %>% count(word, sort = TRUE) %>% head(15)
## # A tibble: 15 × 2
##    word        n
##    <chr>   <int>
##  1 video      18
##  2 gmt        15
##  3 love       12
##  4 don        10
##  5 true       10
##  6 baltic      8
##  7 certina     8
##  8 list        8
##  9 bulova      7
## 10 rolex       7
## 11 seiko       7
## 12 traska      7
## 13 brands      6
## 14 buy         6
## 15 videos      6

Step 3: Build the Co-occurrence Matrix

# 3. Calculate the co-occurrences
comment_pairs <- my_clean_words %>%
  pairwise_count(word, id, sort = TRUE, upper = FALSE)

# View the top 15 most common pairs
comment_pairs %>% head(15)
## # A tibble: 15 × 3
##    item1  item2        n
##    <chr>  <chr>    <dbl>
##  1 true   gmt          6
##  2 don    true         3
##  3 don    gmt          3
##  4 true   call         3
##  5 videos love         3
##  6 gmt    video        3
##  7 list   video        3
##  8 love   video        3
##  9 don    rolex        3
## 10 video  brands       3
## 11 list   hamilton     3
## 12 bulova super        3
## 13 bulova seville      3
## 14 super  seville      3
## 15 bought buy          3

Step 4: Visualize the Network

set.seed(580)

comment_pairs %>%
  filter(n >= 1) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = n, edge_width = n), color = "steelblue") +
  geom_node_point(size = 5, color = "darkorange") +
  geom_node_text(aes(label = name), repel = TRUE, size = 4) +
  theme_void() +
  labs(title = "Word Co-occurrence Network: YouTube Watch Comments")

Step 5: Visualize the Strongest Co-occurrences

To keep the network readable, we’ll only keep pairs that occurred together more than once.

set.seed(580)

comment_pairs %>%
  filter(n > 1) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = n, edge_width = n), color = "firebrick") +
  geom_node_point(size = 4, color = "steelblue") +
  geom_node_text(aes(label = name), repel = TRUE, size = 3.5) +
  theme_void() +
  labs(
    title = "Word Co-occurrence Network: YouTube Watch Comments",
    subtitle = "Edge thickness = number of sentences containing both words"
  )

Reflection questions and answers

Question 1 Which additional words did you add to the stopword list manually? Why does it make sense to remove those words as stopwords? Also, does the final network graph (see the last code chunk) change after removing these additional stopwords? If so, please briefly explain how and why.**

Answer: I manually added generic words like “watch”, “watches”, and “time” to my custom stopword list. It makes a lot of sense to remove these because I scraped comments from a YouTube video specifically about watches under $1,000. Because basically every comment mentions the word “watch,” leaving it in doesn’t actually give me any meaningful insight into what people are saying about the watches. Yes, the final network graph changed significantly after removing them! Before, the word “watch” acted as a massive hub connecting to almost every other word, turning the graph into a confusing hairball. After removing it, the graph separated into much clearer, distinct clusters of conversation (like specific brand comparisons).

Question 2 Change “n” in the function, “filter(n > 1)”, in step 5 to a different value. What is your final value of n? Will it give you a better result?

Answer: I ended up changing my filter to filter(n >= 3). It definitely gave me a way better result. When the threshold was just greater than 1, there was too much visual noise because the graph was plotting random word combinations that only occurred a couple of times. Bumping it up to 3 stripped out that random noise and left only the strongest, most consistent topics that multiple people were actually talking about.

Question 3 On my BlueSky post here: https://bsky.app/profile/did:plc:jbmmqoxfdgpoavycm7fz4k3r/post/3mphyccr2vc2g, I had two co-occurence network graphs. Which one do you like better? Why?

Answer: I prefer the cleaner, less dense graph. When a network has too many edges because the frequency threshold is set too low, it becomes unreadable and you can’t actually extract any business insights from it. The cleaner graph filters out the weak signals, making it much easier to immediately spot actual semantic clusters and understand the core themes of the text.

Question 4 Which word pairs surprised you? Did “buy” cluster more with optimism words (“dip”) or caution words (“wait,” “valuation”)?

Answer: in my watch data, the word pairs that surprised me were how heavily specific brand names clustered with value-related words (like “orient” and “price”). It showed that the commenters weren’t just dropping brand names; they were actively debating which specific watches offered the best value for the money, which is a great insight for a brand analyzing consumer sentiment.

Question 5 Should we treat stock tickers, $SPCX and SpaceX, as the same token instead of separate ones?

Answer: Yes, we absolutely should treat them as the same token. If we don’t, the algorithm treats them as two totally different topics and splits the data. By combining them into one single token (like “spacex”), we get a much more accurate and concentrated count of how often people are talking about the company, which gives us stronger, more reliable connections in our matrix.

Question 6 What’s the risk of drawing conclusions from a co-occurrence network built on only a small sample of comments? What is a good sample size? How would you communicate that limitation in a business report?

Answer: The biggest risk of a small sample size is that one really active or loud user can totally skew the results. If one person repeats the same weird phrase five times, the network will map it as a strong connection even though it’s not a wider trend. A good sample size is usually in the thousands to ensure those individual quirks get drowned out by general consensus. In a business report, I would communicate this by framing the findings as “early directional indicators” or “emerging themes” rather than hard statistical facts.

Question 7 Is there a way to analyze comments or reviews on topics that interest you in relation to a significant event? If so, how would you identify the event? Why is that event significant, and how might it influence the comments or reviews you analyze?

Answer: Yes! For example, it would be really interesting to analyze the watch community’s comments right around the annual “Watches and Wonders” trade show in Geneva. I could identify the event by scraping comments from the weeks right before and right after the event dates. This is a massive event because it’s when major brands like Rolex announce their new models. It would influence the comments heavily—you would likely see a sudden spike in clusters around new dial colors, case sizes, or complaints about upcoming waitlists compared to the pre-event data.


Wrap-Up

Overall, this lab was honestly a bit of a rollercoaster, but it was incredibly rewarding once the code finally clicked. Moving from the professor’s clean fruit and SpaceX examples to my own messy YouTube watch data was a huge reality check—especially wrestling with weird text encodings, hidden emojis, and figuring out exactly where to assign that id column in the pipeline. It was really important to exclude some words such as “watch,” “watches,” and “video” because it was just redundant. We know what the subject is about, so it’s good to get those out of the way. Ultimately, seeing the final network graph actually visualize the real conversations people are having about watch brands and value made all the troubleshooting worth it. It’s wild to see how much actual business insight you can extract from a bunch of random YouTube comments once you know how to filter out the noise.


References

Boost Brand Credibility with Co Occurrence SEO. https://www.linkedin.com/posts/umer-abid-78045131a_co-occurrence-seo-is-a-powerful-concept-that-activity-7421759779892809728-MGh_/

https://seopressor.com/blog/why-co-citation-and-co-occurrence-are-such-big-deal/

Kong, J., Scott, A., & Goerg, G. M. (2016). Improving topic clustering on search queries with word co-occurrence and bipartite graph coclustering. https://research.google/pubs/improving-topic-clustering-on-search-queries-with-word-co-occurrence-and-bipartite-graph-co-clustering/

Colladon, A. F. (2018). The semantic brand score. Journal of Business Research, 88, 150-160.

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

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

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