The “15 minute city” is a trending topic in urban planning for its focus on walkable communities with equitable access to services. However, it also has appeared as a buzzword for government control, especially in a post-COVID world. Understanding the public discourse around “15 minute cities” can guide planners in addressing misconceptions while garnering community support for sustainable urban design. In this analysis, I aim to better understand changes in sentiment of the “15 minute city” over the past 12 months on Reddit.
library(RedditExtractoR)
library(anytime)
library(magrittr)
library(httr)
library(tidytext)
library(tidyverse)
library(igraph)
library(ggraph)
library(wordcloud2)
library(textdata)
library(sf)
library(tmap)
library(here)
library(dplyr)
library(stringr)
library(stringi)
library(lubridate)
library(sentimentr)
library(syuzhet)
# Using keyword '15 minute city'
threads_1 <- find_thread_urls(keywords = '15 minute city',
sort_by = 'relevance',
period = 'all') %>%
drop_na()
## parsing URLs on page 1...
## parsing URLs on page 2...
## parsing URLs on page 3...
rownames(threads_1) <- NULL
colnames(threads_1)
## [1] "date_utc" "timestamp" "title" "text" "subreddit" "comments"
## [7] "url"
head(threads_1, 3) %>% knitr::kable()
date_utc | timestamp | title | text | subreddit | comments | url |
---|---|---|---|---|---|---|
2023-02-13 | 1676258909 | Edmonton’s 15-minute city plan | Edmonton | 96 | https://www.reddit.com/r/Edmonton/comments/110y6ty/edmontons_15minute_city_plan/ | |
2023-02-18 | 1676714201 | Be careful visiting the city centre today, a protest against ‘15 minute cities’ is taking place on Broad Street | The usual far right dickheads and nationalists will be out and about… | oxford | 217 | https://www.reddit.com/r/oxford/comments/115bxug/be_careful_visiting_the_city_centre_today_a/ |
2024-10-08 | 1728410439 | Bill Gates is manufacturing hurricanes to turn Tampa in a 15-minute city. And the evil plan was leaked through Alexa. | insanepeoplefacebook | 34 | https://www.reddit.com/r/insanepeoplefacebook/comments/1fz682l/bill_gates_is_manufacturing_hurricanes_to_turn/ |
# write.csv(threads_1, "15mincity.csv", row.names = FALSE)
# Filter rows that mention "15 minute city" or "15 minute cities"
filtered_threads <- threads_1 %>%
filter(
str_detect(title, "\\b15 minute city\\b|\\b15 minute cities\\b") |
str_detect(text, "\\b15 minute city\\b|\\b15 minute cities\\b")
)
# Remove the one entry from 2022 to only show 2023 and 2024
filtered_threads <- filtered_threads %>%
filter(timestamp != 1642900768)
# Tokenize
words <- threads_1 %>%
unnest_tokens(output = word, input = text, token = "words")
# Load list of stop words - from the tidytext package
data("stop_words")
# View random 50 words
print(stop_words$word[sample(1:nrow(stop_words), 100)])
## [1] "more" "thinks" "out" "alone" "could"
## [6] "h" "gives" "lately" "is" "up"
## [11] "opens" "these" "new" "clear" "did"
## [16] "would" "beside" "see" "got" "please"
## [21] "then" "had" "any" "secondly" "others"
## [26] "any" "viz" "hardly" "less" "away"
## [31] "particularly" "shows" "needed" "whole" "parts"
## [36] "everything" "u" "on" "us" "upon"
## [41] "overall" "said" "perhaps" "howbeit" "f"
## [46] "faces" "didn't" "do" "herself" "how's"
## [51] "or" "known" "but" "eg" "various"
## [56] "were" "don't" "yourselves" "here's" "its"
## [61] "keeps" "little" "too" "smallest" "what's"
## [66] "a" "down" "some" "having" "great"
## [71] "each" "than" "brief" "few" "just"
## [76] "from" "furthermore" "their" "mr" "very"
## [81] "onto" "go" "i've" "seconds" "by"
## [86] "her" "different" "orders" "she" "hasn't"
## [91] "he'd" "fully" "asked" "few" "over"
## [96] "also" "look" "our" "those" "you'll"
# Regex that matches URL-type string
replace_reg <- "http[s]?://[A-Za-z\\d/\\.]+|&|<|>"
words_clean <- threads_1 %>%
# drop URLs
mutate(text = str_replace_all(text, replace_reg, "")) %>%
# Tokenization (word tokens)
unnest_tokens(word, text, token = "words") %>%
# drop stop words
anti_join(stop_words, by = "word") %>%
# drop non-alphabet-only strings
filter(str_detect(word, "[a-z]"))
# Check the number of rows after removal of the stop words. There should be fewer words now
print(
glue::glue("Before: {nrow(words)}, After: {nrow(words_clean)}")
)
## Before: 10786, After: 4115
words_clean %>%
count(word, sort = TRUE) %>%
top_n(20, n) %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(x = word, y = n)) +
geom_col() +
xlab(NULL) +
coord_flip() +
labs(x = "words",
y = "counts",
title = "Unique wordcounts")
# Filter out keywords
words_clean_filtered <- words_clean %>%
filter(!word %in% c("15", "minute", "city", "cities"))
n <- 20
h <- runif(n, 0, 1)
s <- runif(n, 0.6, 1)
v <- runif(n, 0.3, 0.7)
df_hsv <- data.frame(h = h, s = s, v = v)
pal <- apply(df_hsv, 1, function(x) hsv(x['h'], x['s'], x['v']))
pal <- c(pal, rep("grey", 10000))
words_clean_filtered %>%
count(word, sort = TRUE) %>%
wordcloud2(color = pal,
minRotation = 0,
maxRotation = 0,
ellipticity = 0.8)
# Extract trigrams from the text data
words_ngram <- filtered_threads %>%
mutate(text = str_replace_all(text, replace_reg, "")) %>%
select(text) %>%
unnest_tokens(paired_words, text, token = "ngrams", n = 3)
# Separate trigrams into individual words
words_ngram_pair <- words_ngram %>%
separate(paired_words, into = c("word1", "word2", "word3"), sep = " ")
# Filter trigrams
words_ngram_pair_filtered <- words_ngram_pair %>%
filter(
!word1 %in% stop_words$word &
!word2 %in% stop_words$word &
!word3 %in% stop_words$word, # Remove stop words
str_detect(word1, "^[a-zA-Z]+$") &
str_detect(word2, "^[a-zA-Z]+$") &
str_detect(word3, "^[a-zA-Z]+$") # Remove non-alphabetic terms
)
# Count trigram frequencies
trigram_counts <- words_ngram_pair_filtered %>%
count(word1, word2, word3, sort = TRUE)
# Display the top 20 trigrams
top_trigrams <- trigram_counts %>%
head(20)
# Create table
trigram_table <- trigram_counts %>%
mutate(trigram = paste(word1, word2, word3, sep = " ")) %>%
select(trigram, n) %>%
arrange(desc(n)) %>%
head(20)
trigram_table
## trigram n
## 1 cities north texas 2
## 2 fields community suburs 2
## 3 frisco fields community 2
## 4 main street america 2
## 5 minute cities north 2
## 6 north texas frisco 2
## 7 texas frisco fields 2
## 8 add public transport 1
## 9 adding bike lanes 1
## 10 aka credit score 1
## 11 american households owned 1
## 12 anti car isn 1
## 13 anti vax climate 1
## 14 approved emission control 1
## 15 approved insurance coverage 1
## 16 approved licence plate 1
## 17 aux services essentiels 1
## 18 bakery bar parks 1
## 19 bar parks movie 1
## 20 barcelona melbourne oxford 1
Interestingly, the city of Frisco, Texas, located in the Dallas-Fort Worth metroplex, is highlighted as a top trigram. The focus seems to be on Fields, an announced planned mixed use community there that is situated on a 2,544-acre site and will be home to the PGA and the University of North Texas. It is being branded, at last by commenters online, as a model “15 minute city.”
# Create a network graph from trigram data
word_network <- trigram_counts %>%
mutate(trigram_label = paste(word1, word2, word3, sep = " ")) %>%
graph_from_data_frame(directed = FALSE)
ggraph(word_network, layout = "fr") +
geom_edge_link(aes(edge_alpha = 0.6, edge_width = n), show.legend = FALSE) +
geom_node_point(color = "darkslategray4", size = 3) +
geom_node_text(aes(label = name), vjust = 1.8, size = 4, check_overlap = TRUE) +
labs(title = "Trigram Word Network", x = NULL, y = NULL) +
theme_void()
## Warning: The `trans` argument of `continuous_scale()` is deprecated as of ggplot2 3.5.0.
## ℹ Please use the `transform` argument instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
Because the highest number of trigrams found was just two, I decided to look into bigrams as well.
# Extract and clean bigrams
top_bigrams <- filtered_threads %>%
mutate(text = str_replace_all(text, replace_reg, "")) %>%
unnest_tokens(paired_words, text, token = "ngrams", n = 2) %>%
separate(paired_words, into = c("word1", "word2"), sep = " ") %>%
filter(!word1 %in% stop_words$word, !word2 %in% stop_words$word) %>%
drop_na(word1, word2) %>%
count(word1, word2, sort = TRUE) %>%
slice_max(n, n = 10) %>%
mutate(bigram = paste(word1, word2))
# Create the bar chart
ggplot(top_bigrams, aes(x = reorder(bigram, n), y = n)) +
geom_bar(stat = "identity", fill = "steelblue") +
coord_flip() +
labs(
title = "Top 10 Bigrams (Excluding Stop Words)",
x = "Bigrams",
y = "Frequency"
) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
# Words to exclude
excluded_words <- c("15", "minute", "minutes", "city", "cities")
# Extract and clean bigrams
top_bigrams <- filtered_threads %>%
mutate(text = str_replace_all(text, replace_reg, "")) %>%
unnest_tokens(paired_words, text, token = "ngrams", n = 2) %>%
separate(paired_words, into = c("word1", "word2"), sep = " ") %>%
filter(!word1 %in% stop_words$word, !word2 %in% stop_words$word) %>%
drop_na(word1, word2) %>%
filter(!word1 %in% excluded_words, !word2 %in% excluded_words) %>% # Exclude specific words
count(word1, word2, sort = TRUE) %>%
slice_max(n, n = 4) %>% # Display only top 4 bigrams
mutate(bigram = paste(word1, word2))
# Create the bar chart
ggplot(top_bigrams, aes(x = reorder(bigram, n), y = n)) +
geom_bar(stat = "identity", fill = "steelblue") +
coord_flip() +
labs(
title = "Top 4 Bigrams (Excluding Specified and Stop Words)",
x = "Bigrams",
y = "Frequency"
) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
“Government approved” implies a conspiratorial slant to the usage of “15 minute cities,” while “walkable community,” “grocery store,” and “public transport” are all favorite aspects of modern urban planning.
I performed a sentiment analysis on my text data using a dictionary method that accommodates negations.
# Initial sentiment analysis on all posts, displaying the top 10 positively scored
sentiment_15min <- sentiment(filtered_threads$text) %>%
arrange(desc(sentiment))
head(sentiment_15min, 10) %>%
knitr::kable()
element_id | sentence_id | word_count | sentiment |
---|---|---|---|
62 | 9 | 9 | 1.4252667 |
19 | 8 | 21 | 0.9274260 |
6 | 10 | 1 | 0.6000000 |
30 | 6 | 8 | 0.5656854 |
9 | 14 | 5 | 0.4919350 |
24 | 1 | 5 | 0.4919350 |
25 | 7 | 9 | 0.4666667 |
71 | 4 | 15 | 0.4518481 |
73 | 4 | 14 | 0.4510033 |
37 | 6 | 12 | 0.4330127 |
# Set seed and code to randomly select 10 posts from the entire population
set.seed(1234)
senti_15min_filtered <- filtered_threads %>%
filter(nzchar(text) & !grepl("http[s]?://", text)) %>%
unnest(text)
random_indices <- sample(nrow(senti_15min_filtered), size = 10)
ten_15min_samples <- senti_15min_filtered[random_indices, ]
ten_15min_samples$sentiment_score <- sapply(ten_15min_samples$text, function(text) {
sentiment(text)$sentiment[1]
})
# Select columns and sort by sentiment
columns <- c("date_utc", "title", "text", "subreddit", "sentiment_score")
ten_15min_samples_final <- ten_15min_samples[, columns] %>%
arrange(desc(sentiment_score))
ten_15min_samples_final$title <- strtrim(ten_15min_samples_final$title, 50)
ten_15min_samples_final$text <- strtrim(ten_15min_samples_final$text, 50)
head(ten_15min_samples_final, 10) %>%
knitr::kable()
date_utc | title | text | subreddit | sentiment_score |
---|---|---|---|---|
2024-01-28 | What are 15 minute cities? | Like, I literally just saw it in a post mentioned | fuckcars | 0.2000000 |
2023-02-19 | 15 Minute Cities - Good or Bad? | I think they’re a good thing and would benefit alo | askTO | 0.1936492 |
2023-01-23 | The WEF agenda around 15-minute cities is a plot t | Some cities that are following the WEF urban agend | conspiracy | 0.1745743 |
2023-09-30 | What’s so bad about a 15 minute city? | Don’t people want to only walk a few minutes and h | NoStupidQuestions | 0.0000000 |
2023-04-21 | What’s with this weird conspiracy shit around 15 m | I don’t own a car. I bought a car after the Gamest | AskConservatives | 0.0000000 |
2024-06-03 | Conservatives will whine and cry about ’15 minute | That is all | fuckcars | 0.0000000 |
2024-02-01 | This is my take on the topic of 15 minute cities a | Just to clarify for those few that may not know, 1 | fuckcars | -0.1018402 |
2024-02-28 | I REALLY wish the right wing conspiracies about 15 | Shit, every time I see a right winger post a consp | fuckcars | -0.1756986 |
2024-11-01 | What is it with 15 minute city conspiracy theorist | I went to Facebook to look at 15 minute conspiraci | fuckcars | -0.2946278 |
2024-06-23 | 15 minute gulags are now being rolled out in Canad | Do not, under any circumstance accept the mandates | conspiracy_commons | -0.4939306 |
A sample text such as “Do not, under any circumstance accept the mandates” connotes strong negativity and hints at the user’s skepticism of the benefits of a “15 minute city” being normalized. Neutrality is demonstrated in comments such as “It used to be just called just a normal city” and “Don’t people want to only walk a few minutes.”
Positivity is reflected in the comment “I think they’re a good thing and would benefit…”. However, the sample text with the highest sentiment score was “Like, I literally just saw it in a post mentioned,” which, from a human perspective, does not indicate strong feelings one way or another but rather is quite neutral.
While the sentiment analysis seems mostly credible, examples such as the one above (the highest score) indicate that short and out-of-context texts may project neutrality or a misguided approach to positivity. Additionally, a comment such as “It used to be just called just a normal city” could be misclassified due to the ambiguity of tone.
# Run sentiment analysis code used for 10 samples on all text for plotting
sentiment_15minplots <- filtered_threads %>%
filter(nzchar(text) & !grepl("http[s]?://", text)) %>%
unnest(text) %>%
drop_na()
sentiment_15minplots$sentiment_score <-
sapply(sentiment_15minplots$text, function(text) {
sentiment(text)$sentiment[1]
})
columns <- c("date_utc", "title", "text", "subreddit", "sentiment_score")
sentiment_15minplots <- sentiment_15minplots[, columns] %>%
arrange(desc(sentiment_score))
sentiment_15minplots$title <- strtrim(sentiment_15minplots$title, 50)
sentiment_15minplots$text <- strtrim(sentiment_15minplots$text, 50)
sentiment_15minplots$DoW <- wday(sentiment_15minplots$date_utc, label = TRUE, abbr = FALSE)
sentiment_15minplots <- sentiment_15minplots %>% select(date_utc, DoW, title, text,
subreddit, sentiment_score)
# Density plot to show distribution of scores
ggplot(sentiment_15minplots, aes(x = sentiment_score)) +
geom_density(fill = "lightblue", alpha = 0.9) +
labs(title = "Distribution of Sentiment", x = "Sentiment_score", y = "Density") +
theme_dark()
The sentiment scores range from about -0.5 to 0.5, which indicates both positive and negative statements were found in the analysis. The distribution peaks above 0, suggesting most sentiments are neutral. While there is a balanced sentiment response, the results may be a bit skewed due to ambiguity in tone and a difficulty detecting sarcasm.
Below, the heightened number of terms occurring on Mondays may indicate more traffic and a displeasure in congestion at the beginning of the work week, leading to more users commenting on “15 minute cities.”
# Bar graph for day of week
sentiment_15minplots %>%
ggplot(aes(x = DoW)) +
geom_bar(fill = 'orange') +
theme_classic()
The following violin plot reflects how sentiments differ based on the day, with Sundays exemplifying the widest range of variability. Fewer extreme sentiments seem to be shared in the middle of the week (Wednesday and Thursday), while Monday and Friday show more concentrated distributions, which likely represent more consistency in sentiment levels.
Overall, most posts were neutral without an obvious slant towards positive or negative, especially considering the broad range of Reddit forums scraped (from the left-leaning r/fuckcars to the right-leaning r/AskConservatives). Further analysis of more user sentiments may be necessary to accurately capture human thought on a topic as broad and wide ranging as the 15 minute city.
# Violin plot by day of week
ggplot(sentiment_15minplots, aes(x = DoW, y = sentiment_score)) +
geom_violin(fill = "lightblue", color = "darkblue") +
labs(title = "Violin Plot by Group", x = "Group", y = "Value") +
theme_dark()