Empirical Analysis — Phase 2: The Transgressive Periphery

This document performs a temporal analysis of human-annotated hate speech using the Election_tweet_2021.csv dataset.

1. Load Libraries

library(tidyverse)
library(lubridate)
library(slider)

2. Data Ingestion & Snowflake Decoding

We read the academic dataset (from Jafri et al., 2023). Column 1 contains the Tweet ID (read as a double to preserve the 64-bit integer), and Column 2 contains the Labels (0 = Non-Hate, 1 = Hate).

Methodology: Since Twitter developer terms prohibit sharing raw text/dates, we reverse-engineer the exact timestamp directly from the Tweet ID using the Snowflake Algorithm. The formula is: Timestamp (ms) = floor(Tweet_ID / 2^22) + Twitter Epoch (1288834974657).

social_raw <- read_csv("Election_tweet_2021.csv", 
                       col_types = cols(`Tweet Id` = col_double(), Labels = col_integer()))

social_temporal <- social_raw %>%
  rename(tweet_id = `Tweet Id`, label = Labels) %>%
  mutate(
    # Snowflake decoding to recover exact dates
    timestamp_ms = floor(tweet_id / 4194304) + 1288834974657,
    parsed_date = as.POSIXct(timestamp_ms / 1000, origin = "1970-01-01", tz = "UTC"),
    parsed_date = as.Date(parsed_date)
  )

3. Temporal Windowing (UP Election 2021-2022)

We anchor the timeline to March 10, 2022 (Counting Day for the 2022 UP Assembly Elections) and establish a 90-day pre-election campaign window starting December 10, 2021, compared against the preceding baseline.

ELECTION_ANCHOR <- as.Date("2022-03-10")

social_clean <- social_temporal %>%
  filter(parsed_date <= ELECTION_ANCHOR) %>%
  mutate(
    days_to_election = as.numeric(difftime(ELECTION_ANCHOR, parsed_date, units = "days")),
    # 90-Day Pre-Election Campaign Window vs. Baseline (Starts Dec 10, 2021)
    election_window = ifelse(days_to_election <= 90, "Election_Window (90d)", "Baseline")
  )

print("=== TEMPORAL DISTRIBUTION OF TWEETS ===")
[1] "=== TEMPORAL DISTRIBUTION OF TWEETS ==="
print(table(social_clean$election_window))

             Baseline Election_Window (90d) 
                  299                 11158 

4. Daily Aggregation & Rolling Averages

We aggregate the posts daily, fill missing days with zeroes to prevent line breaks in the visualization, and apply 7-day rolling means for smoothed tracking of hate speech volume.

daily_periphery <- social_clean %>%
  group_by(parsed_date, election_window) %>%
  summarise(
    Total_Posts = n(),
    Hate_Posts = sum(label == 1),
    Non_Hate_Posts = sum(label == 0),
    .groups = "drop"
  ) %>%
  # Fill missing days with 0 to prevent line breaks in the ggplot
  complete(
    parsed_date = seq(min(parsed_date), max(parsed_date), by = "day"),
    fill = list(Total_Posts = 0, Hate_Posts = 0, Non_Hate_Posts = 0)
  ) %>%
  arrange(parsed_date) %>%
  mutate(
    # Re-derive election_window for filled rows
    days_to_election = as.numeric(difftime(ELECTION_ANCHOR, parsed_date, units = "days")),
    election_window = ifelse(days_to_election <= 90, "Election_Window (90d)", "Baseline"),
    
    # Apply 7-Day Rolling Means for smoothed visualization
    Roll_Hate = slide_dbl(Hate_Posts, mean, .before = 6, .after = 0),
    Roll_Total = slide_dbl(Total_Posts, mean, .before = 6, .after = 0)
  )

5. Poisson GLM Regression

This model tests whether the 90-day campaign window predicts an explosion in hate speech. An offset controls for the fact that total tweeting volume also generally increases during campaigns. An Incidence Rate Ratio (IRR) significantly greater than 1 indicates that hate speech volume disproportionately surged in the digital periphery during the final 90 days.

print("=== REGRESSION RESULTS: FRANCHISED TRANSGRESSION ON SOCIAL MEDIA ===")
[1] "=== REGRESSION RESULTS: FRANCHISED TRANSGRESSION ON SOCIAL MEDIA ==="
daily_periphery_reg <- daily_periphery %>% mutate(log_posts = log(Total_Posts + 1))

hate_model <- glm(Hate_Posts ~ election_window + offset(log_posts), 
                  family = poisson, data = daily_periphery_reg)

coefs <- summary(hate_model)$coefficients
results <- tibble(
  Metric = "Human-Annotated Hate Speech",
  Estimate = round(coefs["election_windowElection_Window (90d)", "Estimate"], 4),
  P_Value = round(coefs["election_windowElection_Window (90d)", "Pr(>|z|)"], 6),
  IRR = round(exp(coefs["election_windowElection_Window (90d)", "Estimate"]), 3)
)

print(results)
# A tibble: 1 × 4
  Metric                      Estimate  P_Value   IRR
  <chr>                          <dbl>    <dbl> <dbl>
1 Human-Annotated Hate Speech   -0.654 0.000004  0.52

6. Publication Plot — The Surge of the Periphery

final_phase2_plot <- ggplot(daily_periphery, aes(x = parsed_date)) +
  
  # Smoothed Line & Ribbon
  geom_line(aes(y = Roll_Hate), color = "#C0392B", linewidth = 1.1) +
  geom_ribbon(aes(ymin = 0, ymax = Roll_Hate), fill = "#C0392B", alpha = 0.2) +
  
  # 90-Day Campaign Window Indicator
  geom_vline(xintercept = as.Date("2021-12-10"), linetype = "dashed", color = "black", linewidth = 0.7) +
  annotate("rect", xmin = as.Date("2021-12-10"), xmax = ELECTION_ANCHOR, ymin = -Inf, ymax = Inf, 
           alpha = 0.05, fill = "red") +
  
  # Text Annotation (Increased to size = 4 for better visibility)
  annotate("text", x = as.Date("2021-12-12"), 
           y = max(daily_periphery$Roll_Hate, na.rm = TRUE) * 0.9, 
           label = "90-Day \nCampaign Window", 
           angle = 0, hjust = 0, color = "black", fontface = "bold",
           size = 4) +
  
  # Date Formatting
  scale_x_date(date_breaks = "1 month", date_labels = "%b %Y") +
  
  labs(
    title = "The Surge of Peripheral Hate Speech",
    subtitle = "7-day rolling mean of annotated divisive tweets (2021–2022 UP Election Cycle)",
    x = "Timeline",
    y = "Daily Hate Speech Volume (7-Day Mean)"
  ) +
  
  # Base theme size
  theme_minimal(base_size = 14) +
  theme(
    plot.title = element_text(face = "bold", size = 16),
    plot.subtitle = element_text(size = 13),
    axis.title = element_text(face = "bold", size = 13),
    axis.text = element_text(size = 11),
    plot.margin = margin(t = 10, r = 10, b = 10, l = 15)
  )

print(final_phase2_plot)

# Save the plot
ggsave("Phase2_Peripheral_Surge.png", plot = final_phase2_plot, width = 8, height = 5, dpi = 300)