About the corpus¶
My corpus comes from comments and thread topics from r/ARCRaiders, a subreddit for the popular extraction third-person shooter ARC Raiders. The comments I'm focusing on are related to the debate between people who favor player vs player (PvP) and player vs environment (PvE) playstyles and the moral language they use to defend their stance.
My goal is to find which group uses what type of moral language the most. After a quick review of some of the comments they read exactly how I thought they would: a lot of moral judgements directed towards one another and name calling. These are heavily commented threads and debates between people so it makes sense the type of language that I'm noticing. One of the interesting things I noticed is how long some of the comments are. It seems some people are very passionate about their stance on PvP and PvE.
The reason I chose this topic is because I myself play ARC Raiders and am part of Facebook groups and the subreddit and have run into this debate often. Coming from a psychology background, I enjoy understanding behaviors and so I was curious to analyze these specific behaviors and try to understand what's going on underneath the moral accusations being thrown around. In my first check-in, I ended up with much lower numbers for the corpus text units and words, however since tweaking the scraper tool I was able to gather much more.
The final count for the corpus is 3,027 text units and 67,087 words. Posts and comments both function as their own standalone expression of opinion, not just a prompt for others to respond to. Posts typically open or introduce a discussion, expressing the author's opinion or frustration directly, making it still meaningful data.
How the dataset was created¶
The first scraper I used pulled comments and threads that were generally found in the subreddit. But it came back with very few mentions of PvP or PvE discourse so I decided to rewrite the scraper with ChatGPT assistance to specifically pull threads and comments that mention PvP and PvE as well as language such as "unfair", "toxic", "balance", "rat" that commonly come up in these types of heated discussions. Then I downloaded the full comment threads and measured how frequently PvP and PvE were discussed within each thread. I only kept the threads that showed actually meaningful PvP/PvE debate engagement. From that group, I then created a comment-only dataset that includes comments mentioning PvP/PvE terms and containing moral or trait-based language, or directly replying to those type of comments to maintain context.
The goal was to make sure that the final corpus reflected active PvP/PvE debate rather than just general subreddit discussions about the game itself (game mechanics, game updates, etc.). The scraper was kept in a separate notebook so that running analyses would not trigger a new scrape and update the data mid-project.
Preprocessing steps & justification¶
For preprocessing I ran standard preprocessing to remove any "noise" that could disrupt the signal I am trying to analyze. I removed apostrophes so that no contractions remained in the corpus. I made everything lowercase to make it easier to view and analyze the words without any distractions from capitalization. I removed punctuation, the newline character \n because none of these provide any meaningful content (\n is not a word). I also removed stopwords, which are commonly used words that do not contribute to the analysis, things such as "the", "is", and "in". In addition, I created a custom stopwords dictionary that contained gaming-specific generic words that slipped through the initial preprocessing step, such as "like" and "let".
Finally I lemmatized the text to condense multiple words. I noticed in the ratio section that the words rat and rats were coming up as two separate words used exclusively by the moral language group and refer to the same thing so it made sense to lemmatize rats down to rat. This is just one example, there were other words with similar situations. However, in the case of the words camping and camper while they relate to the same topic they do convey different meanings so the lemmatizer did not lemmatize that word down to camp.
Import libraries & setup¶
# Libraries used
import re # pattern matching for cleaning text (apostrophes, etc.)
import pandas as pd # storing and manipulating the corpus as a dataframe
from collections import Counter # counting word frequencies
import spacy # NLP library used for lemmatization
from wordcloud import WordCloud # generating word cloud visualizations
import matplotlib.pyplot as plt # plotting and displaying visualizations
from gensim.parsing.preprocessing import (
strip_punctuation, # removes punctuation from tokens
strip_multiple_whitespaces, # collapses extra whitespace into one
remove_stopwords, # removes common words with no analytical value
strip_short, # removes very short tokens (e.g. single letters)
preprocess_string # applies a pipeline of cleaning filters to text
)
from gensim.models.phrases import Phrases, ENGLISH_CONNECTOR_WORDS # bigram detection
from gensim import models # general gensim modeling utilities
from gensim.corpora.dictionary import Dictionary # builds word-to-id mappings for LDA
import gensim # core gensim library
from gensim.models.ldamodel import LdaModel, CoherenceModel # LDA model and coherence scoring
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS # predefined list of common English stopwords
import nltk # natural language processing toolkit
from nltk.sentiment.vader import SentimentIntensityAnalyzer # VADER sentiment analysis
import seaborn as sns # statistical data visualization
from sklearn.feature_extraction.text import TfidfVectorizer # converts text to TF-IDF numerical features
from sklearn.svm import SVC # support vector classification model
from sklearn.model_selection import train_test_split # splits data into training and testing sets
from sklearn.metrics import accuracy_score # calculates model accuracy
from sklearn.metrics import classification_report # generates precision, recall, and F1 scores
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay # generates and displays confusion matrix
import pyLDAvis # interactive LDA topic visualization
import pyLDAvis.gensim # gensim compatibility for pyLDAvis
pyLDAvis.enable_notebook() # enables pyLDAvis rendering in Jupyter
Import data¶
Here I load the following 4 datasets:
post_debate_candidates: contains all the posts that passed the initial keyword search and prefilterthreads_with_stats: contains all threads with debate density stats attached (debate density is PvP/PvE being mention at least 10 times and having 5 or more unique authors)comments_kept_threads: contains all comments from the threads that passed the debate density filterdf: all comments that mention PvP/PvE or moral/trait language and direct replies for context
# Import the four JSONL files output by the scraper notebook
# Each file represents a different stage of the data collection and filtering pipeline
# All the posts that passed the initial keyword search and prefilter
posts_debate_candidates = pd.read_json(
"../data/raw/posts_debate_candidates.jsonl",
lines=True
)
# All threads with stats attached (PvP/PvE mention >=10 and >=5 diff people commented)
threads_with_stats = pd.read_json(
"../data/raw/threads_with_stats.jsonl",
lines=True
)
# All comments from threads that passed debate density filter
comments_kept_threads = pd.read_json(
"../data/raw/comments_keptthreads.jsonl",
lines=True
)
# All comments that mention PvP/PvE or moral/trait language, plus direct replies for context
df = pd.read_json(
"../data/raw/comments_debate_slice.jsonl",
lines=True
)
Summary¶
The scraper pulled 350 posts (referred to as candidates), that it found matching the search inquiries. I ran another filter that went through those 350 posts to make sure that the posts had enough PvP/PvE discussion to be worth keeping. This shrank the post pool down from 350 to 293 posts, which I call the kept threads.
From those kept threads I pulled all the comments which totaled to 5,276 comments. From those comments, I filtered down one last time to pull only comments that actually mention PvP/PvE OR moral language and replies to those comments for context which gives a total of 3,027 text units for analysis.
# Summary print outs
print("Number of candidate threads:", len(posts_debate_candidates))
print("Number of kept threads:", len(threads_with_stats))
print("Total keptthread comments:", len(comments_kept_threads))
print("Final dataset to analyze:", len(df))
Number of candidate threads: 350 Number of kept threads: 293 Total keptthread comments: 5276 Final dataset to analyze: 3027
Preprocessing¶
During pre-processing, I ran into some contractions like "it's" slipping past the filters, so I had to go back and strip the apostrophes from the tokens themselves.
Everything else was standard preprocessing that included:
- removing apostrophes - no contractions in the corpus
- making everything lowercase - makes it easier to view and analyze without capitalization distractions
- removing punctuation - adds no meaningful content to the analysis
- removing
\n- newline character, not a word - removing stopwords - commonly used words that don't contribute to the analysis
- removing custom stopwords - gaming-specific generic words that slipped through such as
likeandlet - lemmatization - reduces multiple forms of the same word with the same meaning
- bigram detection was also applied to the corpus and is documented in its own section below.
Regarding lemmatization
I noticed in the ratio section that the words
ratandratswere coming up as two different words that the moral language group uses only and they are the same thing so it made sense to lemmatizeratsdown torat. This is just one example, there were other words with similar situations. However, in the case of the wordscampingandcamperwhile they relate to the same topic they do convey different meanings so the lemmatizer did not lemmatize that word down tocamp.
# Custom stopwords for gaming corpus
# These are words that slipped through gensim's default stopword list
# but don't add meaningful content to this analysis
custom_stopwords = {
# Generic words too broad to be analytically useful
"game", "just", "really", "people", "players", "player", "think",
"know", "want", "like", "good", "going", "make", "way", "thing", "things",
"right", "said", "say", "got", "getting", "actually", "maybe", "pretty",
"need", "time", "times", "better", "best", "lot", "use", "using", "yeah",
"play", "playing", "games", "guy", "guys", "bad", "free", "fun", "high",
"try", "feel", "does", "work", "run", "team", "point", "reason",
"experience", "enjoy", "having", "doing", "making", "trying", "sure",
"fine", "long", "big", "day", "little", "different", "real", "probably",
"simply", "especially", "literally", "instead", "away", "able", "come",
"let", "stop", "bring", "mean", "kind", "care", "abmm", "arc", "https", "www",
# Contractions stripped of apostrophes (e.g. "don't" becomes "dont" after cleaning)
"dont", "doesnt", "isnt", "cant", "wont", "didnt", "wouldnt", "shouldnt",
"wasnt", "werent", "hasnt", "havent", "hadnt", "youre", "theyre", "ive",
"im", "thats", "its", "itll", "theres", "heres", "whats", "lets"
}
# Strip all apostrophe variants before tokenizing
# Applied multiple times because different apostrophe characters kept slipping through
df["body"] = df["body"].str.replace("'", "", regex=False)
df["body"] = df["body"].str.replace("'", "", regex=False)
df["body"] = df["body"].str.replace("`", "", regex=False)
df["body"] = df["body"].str.replace("'", "", regex=False)
# Standardize British spelling to American spelling for consistency
df["body"] = df["body"].str.replace("behaviour", "behavior", regex=False)
# Remove newline characters so each comment is treated as continuous text
df["body"] = df["body"].str.replace(r"\n", " ", regex=True)
# Lowercase all text for consistent matching
df_body_lower = [text.lower() for text in df["body"]]
# Lemmatize each token to reduce words to their base form
# e.g. "rats" -> "rat", "attempts" -> "attempt"
nlp = spacy.load("en_core_web_sm")
df_body_lemmatized = []
for text in df_body_lower:
doc = nlp(text)
lemmas = [token.lemma_ for token in doc]
df_body_lemmatized.append(lemmas)
# Define the gensim preprocessing pipeline
# Applies punctuation removal, whitespace normalization, stopword removal, and short token removal
CUSTOM_FILTERS = [strip_punctuation, strip_multiple_whitespaces, remove_stopwords, strip_short]
# Apply the preprocessing pipeline to each lemmatized comment
df_tokens = [preprocess_string(" ".join(lemmas), CUSTOM_FILTERS) for lemmas in df_body_lemmatized]
# Save tokens and readable cleaned text as new columns
df["tokens"] = df_tokens
df["text_clean"] = [" ".join(tokens) for tokens in df_tokens]
# Second apostrophe pass applied directly to tokens
# Some unicode apostrophe variants survived the first pass and required token-level removal
df["tokens"] = df["tokens"].apply(
lambda tokens: [t.replace("\u2019", "").replace("\u2018", "").replace("'", "") for t in tokens]
)
# Remove custom stopwords from token list
df["tokens"] = df["tokens"].apply(
lambda tokens: [t for t in tokens if t not in custom_stopwords]
)
# Rebuild cleaned text column from final token list
df["text_clean"] = df["tokens"].apply(lambda x: " ".join(x))
Example: Before and after preprocessing¶
Here you can see the transformation before and after preprocessing with comment #31. The preprocessing step took away a lot of language that doesn't add to the analysis such as the stopwords: yeah, just, how, and no. These are words that are commonly used in everyday speech but do not add anything meaningful to this analysis. You can also see the lemmatization of "attempts" to attempt and "expected" became expect. Removing these commonly used words and reducing terms to their base forms helps the moral judgement and trait-based words stand out.
example_idx = 31
print("==== BEFORE PREPROCESSING ===")
print(df["body"].iloc[example_idx])
print()
print("=== AFTER PREPROCESSING ===")
print(df["text_clean"].iloc[example_idx])
==== BEFORE PREPROCESSING === Yeah, even more evidence of how toxic this place is. No engagement, just cheap attempts to shut you up. Sadly expected. === AFTER PREPROCESSING === evidence toxic place engagement cheap attempt shut sadly expect
Bigram creation¶
Bigrams are the combination of two word tokens that appear next to each other enough times to meet a minimum frequency threshold. The script takes those two words and joins them with an underscore to create a single token representation of the two words. This is important for this analysis because a lot of the trigger words are used in relation to other words such as pve_mode, pvp_focused and stella_montis (which is a map location). The printed output shows the top 20 most common bigrams found in the corpus.
These bigrams are an excellent tool to use in the analysis because a lot of the trait judgement and moral language is used in combination with another word. For example:
skill_issuerefers to a trait judgment, implying someone lacks skillfriendly_lobbiesrefers to the kind of environment players want, which connects to the PvP/PvE debatepvp_focusedis directly relevant to the debateshoot_sightrefers most likely to the phrase "shoot on sight" which describes aggressive PvP behavior
# Train the bigram model on the tokenized corpus
# min_count=5: a pair must appear at least 5 times to be considered a bigram
# threshold=10: minimum score a pair needs to qualify as a bigram
# connector_words: allows common words like "of" or "on" to sit between tokens (e.g. "shoot on sight")
phrases = Phrases(df["tokens"], min_count=5, threshold=10, connector_words=ENGLISH_CONNECTOR_WORDS)
# Apply the trained bigram model to each row's token list
# Joined bigrams appear as a single token with an underscore (e.g. skill_issue)
df["tokens_bigram"] = df["tokens"].apply(lambda tokens: phrases[tokens])
# Extract only the bigram tokens (identified by the underscore) across all rows
bigrams_found = [token for tokens in df["tokens_bigram"] for token in tokens if "_" in token]
print("=== BI-GRAMS FOUND ==")
Counter(bigrams_found).most_common(20)
=== BI-GRAMS FOUND ==
[('extraction_shooter', 171),
('friendly_lobby', 140),
('pve_mode', 88),
('shoot_sight', 51),
('skill_issue', 33),
('pve_content', 28),
('pvpve_extraction', 27),
('mixed_lobby', 27),
('battle_royale', 22),
('blue_gate', 20),
('kill_sight', 20),
('stella_montis', 20),
('lose_gear', 19),
('trigger_nade', 18),
('aggression_base', 18),
('kill_queen', 18),
('duos_trio', 17),
('queen_matriarch', 17),
('pure_pve', 16),
('middle_ground', 16)]
Trigram Creation¶
Trigrams are the combination of three word tokens that appear next to each other enough times to meet a minimum frequency threshold. The trigrams found are listed below. The two most meaningful ones are shoot_fish_barrel (referring to the phrase "shooting fish in a barrel", a moral judgment about easy kills) and aggression_base_matchmaking (relates to the core mechanic of the game that measures a player's aggression level and uses it to determine which lobby to place them in PvP) as both connect directly to each group's main arguments. There was some noise/artifacts that slipped through pre-processing and are related to url artifacts (amp_auto_webp_amp, youtube_com_watch). I had believed that the phrase "shoot on sight" would've appeared as a trigram but it seems that the stopwords preprocessing ended up removing the word "on" leading to it being represented as a bigram instead.
Here's a brief list of the trigrams found and what they mean:
pvpve_extraction_shooter: the game's full genre descriptionshoot_fish_barrel: meaning "shooting fish in a barrel" that is a moral judgment implying that killing certain players is unfairly easy, connects directly to the moral/trait language research questionaggression_base_matchmaking: this refers to the ABMM system (aggression-based matchmaking) which is the core mechanic that separates PvP and PvE lobbies, central to the entire debatesolo_duos_trio: lobby size discussionamp_auto_webp_ampandyoutube_com_watch: URL artifacts that slipped through preprocessing
# Train the trigram model on the tokenized bigrams
trigram_phrases = Phrases(df["tokens_bigram"], min_count=5, threshold=10, connector_words=ENGLISH_CONNECTOR_WORDS)
# Apply the trained bigram model to each row's token list
# Joined bigrams appear as a single token with an underscore (e.g. skill_issue)
df["tokens_trigram"] = df["tokens_bigram"].apply(lambda tokens: trigram_phrases[tokens])
# Extract only the bigram tokens (identified by the underscore) across all rows
trigrams_found = [token for tokens in df["tokens_trigram"] for token in tokens if token.count("_") >= 2]
print("=== Tri-GRAMS FOUND ==")
Counter(trigrams_found).most_common(20)
=== Tri-GRAMS FOUND ==
[('pvpve_extraction_shooter', 24),
('aggression_base_matchmaking', 12),
('amp_auto_webp_amp', 10),
('youtube_com_watch', 8),
('edit_post_address_comment', 7),
('shoot_fish_barrel', 6),
('solo_duos_trio', 6)]
Dictionary Method¶
In this section I used the dictionary method to create a dictionary containing words commonly used that relate to morality such as "unfair", "coward", and "grief". In text-as-data, the dictionary method is a curated set of words that represent a concept, so in this case the concept of "moral or trait language". This dictionary was used to search through the corpus and categorize each text unit (comment or post) as being Group A (uses moral language found in the dictionary) or Group B (debate-only because it doesn't contain any moral language). I chose these specific words due to their heavy use in posts and comments where the user is expressing frustration with something such as a cheater. So basically, emotionally charged words. I used regex because some of the moral terms consist of two words like "skill issue" for example would be missed with a simple word match.
The results of the dictionary method are shown in the ratio table located in the section titled How much more likely is a certain word to appear in Group A compared to Group B. The ratio chart compares how often each word appears in Group A versus Group B relative to group size. For example, words like toxic, unfair, and camping appeared exclusively in Group A.
# Regex pattern to detect any mention of pvp, pve, or pvpve (case insensitive)
pvp_re = re.compile(r"\b(?:pvp|pve|pvpve)\b", re.IGNORECASE)
# List of moral and trait-based keywords used to identify Group A comments
moral_terms = [
"unfair", "toxic", "ruin", "ruins", "cheat", "cheater",
"grief", "griefer", "coward", "rat", "sweat", "sweaty",
"tryhard", "entitled", "deserve", "skill issue", "cheap",
"cheating", "camping", "rats"
]
# Compile moral terms into a single regex pattern for efficient matching
moral_re = re.compile(r"\b(?:" + "|".join([re.escape(t) for t in moral_terms]) + r")\b", re.IGNORECASE)
# Create boolean masks to flag which comments match each pattern
mask_moral = df["body"].str.contains(moral_re, regex=True, na=False)
mask_pvp = df["body"].str.contains(pvp_re, regex=True, na=False)
# Group A: comments containing at least one moral or trait-based keyword
df_moral = df[mask_moral].copy()
# Group B: comments mentioning pvp/pve but containing no moral keywords
df_debate_only = df[mask_pvp & ~mask_moral].copy()
print("Total debate-slice comments:", len(df))
print("Group A (moral/trait):", len(df_moral))
print("Group B (pvp/pve but no moral keywords):", len(df_debate_only))
Total debate-slice comments: 3027 Group A (moral/trait): 404 Group B (pvp/pve but no moral keywords): 1479
Tokenizing and counting¶
The next step is to tokenize all the words which breaks up the text into individual countable text units, or tokens. This section is counting up all the tokens so I can understand how large each group's corpus is during the analysis.
The total number of tokens is 66,593. Out of those, 18,319 tokens are found in the moral/trait group and 48,274 are found in the debate-only group.
# Regex pattern that matches only alphabetic words of 3 or more characters
# Filters out numbers, symbols, and very short tokens like "a" or "to"
token_pattern = re.compile(r"[a-zA-Z]{3,}") # words 3+ letters
def tokenize(text):
tokens = token_pattern.findall(text.lower())
# Remove sklearn's built-in English stopwords before counting
return [t for t in tokens if t not in ENGLISH_STOP_WORDS]
# Count tokens in each group
def count_tokens(series):
counter = Counter() # tracks frequency of each unique token
total_tokens = 0 # running total of all tokens across all comments
for text in series: # iterate through each comment in the group
tokens = tokenize(text)
counter.update(tokens)
total_tokens += len(tokens)
return counter, total_tokens
# Count token frequencies separately for each group
moral_counter, moral_total = count_tokens(df_moral["body"])
debate_counter, debate_total = count_tokens(df_debate_only["body"])
# Print token counts per group and combined total
print("Moral tokens:", moral_total)
print("Debate-only tokens:", debate_total)
print("Total tokens:", moral_total + debate_total)
Moral tokens: 18319 Debate-only tokens: 48274 Total tokens: 66593
Here is an example of before and after tokenization for each group.¶
Group A contains moral or trait-based tokens such as unfair and unfriendly.
Group B contains gameplay-focused tokens such as targeted, losing, gear, and kits that show language that revolves around gameplay, not judgements about other players.
# Example of before and after tokenization
example_text = df_moral["body"].iloc[31]
print("=== GROUP A: MORAL/TRAIT BASED LANGUAGE ===")
print()
print("BEFORE:")
print(example_text)
print()
print("AFTER:")
print(tokenize(example_text))
print()
# Example of before and after tokenization
example_text = df_debate_only["body"].iloc[31]
print("------------------------------------")
print()
print("=== GROUP B: DEBATE-ONLY ===")
print()
print("BEFORE:")
print(example_text)
print()
print("AFTER:")
print(tokenize(example_text))
=== GROUP A: MORAL/TRAIT BASED LANGUAGE === BEFORE: When getting shot in the back with your expensive kit is the end result, you either sweep the corners, or you take the loss for what it is. You certainly don’t complain about the other guy being unfair to you, especially when your bobcat is completely pristine at the **end** of an unfriendly encounter. In short, you PvE, he PvP, this is a PvPvE game. You got rolled. GG. AFTER: ['getting', 'shot', 'expensive', 'kit', 'end', 'result', 'sweep', 'corners', 'loss', 'certainly', 'don', 'complain', 'guy', 'unfair', 'especially', 'bobcat', 'completely', 'pristine', 'end', 'unfriendly', 'encounter', 'short', 'pve', 'pvp', 'pvpve', 'game', 'got', 'rolled'] ------------------------------------ === GROUP B: DEBATE-ONLY === BEFORE: Currebtly the pve leads to being targeted by people who go in with free kits and dont actually engage in the pve content. This game is great if you wanna just shoot people. Engaging in the actual game leads to you losing your gear to free kits. AFTER: ['currebtly', 'pve', 'leads', 'targeted', 'people', 'free', 'kits', 'dont', 'actually', 'engage', 'pve', 'content', 'game', 'great', 'wanna', 'just', 'shoot', 'people', 'engaging', 'actual', 'game', 'leads', 'losing', 'gear', 'free', 'kits']
Word clouds¶
Word clouds are a creative way to show the frequency of words used in a corpus: the larger the word the higher the frequency of that word. For my project, I thought it would be interesting to see the different types of words used by each group (moral/trait vs debate-only groups) to see if they reveal any patterns. These are my findings.
Group A: Moral/Trait Based Tokens¶
# Custom stopwords for gaming corpus
# These are words that slipped through gensim's default stopword list
# but don't add meaningful content to this analysis
custom_stopwords = {
# Generic words too broad to be analytically useful
"game", "just", "really", "people", "players", "player", "think",
"know", "want", "like", "good", "going", "make", "way", "thing", "things",
"right", "said", "say", "got", "getting", "actually", "maybe", "pretty",
"need", "time", "times", "better", "best", "lot", "use", "using", "yeah",
"play", "playing", "games", "guy", "guys", "bad", "free", "fun", "high",
"try", "feel", "does", "work", "run", "team", "point", "reason",
"experience", "enjoy", "having", "doing", "making", "trying", "sure",
"fine", "long", "big", "day", "little", "different", "real", "probably",
"simply", "especially", "literally", "instead", "away", "able", "come",
"let", "stop", "bring", "mean", "kind", "care", "abmm", "arc",
# Contractions stripped of apostrophes (e.g. "don't" becomes "dont" after cleaning)
"dont", "doesnt", "isnt", "cant", "wont", "didnt", "wouldnt", "shouldnt",
"wasnt", "werent", "hasnt", "havent", "hadnt", "youre", "theyre", "ive",
"im", "thats", "its", "itll", "theres", "heres", "whats", "lets"
}
# Remove custom stopwords from each row's token list and rebuild cleaned text column
df["tokens"] = df["tokens"].apply(
lambda tokens: [t for t in tokens if t not in custom_stopwords]
)
df["text_clean"] = df["tokens"].apply(lambda x: " ".join(x))
# Recount Group A token frequencies using the updated cleaned tokens
moral_counter = Counter()
for tokens in df[mask_moral]["tokens"]:
moral_counter.update(tokens)
# Apply custom stopwords to the counter as a second pass for word cloud accuracy
filtered_moral_counter = {
word: count
for word, count in moral_counter.items()
if word not in custom_stopwords
}
# Count bigram frequencies in Group A only (tokens containing underscore are bigrams)
moral_bigram_counter = Counter()
for tokens in df[mask_moral]["tokens_bigram"]:
for token in tokens:
if "_" in token:
moral_bigram_counter[token] += 1
# Remove generic bigrams that don't add meaningful content to the analysis
bigram_stopwords = {
"feel_like", "feels_like", "dont_want", "dont_know",
"sounds_like", "getting_shot"
}
# Keep only bigrams not in the stoplist and appearing at least 5 times
filtered_bigram_counter = {
word: count
for word, count in moral_bigram_counter.items()
if word not in bigram_stopwords and count >= 5
}
# Merge unigram and bigram counters into one for the combined word cloud
combined_counter = {**filtered_moral_counter, **filtered_bigram_counter}
# Merge unigram and bigram counters into one for the combined word cloud
wordcloud_combined = WordCloud(
width=800,
height=600,
background_color="white",
colormap="gist_heat_r", # warm red/orange tones for Group A
max_words=100 # cap at 100 words for readability
).generate_from_frequencies(combined_counter)
plt.figure(figsize=(8, 6))
plt.imshow(wordcloud_combined, interpolation="bilinear")
plt.axis("off")
plt.title("Most Frequent Words + Bigrams: Group A (moral/trait)", fontweight='bold', fontsize=12, pad=40)
plt.tight_layout()
plt.savefig("wordcloud_combined_groupA.png", dpi=150)
plt.show()
Group B: Debate Only Word Cloud¶
# Recount Group B token frequencies using updated cleaned tokens
debate_counter = Counter()
for tokens in df[mask_pvp & ~mask_moral]["tokens"]:
debate_counter.update(tokens)
# Apply custom stopwords to the counter as a second pass for word cloud accuracy
filtered_debate_counter = {
word: count
for word, count in debate_counter.items()
if word not in custom_stopwords
}
# Count bigram frequencies in Group B only
debate_bigram_counter = Counter()
for tokens in df[mask_pvp & ~mask_moral]["tokens_bigram"]:
for token in tokens:
if "_" in token:
debate_bigram_counter[token] += 1
# Remove generic bigrams that don't add meaningful content to the analysis
bigram_stopwords = {
"feel_like", "feels_like", "dont_want", "dont_know",
"sounds_like", "getting_shot"
}
# Keep only bigrams not in the stoplist and appearing at least 5 times
filtered_bigram_counter = {
word: count
for word, count in debate_bigram_counter.items()
if word not in bigram_stopwords and count >= 5
}
# Merge unigram and bigram counters for the combined word cloud
combined_counter2 = {**filtered_debate_counter, **filtered_bigram_counter}
# Generate word cloud for Group B using a different colormap to visually distinguish from Group A
wordcloud_combined2 = WordCloud(
width=800,
height=600,
background_color="white",
colormap="gist_heat", # warm red/orange tones for Group B
max_words=100
).generate_from_frequencies(combined_counter2)
plt.figure(figsize=(8, 6))
plt.imshow(wordcloud_combined2, interpolation="bilinear")
plt.axis("off")
plt.title("Most Frequent Words + Bigrams: Group B (debate-only)", fontweight='bold', fontsize=12, pad=40)
plt.tight_layout()
plt.savefig("wordcloud_combined_groupB.png", dpi=150)
plt.show()
Word Cloud Observations¶
Group A (moral/trait) has words such as matchmaking, friendly_lobbies, problem, embark that show up prominently which feel more complaint and solution oriented. In contrast, Group B displays the words shoot, loot, kill, fight, and tension more prominently which feel more action and gameplay oriented. Both groups contain the words pvp, pve and extraction which is expected since those are the core debate topics.
A couple more observations worth noting are that:
- Group B (debate-only) has many more unique words than Group A (moral/trait) which makes sense since there are far more comments that were debate-only (1,479) centered than moral/trait-based (404).
- Group B (debate-only) has more aggressive language like
kill,fight,shoot, suggesting users are more pro-PvP and appear to be defending PvP, whereas Group A that uses moral language might be more critical of PvP.
This was an initial observation before deeper analysis revealed that Group B's language, while action-oriented, is more focused on game mechanics than moral judgment.
How much more likely is a certain word to appear in Group A compared to Group B?¶
This ratio table is the output of the dictionary method described earlier. It compares how often each word appears in Group A versus Group B relative to group size.
In order to get accurate ratio numbers I implemented Laplace smoothing to the ratio calculation to address the infinity values by adding a small correction to word counts. This way words appearing in only Group A still get a meaningful ratio rather than infinity.
Group A uses these words significantly more than Group B: toxic, deserve, camping, cheap, cheating and unfair. The words toxic, cheap, unfair are judgements about the players or behavior while camping, and cheating describe actions in a moral way.
On the other hand, Group B (debate-only) does not have any exclusive words that it uses. It could suggest that people who only debate do not use exclusive language like people who do use moral language. The moral language group seems to use the specific phrases/words when they debate.
This is an interesting finding and is the core of what my project is trying to find. Group A seems to be more emotional, personal and uses morally charged language that shows more of an emotional investment to the game. While Group B's arguments come across as neutral, game-focused, logical suggesting they are more detached when arguing.
# Compute relative frequencies and ratio of each word between Group A and Group B
rows = []
for word in moral_counter:
# Relative frequency = word count / total tokens in that group
# Allows fair comparison between groups of different sizes
moral_freq = (moral_counter[word] + 1) / (moral_total + 1)
debate_freq = (debate_counter[word] + 1) / (debate_total + 1)
# Higher ratio = word is proportionally more common in Group A than Group B
ratio = moral_freq / debate_freq
rows.append({
"word": word,
"moral_freq": moral_freq,
"debate_freq": debate_freq,
"ratio": ratio,
"moral_count": moral_counter[word]
})
df_compare = pd.DataFrame(rows)
# Filter to meaningful candidates only:
# - must appear at least 10 times in Group A to avoid rare noise
# - must appear at least twice as often in Group A as Group B
df_candidates = df_compare[
(df_compare["moral_count"] >= 10) &
(df_compare["ratio"] > 2)
].sort_values("ratio", ascending=False)
# Display top 30 words most distinctive to the moral/trait group
df_candidates.head(30)
| word | moral_freq | debate_freq | ratio | moral_count | |
|---|---|---|---|---|---|
| 85 | toxic | 0.003057 | 0.000021 | 147.565502 | 55 |
| 410 | unfair | 0.002129 | 0.000021 | 102.768832 | 38 |
| 88 | cheap | 0.001747 | 0.000021 | 84.323144 | 31 |
| 4 | rat | 0.010262 | 0.000124 | 82.566412 | 187 |
| 132 | camping | 0.001583 | 0.000021 | 76.417849 | 28 |
| 99 | deserve | 0.000655 | 0.000021 | 31.621179 | 11 |
| 250 | cheating | 0.000655 | 0.000021 | 31.621179 | 11 |
| 519 | ruin | 0.003766 | 0.000124 | 30.303630 | 68 |
| 189 | cheat | 0.001201 | 0.000062 | 19.324054 | 21 |
| 214 | sweaty | 0.001419 | 0.000104 | 13.702511 | 25 |
| 259 | cheater | 0.000819 | 0.000062 | 13.175491 | 14 |
| 313 | sweat | 0.001910 | 0.000166 | 11.528555 | 34 |
| 127 | camp | 0.001310 | 0.000166 | 7.905295 | 23 |
| 607 | voice | 0.000600 | 0.000124 | 4.831013 | 10 |
| 399 | short | 0.000600 | 0.000124 | 4.831013 | 10 |
| 327 | claim | 0.000655 | 0.000145 | 4.517311 | 11 |
| 49 | fake | 0.000655 | 0.000145 | 4.517311 | 11 |
| 403 | behavior | 0.002402 | 0.000725 | 3.312695 | 43 |
| 510 | fuck | 0.001310 | 0.000414 | 3.162118 | 23 |
| 442 | exploit | 0.000710 | 0.000228 | 3.114207 | 12 |
| 584 | chat | 0.000764 | 0.000249 | 3.074281 | 13 |
| 140 | anymore | 0.000819 | 0.000269 | 3.040498 | 14 |
| 692 | rocketeer | 0.000819 | 0.000269 | 3.040498 | 14 |
| 594 | walk | 0.000600 | 0.000207 | 2.898608 | 10 |
| 406 | jump | 0.000873 | 0.000331 | 2.635098 | 15 |
| 373 | corner | 0.000819 | 0.000311 | 2.635098 | 14 |
| 650 | lore | 0.000600 | 0.000228 | 2.635098 | 10 |
| 324 | outside | 0.000655 | 0.000249 | 2.635098 | 11 |
| 728 | gain | 0.000655 | 0.000249 | 2.635098 | 11 |
| 283 | camper | 0.000873 | 0.000352 | 2.480092 | 15 |
What Are Each Group Talking About? A Topic Modeling Analysis¶
Latent Dirichlet Allocation (LDA/Topic modeling) is an unsupervised machine learning technique used for topic modeling. It automatically identifies underlying themes within large text collections. I chose LDA because it was the perfect method to identify the themes that were coming up for both groups.
I chose different parameters for each group because the groups were not equal in size. For example Group A (moral/trait) has 404 total text units whereas Group B (debate-only) has 1,479 text units.
For Group A I chose to model 3 topics. I originally tried 5 but I noticed that the topics started bleeding into one another so they became messy as multiple words were being used in multiple categories making it difficult to distinguish the topics. Because Group B was larger, I decided to go with 4 topics. I originally tried 5 but I ran into the same issue as with Group A.
# Group A (Moral/Trait-language) ===============================
# Encapsulates mapping between normalized words and integer ids
grpa_dictionary = Dictionary(df_moral["tokens"])
# Filters out tokens that appear in less than 3 documents
# And shrinks resulting gaps in word ids
# Small number no_below because group a is smaller than group b
grpa_dictionary.filter_extremes(no_below=3)
# Removes gaps in word ID sequences
grpa_dictionary.compactify()
# Converts lists of tokes into bag-of-words presentation
grpa_bow = [grpa_dictionary.doc2bow(a) for a in df_moral["tokens"]]
# Group B (debate-only) ==================================================
# Encapsulates mapping between normalized words and integer ids
grpb_dictionary = Dictionary(df_debate_only["tokens"])
# Filters out tokens that appear in less than 10 documents
# Larger no_below number due to being the larger group
# And shrinks resulting gaps in word ids
grpb_dictionary.filter_extremes(no_below=10)
# Removes gaps in word ID sequences
grpb_dictionary.compactify()
# Converts lists of tokes into bag-of-words presentation
grpb_bow = [grpb_dictionary.doc2bow(a) for a in df_debate_only["tokens"]]
# Builds the LDA model using raw bag-of-words
# Group A (Moral/Trait-language)
lda_model_a = LdaModel(grpa_bow, # The training corpus
num_topics=3, # Number of latent topics to extract
random_state=2026, # Random seed for reproducibility
id2word=grpa_dictionary, # Mapping from word IDs (integers) to words (strings)
passes=10) # Number of iterations
# Group B (debate-only)
lda_model_b = LdaModel(grpb_bow, # The training corpus
num_topics=4, # Number of latent topics to extract
random_state=2026, # Random seed for reproducibility
id2word=grpb_dictionary, # Mapping from word IDs (integers) to words (strings)
passes=10) # Number of iterations
Group A (Moral/Trait Language) Topics¶
The topics for each group were as expected. Group A's topics focused around users' complaints and frustrations about what they experienced while playing the game in relation to interaction with other players. The 3 topics that the model identified were the following:
The first topic (Topic 0) was related to an unfair PvP experience such as being killed by rats while looting or extracting. The second topic (Topic 1) was related to debate over users wanting friendly lobbies so words related to wanting relaxed lobbies and the problems with dealing with toxic/rat players. The final topic (Topic 2) related to game ruining behavior so complaints about player behavior ruining the game and users wanting a PvE mode.
- Topic 0 = "Unfair PvP experience" - being killed by rats while looting/extracting
- Topic 1 = "Friendly lobby debate" - wanting chill lobbies, dealing with toxic/rat players
- Topic 2 = "Game ruining behavior" - complaints about player behavior ruining the game, wanting a PvE mode
Below you can see a breakdown of the words found in each topic group:
# Prints the top words per topic
# Interprets the top words to infer the topic label
lda_model_a.print_topics(num_words=10)
[(0, '0.021*"rat" + 0.021*"loot" + 0.019*"shoot" + 0.019*"kill" + 0.017*"pve" + 0.015*"lobby" + 0.011*"fight" + 0.007*"gear" + 0.007*"friendly" + 0.007*"unfair"'), (1, '0.034*"lobby" + 0.023*"rat" + 0.019*"pve" + 0.018*"kill" + 0.014*"shoot" + 0.014*"fight" + 0.012*"raider" + 0.011*"issue" + 0.011*"toxic" + 0.010*"friendly"'), (2, '0.027*"pve" + 0.013*"kill" + 0.012*"friendly" + 0.011*"extraction" + 0.010*"loot" + 0.009*"nt" + 0.008*"ruin" + 0.008*"behavior" + 0.008*"mode" + 0.007*"lobby"')]
Group B (debate-only) Topics¶
Group B's topics related to strictly comments on defending the game's mechanics as being PvPvE. The first topic (Topic 0) relates to the debate argument of the game being a PvPvE game and basically defending the game's design. The second topic (Topic 1) is related to lobby or matchmaking discussion so explaining the differences between solo vs trio lobbies and how solo lobbies seems to be friendly. The third topic (Topic 2) is related to risk and reward mechanics, so words relating to these mechanics in the game such as loot, loadouts, gear, and the loss of gear. The final topic (Topic 3) is related to the skills and mechanics debate relating to how skillful a player is based on how they prepare before going into a match (loadout) and how they are able to survive because of their loadout.
Topic 0 = "It's a PvPvE game" - defending the game's design
Topic 1 = "Lobby/matchmaking discussion" - solo vs trio, friendly lobbies
Topic 2 = "Risk/reward mechanics" - loot, loadouts, gear, loss
Topic 3 = "Skill and mechanics debate" - skill, shields, extraction
Below you can see a breakdown of the words found in each topic group:
# Prints the top words per topic
# Interprets the top words to infer the topic label
lda_model_b.print_topics(num_words=10)
[(0, '0.027*"pvpve" + 0.020*"extraction" + 0.020*"mode" + 0.019*"shooter" + 0.017*"raider" + 0.014*"add" + 0.013*"fight" + 0.011*"devs" + 0.010*"shoot" + 0.009*"nt"'), (1, '0.092*"lobby" + 0.041*"shoot" + 0.033*"friendly" + 0.017*"kill" + 0.015*"matchmaking" + 0.014*"solo" + 0.014*"match" + 0.013*"raider" + 0.010*"base" + 0.009*"trio"'), (2, '0.029*"kill" + 0.021*"loot" + 0.020*"weapon" + 0.018*"lose" + 0.016*"gun" + 0.015*"loadout" + 0.013*"kit" + 0.010*"shoot" + 0.010*"risk" + 0.010*"issue"'), (3, '0.018*"shooter" + 0.017*"extraction" + 0.014*"friendly" + 0.014*"shoot" + 0.013*"nt" + 0.012*"kill" + 0.010*"skill" + 0.010*"fight" + 0.009*"shield" + 0.009*"agree"')]
LDA Results Discussion¶
By contrasting the groups, it's clear to see that each group's type of comments and posts center around one theme. For example, Group A's comments/posts center around expressing frustration about unfair in-game experiences, most commonly being killed by another player. Their comments are more emotional and reactive than Group B. When I review Group B's topics, they all center around defending the mechanics of the game and come across more logical and calm.
Central Themes
Group A (moral/trait-based language): Grievance-based
Group B (debate-only language): Game mechanics-based
Document-Level Topic Examples¶
Group A Example¶
This comment is 98% Topic 0 ("Unfair PvP experience") which makes sense if you look at the tokens: rat, kill, loot, lobby, pve, ignore. The person is expressing frustration about rats killing people instead of fighting the "boss" (robot enemy), which fits perfectly with the "unfair PvP experience" theme.
# Group A (moral/trait-based language)
# View the comment/post tokens at index 1 in group A
print(df_moral["tokens"].iloc[1])
# Find the topic(s) for this particular comment/post
lda_model_a.get_document_topics(grpa_bow[1])
['mmm', 'boss', 'lobby', 'pve', 'address', 'issue', 'lately', 'lobby', 'start', 'kill', 'boss', 'stay', 'far', 'loot', 'map', 'loadout', 'loot', 'chamber', 'ignore', 'boss', 'ppl', 'kill', 'rat', 'lobby', 'intend', 'beginnibg', 'situation', 'ppl', 'aleay', 'raise', 'community', 'lobby', 'ppl', 'far', 'afraid', 'boss', 'start', 'focus', 'raise', 'difficulty', 'raid', 'wander', 'ecc', 'ppl', 'start', 'communicate', 'enemy']
[(0, np.float32(0.9803885)), (1, np.float32(0.010043411))]
Group B Example¶
This comment is split between Topic 0 (41%) and Topic 2 (55%). It contains the tokens pvp, pvpve, chaos, loadout, damage, reimagine, balancing. This sounds like someone making a mechanical argument about PvP balance and game design which is what Topic 2 ("Risk/reward mechanics") and Topic 0 ("It's a PvPvE game") which makes sense because the user is talking about PvP mechanics within the context of a PvPvE game.
Another thing to note is that there is no moral language at all in those tokens. No rat, toxic, unfair. Just pure game design discussion.
LDA Topic-Modeling Visualization¶
# Group B (debate-only)
# View the comment/post tokens at index 1 in Group B
print(df_debate_only["tokens"].iloc[1])
# Find the topic(s) for this particular comment/post
lda_model_b.get_document_topics(grpb_bow[1])
['imo', 'issue', 'balancing', 'incentiv', 'disincentive', 'pvp', 'moment', 'loadout', 'essentialy', 'pvp', 'chaos', 'tool', 'fundamentaly', 'reimagine', '2nd', 'pvp', 'damage', 'fast', 'incentive', 'chaos', 'uncertainty', 'exitement', 'pvpve']
[(0, np.float32(0.405523)), (1, np.float32(0.023285972)), (2, np.float32(0.5450438)), (3, np.float32(0.026147205))]
# Group A (moral/trait)
# prepare() converts the gensim LDA model into a format pyLDAvis can render
# takes the model, the bag-of-words corpus, and the dictionary as inputs
vis_a = pyLDAvis.gensim.prepare(lda_model_a, grpa_bow, grpa_dictionary)
pyLDAvis.display(vis_a) # renders the interactive visualization inline
# Group B (debate-only)
vis_b = pyLDAvis.gensim.prepare(lda_model_b, grpb_bow, grpb_dictionary)
pyLDAvis.display(vis_b)
Comparing the dictionary method vs LDA¶
The dictionary method helped split the two groups based on whether moral/trait language was used. LDA helped narrow down the topics each group talked about. The dictionary method helped give an overall view of how many users were discussing the game from an emotional point of view and how many were defending the gameplay mechanics. It's interesting because when you browse the ArcRaiders subreddit it looks like the majority of the posts are about someone expressing grievance about the game. However, according to this it looks like those types of posts and comments are in the minority.
The dictionary method helped distinguish that difference and answered my main research question about the extent to which users use moral and trait-based language and LDA helped solidify the types of topics that each group centered around which helps deepen the understanding of each group's general perspectives.
Which group's language is more emotionally charged?¶
Vader (Valence Aware Dictionary for Sentiment Reasoner) is a rule-based sentiment analyzer NLTK module that provides sentiment based on the words used and labels based on their semantic orientation as either positive or negative. I ran it on both the moral/trait (Group A) and debate-only (Group B) comments focusing on their compound scores that represents the overall sentiment of a piece of text. The score ranges from -1(most extreme negative) to +1 (most extreme positive).
# downloads the VADER sentiment lexicon if not already installed
nltk.download('vader_lexicon')
[nltk_data] Downloading package vader_lexicon to [nltk_data] C:\Users\anjel\AppData\Roaming\nltk_data... [nltk_data] Package vader_lexicon is already up-to-date!
True
# Initializes the VADER sentiment analyzer
# Returns pos, neg, neu, and compound scores for each comment
# Compound score represents overall sentiment (-1 to +1)
# Group A (moral/trait)
sentim_analyzer = SentimentIntensityAnalyzer()
grpa_scores = []
for comment in df_moral['body']:
scores = sentim_analyzer.polarity_scores(comment)
grpa_scores.append(scores['compound'])
# Group B (debate-only)
grpb_scores = []
for comment in df_debate_only['body']:
scores = sentim_analyzer.polarity_scores(comment)
grpb_scores.append(scores['compound'])
# Calculate average compound sentiment score for each group
# Divides total compound scores by number of comments to get the mean
grpa_avg_score = sum(grpa_scores) / len(grpa_scores)
grpb_avg_score = sum(grpb_scores) / len(grpb_scores)
print("Group A average sentiment:", grpa_avg_score)
print("Group B average sentiment:", grpb_avg_score)
Group A average sentiment: -0.062477475247524755 Group B average sentiment: 0.12794543610547668
# Creates a dataframe with the average sentiment score for each group
# Used to generate the bar chart in the next cell
grp_avg_scores = pd.DataFrame({
'Group': ['Group A (moral/trait)', 'Group B (debate-only)'],
'Average Sentiment': [grpa_avg_score, grpb_avg_score]
})
Which group uses more positive or negative language on average?¶
After receiving all the compound scores for each group I then found the average compound score. Group A (moral/trait) came out with a score of -0.06 which means it is slightly negative in sentiment, and Group B (debate-only) had an average compound score of +0.12 which is slightly positive. I then created a boxplot to get a good overview of the compound scores for each group and made these interesting findings.
# Bar chart comparing average sentiment scores between groups
# axhline adds a reference line at 0 to clearly show positive vs negative sentiment
plt.figure(figsize=(8, 6))
ax = sns.barplot(data=grp_avg_scores,
x='Group',
y='Average Sentiment',
hue='Group',
palette="gist_heat",
legend=False)
plt.title("Average Sentiment Score by Group", fontweight='bold', pad=20)
plt.xlabel('Group', fontweight='bold')
plt.ylabel('Average Sentiment', fontweight='bold')
plt.axhline(0, color='grey', linewidth=.5)
plt.show()
How does sentiment vary within each group?¶
Group A has a median of -.16 which shows that more than half of Group A's comments are negative. Group B has a median of +.18 showing that more than half of Group B's comments are positive. Group A has a standard deviation of 0.70 demonstrating that it is more volatile in sentiment than Group B. The wider box for Group A confirms how much more variation there is in how people express moral/trait language. Some are either very positive or very negative. This could be because they may be defending their own moral position rather than attacking others. For example, a player might be angrily calling another player a rat (very negative) or passionately defending why PvE players deserve better treatment (a situation that can be scored positively while still using moral language).
Group A's 25th percentile is -0.75 which means that 25% of the moral/trait comments are very strongly negative, whereas Group B's 25th percentile is -0.44, so not as extreme. This finding shows that the most negative comments in Group A are significantly more negative than the most negative comments in Group B.
Both groups are close to reaching -1 and +1 at the extremes, which means that both use strongly worded comments in both directions.
# Creates a dataframe with individual compound scores for each comment in both groups
# Used to generate the boxplot in the next cell showing sentiment distribution
grp_scores = pd.DataFrame({
'Group': ['Group A (moral/trait)'] * len(grpa_scores) + ['Group B (debate-only)'] * len(grpb_scores),
'Compound Score': grpa_scores + grpb_scores
})
# Boxplot showing the distribution of compound sentiment scores for each group
# Reveals median, spread, and outliers within each group's sentiment range
sns.boxplot(x='Group',
y='Compound Score',
data=grp_scores,
palette='gist_heat',
hue='Group',
legend=False)
plt.title("Sentiment Score Distribution by Group", fontweight='bold', pad=20)
plt.xlabel('Group', fontweight='bold')
plt.ylabel('Compound Score', fontweight='bold')
plt.show()
# Descriptive statistics for Group A (moral/trait) sentiment scores
# Includes mean, std, min, max, and percentiles
pd.Series(grpa_scores).describe()
count 404.000000 mean -0.062477 std 0.700407 min -0.996100 25% -0.753500 50% -0.161450 75% 0.674600 max 0.998200 dtype: float64
# Descriptive statistics for Group B (debate-only) sentiment scores
# Includes mean, std, min, max, and percentiles
pd.Series(grpb_scores).describe()
count 1479.000000 mean 0.127945 std 0.623916 min -0.990800 25% -0.444050 50% 0.177900 75% 0.710600 max 0.997600 dtype: float64
Which group writes the most?¶
For this analysis, I wanted to see if there was a difference in how long the comments were for each group. I hypothesized that Group A (moral/trait) would have longer comments due to their use of more emotionally charged language.
To do this, I took each comment and split it into words, then I counted each word for that specific comment. I then was able to add up all the words that were counted per comment for each group and find the average number of words per comment.
# Calculates the average number of words per comment for each group
# split() breaks each comment into words and len() counts them
grpa_avg_words = df_moral['body'].apply(lambda x: len(x.split())).mean().round()
grpb_avg_words = df_debate_only['body'].apply(lambda x: len(x.split())).mean().round()
print("Group A (moral/trait):", grpa_avg_words, "words per comment")
print("Group B (debate-only):", grpb_avg_words, "words per comment")
Group A (moral/trait): 100.0 words per comment Group B (debate-only): 72.0 words per comment
Do commenters who use moral language write longer comments?¶
It turns out that my hunch was correct. Group A (moral/trait) averaged around 100 words per comment, and Group B (debate-only) averaged about 72 words per comment. That's about 40% longer on average. I was surprised to see that Group B wasn't too far behind in comment length as I thought they wouldn't write so much because they didn't feel as passionate about the topic as Group A did.
# Creates a dataframe with the average comment length for each group
# Used to generate the bar chart in the next cell
grp_avg_words = pd.DataFrame({
'Group': ['Group A (moral/trait)', 'Group B (debate-only)'],
'Average Length': [grpa_avg_words, grpb_avg_words]
})
grp_avg_words
| Group | Average Length | |
|---|---|---|
| 0 | Group A (moral/trait) | 100.0 |
| 1 | Group B (debate-only) | 72.0 |
# Bar chart comparing average comment length between groups
# axhline adds a reference line at 0 as a baseline
plt.figure(figsize=(8, 6))
ax = sns.barplot(data=grp_avg_words,
x='Group',
y='Average Length',
hue='Group',
palette="gist_heat",
legend=False)
plt.title("Comment Length Comparison: Group A (moral/trait) vs Group B (debate-only)", fontweight='bold', pad=20)
plt.xlabel('Group', fontweight='bold')
plt.ylabel('Average Comment Length (By Word)', fontweight='bold')
plt.axhline(0, color='grey', linewidth=.5)
plt.show()
How does comment length differ between the two groups?¶
Group A (moral/trait) comment length ranged from 2 to 626 words whereas Group B (debate-only) ranged from 3 to 499 words. The standard deviation for Group A was 91.5 vs Group B's 61.7 which confirms Group A's comment lengths are more spread out. Finally, 75% of Group A comments were under 134 words, while 75% of Group B comments were under 93 words, so even the upper range of Group A comments tends to be longer than the upper range of Group B comments.
# Calculates the word count for each individual comment in Group A (moral/trait)
# Returns a series of word counts used to generate the boxplot in the next cell
grpa_comment_length = df_moral['body'].apply(lambda x: len(x.split()))
# Calculates the word count for each individual comment in Group B (debate-only)
grpb_comment_length = df_debate_only['body'].apply(lambda x: len(x.split()))
# Combines both groups into a single dataframe for the boxplot
grp_comment_length = pd.DataFrame({
'Group': ['Group A (moral/trait)'] * len(grpa_comment_length) + ['Group B (debate-only)'] * len(grpb_comment_length),
'Word Count': list(grpa_comment_length) + list(grpb_comment_length)
})
# Boxplot showing the distribution of comment lengths for each group
# Reveals median, spread, and outliers in comment length within each group
plt.figure(figsize=(6,8))
sns.boxplot(x='Group',
y='Word Count',
data=grp_comment_length,
palette='gist_heat',
hue='Group',
legend=False)
plt.title("Comment Length: Group A (moral/trait) vs Group B (debate-only)", fontweight='bold', pad=20)
plt.xlabel('Group', fontweight='bold', labelpad=10)
plt.ylabel('Comment Length (By Word)', fontweight='bold', labelpad=10)
plt.show()
# Descriptive statistics for Group A (moral/trait) comment lengths
# Includes mean, std, min, max, and percentiles
grpa_comment_length.describe()
count 404.000000 mean 100.220297 std 91.545855 min 2.000000 25% 38.000000 50% 75.000000 75% 134.000000 max 626.000000 Name: body, dtype: float64
# Descriptive statistics for Group B (debate-only) comment lengths
# Includes mean, std, min, max, and percentiles
grpb_comment_length.describe()
count 1479.000000 mean 71.580798 std 61.701177 min 3.000000 25% 30.000000 50% 54.000000 75% 93.500000 max 499.000000 Name: body, dtype: float64
Does a supervised model agree with the dictionary method?¶
The next method I used was SVM which is a supervised machine learning classification model. It finds the best boundary to separate two groups based on certain features. I used SVC from sklearn with an RBF (Radial Basis Function) kernel to do this because it works well when the relationship between features isn't linear, it handles complexity better than a linear kernel, and it is a safe default for these types of models. I chose this method partly at the suggestion of my professor as a way to identify the two groups with a machine learning model. I also found that it's a great way to validate whether the dictionary method's grouping could be learned by a model. The Group A and Group B labels from the dictionary method served as the training data. I used TF-IDF vectorization to convert the text to numbers, did an 80/20 train/test split and used a 1 label for the moral/trait group (Group A), and a 0 for the debate-only group (Group B).
# Assigns binary labels for the SVM model
# 1 = Group A (moral/trait), 0 = Group B (debate-only)
df_moral['label'] = 1
df_debate_only['label'] = 0
# Combines both groups into a single dataframe for the SVM model
df_svm = pd.concat([df_moral, df_debate_only])
SVM Results¶
The overall accuracy of the model was 85%. Group B performed very well with a recall of 1.00 and an F1 of 0.91. A recall of 1.00 means it catches basically every Group B comment correctly. Group A struggled with a recall of 0.30 and F1 of 0.46. For Group A (class 1) precision of 1.00 means when it predicts a comment is moral/trait language it's always right. But recall of 0.30 means it only catches 30% of the actual moral/trait comments meaning it's missing 70% of them. The confusion matrix shows 296 correct Group B predictions but only 24 correct Group A predictions, and 57 false negatives. As I suspected, this is a class imbalance because Group B is much larger (1,479 comments) than Group A (404 comments). The model learned to favor predicting Group B. It plays it safe by defaulting to the majority class. This finding confirms that the differing sizes of the groups does in fact create bias and it would be a good idea to hand-label a sample of comments to use as training data for a more balanced model.
# Converts cleaned text into numerical TF-IDF features the SVM model can process
vectorizer = TfidfVectorizer()
matrix = vectorizer.fit_transform(df_svm['text_clean'])
X = matrix
y = df_svm['label']
# Splits data into 80% training and 20% testing sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Initializes SVC model with RBF kernel
# RBF handles non-linear relationships between features better than a linear kernel
model = SVC(kernel='rbf', C=1.0, gamma='scale')
# Fits model to training data
model.fit(X_train, y_train)
# Makes predictions on the test set
predictions = model.predict(X_test)
# Evaluates model performance
print("===== Classification Report =====")
print(classification_report(y_test, predictions))
print("===== Confusion Matrix =====")
print(confusion_matrix(y_test, predictions))
===== Classification Report =====
precision recall f1-score support
0 0.84 1.00 0.91 296
1 1.00 0.30 0.46 81
accuracy 0.85 377
macro avg 0.92 0.65 0.68 377
weighted avg 0.87 0.85 0.81 377
===== Confusion Matrix =====
[[296 0]
[ 57 24]]
# Generates confusion matrix heatmap showing model predictions vs actual labels
# cbar=False removes the colorbar since counts are already annotated in each cell
# xticks and yticks replace the default 0/1 labels with descriptive group names
cm = confusion_matrix(y_test, predictions)
plt.figure(figsize=(6, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Reds', cbar=False)
plt.title('SVM Confusion Matrix: Class Imbalance Between Groups', fontweight='bold', pad=20)
plt.xlabel('Predicted', fontweight='bold', labelpad=20)
plt.ylabel('Actual', fontweight='bold', labelpad=20)
plt.xticks(ticks=[0.5, 1.5], labels=['Group B (debate-only)', 'Group A (moral/trait)'])
plt.yticks(ticks=[0.5, 1.5], labels=['Group B (debate-only)', 'Group A (moral/trait)'])
plt.show()
What did this project reveal about how moral and trait-based language is used in PvP vs PvE debates on r/ARCRaiders?¶
My hypothesis was that commenters whose comments contained moral/trait language (Group A) would have more emotionally charged comments that related to their grievances of gameplay such as feeling the game was unfair or frustration about repeatedly being killed by other players, and that commenters who did not contain moral/trait language and focused more on debate-only (Group B) comments relating to the PvPvE gameplay style would use more neutral language.
The Dictionary method findings showed that Group A (moral/trait) uses words like "toxic", "rat", "camping", and "cheap" exclusively or significantly more than Group B (debate-only). After Laplace smoothing, the following words were more likely to appear in Group A (moral/trait): "toxic" was 147 times more likely to appear, "unfair" was 102 times more likely to appear, and "rat" was 82 times more likely to appear in Group A (moral/trait). Group B (debate-only) had no exclusive words of its own.
The LDA findings found that Group A topics are grievance-based and focus on comments related to unfair PvP experience, debate about friendly lobbies, and game ruining behavior. Group B's topics are game-mechanics based and focus on comments related to defending PvPvE design, matchmaking mechanics, risk and reward system, and debate over player skills.
The VADER findings showed that Group A average sentiment was slightly negative (-0.06) whereas Group B average sentiment was slightly positive (+0.12). Group A has a higher sentiment variance than Group B (std 0.70 vs 0.62), and finally Group A has a median of -0.16 indicating that more than half of the comments are negative in sentiment.
The comment length findings showed that Group A was about 40% longer on average and averaged 100 words per comment versus Group B which averaged 72 words per comment. Group A also had a higher variance in length (std 91.5 vs 61.7).
The SVM findings indicated the model had an 85% overall accuracy. It struggled with labeling Group A due to the class imbalance of number of comments for each group confirming that group size disproportion does indeed create bias.
Conclusion¶
Overall, the findings support my original hypothesis that Reddit commenters on the ARCRaiders subreddit use more moral/trait-language behavior when discussing the game. The types of comments found split into two general topics: Gameplay grievances (Group A) and PvPvE gameplay mechanics (Group B). Group A (moral/trait) comments centered around emotionally charged language that attacked players who they believe used game ruining behavior for example grievances of being killed on sight before being asked if they were friendly. This led to the majority of comments by Group A (moral/trait) having more negative sentiment in their comments than Group B. Group B (debate-only) comments mainly focused on defending the game's mechanics and thus their sentiment was on average slightly positive as it seems they were less emotionally invested in their arguments. This was clearly seen when comparing the range of sentiment and length of comments as Group A ranged widely between positive and negative sentiment indicating a wide range of emotion. Group A also wrote longer comments on average, further suggesting greater emotional investment in the debate.
Challenges¶
The biggest challenge I had was in the preprocessing stage. Apostrophes were the most difficult to remove because of the different characters users would use for their apostrophe such as standard apostrophes, curly/smart quotes, backticks, and left/right single quotation marks. I had to apply the code for removing apostrophes twice, one before tokenizing and one after tokenizing because they kept slipping through. Next was increasing the stopwords list since there were a lot of meaningless words that were showing up in the wordclouds that didn't add anything meaningful to them. In addition, stopword removal affected some expected trigrams like "shoot_on_sight" which shows the trade-offs of preprocessing decisions.
The initial scraper, built with ChatGPT's assistance, wasn't specific enough to capture the type of comments and posts that I was interested in. I had to ask ChatGPT to write another script that filtered out comments that were unrelated to PvP and PvE discourse, things like bug complaints, comments on game design, etc.
Another challenge I faced was formulating what each topic theme was. Some themes were easier to distinguish than others. For example Topics 0 and 1 from Group A are very similar but nuanced in their difference. Both contain the words kill and fight but the remaining words shift the topics where Topic 0 is more about the unfairness of PvP and Topic 1 centered specifically around friendly lobbies.
A continuing challenge was dealing with the large difference in group sizes. For example, when I ran the topic modeling algorithm I had to reduce the number of topics to 3 for Group A because there simply wasn't enough comments and posts to pull in contrast to Group B where I was able to pull out 4 topics. After running the SVC model, I noticed it struggled with labeling Group A due to class imbalance, further confirming that group size disproportion creates bias in the analysis.
Finally, in my most recent analysis I discovered that some of the comments were written in a foreign language. Specifically the longest comment in Group B(debate-only) was in German. This is entirely due to Reddit being an international forum so finding foreign languages in the discussions makes sense. However, this means that it may have affected the analysis since VADER and preprocessing modules are designed for English language text.
Next Steps¶
In this final analysis, I was able to do some of the things that I wrote in my last check-in #3 including calculating and comparing the comment lengths, sentiment analysis and sentiment labeling using VADER for Group A (moral/trait) and Group B (debate-only). Since the SVM model didn't do well because of group disproportion, I would like to relabel the comments again using an ML model to more accurately label comments as moral/trait or debate-only by using training data that I hand coded from a sample of 400-500 comments. I would be interested to see if the results come out the same or if the ML model finds that the groups are more proportional than originally thought. This would most likely produce more accurate results across all the analyses I have done. There are a few other analyses I would like to explore that I wasn't able to do for this project. First, a network diagram of co-occurring moral words to find if there is a pattern of phrases that moral/trait-language users seem to repeatedly use in their comments. Second, I'd like to examine where moral words appear in the beginning or end of their comments. This could help determine the commenter's emotional state when writing their comments and whether they start off frustrated or as they write end up more frustrated. Third, I'd like to expand on trigrams. The trigrams I found in this project seemed incomplete as I believe many were excluded because of too many stopwords being removed in the preprocessing steps, for example "shoot on sight" was cut down to "shoot sight" and was considered a bigram where it should be considered a trigram. Additionally, I'd like to apply psychological frameworks to get a better understanding of how the different commenters phrase their comments and how they relate to what we know of human behavior. Finally, I'd like to create an ML model that detects commenter stances as to whether they are pro-PvE or pro-PvP.
Citations¶
Corpus:
- r/ARCRaiders. (2026). ARC Raiders subreddit. Reddit. https://www.reddit.com/r/ARCRaiders/
AI Tools:
Anthropic. (2026). Claude (Claude Sonnet 4.6) [Large language model]. https://www.anthropic.com
OpenAI. (2026). ChatGPT (GPT-5) [Large language model]. https://www.openai.com
Reddit API:
- Reddit Inc. (2026). Reddit public JSON API. Reddit. https://www.reddit.com/dev/api/
Tutorials/Resources:
- Selva, P. (2019). Topic modeling visualization: How to present results of LDA models. Machine Learning Plus. https://www.machinelearningplus.com/nlp/topic-modeling-visualization-how-to-present-results-lda-models/
Libraries:
Harris, C.R., et al. (2020). Array programming with NumPy. Nature, 585, 357–362.
Hutto, C., & Gilbert, E. (2014). VADER: A parsimonious rule-based model for sentiment analysis of social media text. Proceedings of the Eighth International Conference on Weblogs and Social Media.
McKinney, W. (2010). Data structures for statistical computing in Python. Proceedings of the 9th Python in Science Conference, 445, 51–56.
Pedregosa, F., et al. (2011). Scikit-learn: Machine learning in Python. Journal of Machine Learning Research, 12, 2825–2830.
Rehurek, R., & Sojka, P. (2010). Gensim: Topic modelling for humans. https://radimrehurek.com/gensim/
Hunter, J. D. (2007). Matplotlib: A 2D graphics environment. Computing in Science & Engineering, 9(3), 90–95.
Explosion AI. (2024). spaCy: Industrial-strength natural language processing. https://spacy.io
Waskom, M. (2021). Seaborn: Statistical data visualization. Journal of Open Source Software, 6(60), 3021.
Bird, S., Klein, E., & Loper, E. (2009). Natural language processing with Python. O'Reilly Media. (NLTK)