This report is an early checkpoint on the way to building a text prediction app (the kind that suggests the next word as you type, similar to the predictive keyboard on a smartphone). The goal here is not the finished app — it’s to show three things:
Everything below uses three text files — collected from blogs, news articles, and Twitter posts — all in US English, provided as the source data for this project.
# Data is downloaded once and cached locally — subsequent knits reuse the local copy
url <- "https://d396qusza40orc.cloudfront.net/dsscapstone/dataset/Coursera-SwiftKey.zip"
zip_path <- "Coursera-SwiftKey.zip"
if (!file.exists("final/en_US/en_US.blogs.txt")) {
if (!file.exists(zip_path)) {
download.file(url, destfile = zip_path, mode = "wb")
}
unzip(zip_path)
}
blogs_path <- "final/en_US/en_US.blogs.txt"
news_path <- "final/en_US/en_US.news.txt"
twitter_path <- "final/en_US/en_US.twitter.txt"
# Safety check with a clear error message if paths still aren't found
stopifnot(
"blogs file not found - check your working directory" = file.exists(blogs_path),
"news file not found - check your working directory" = file.exists(news_path),
"twitter file not found - check your working directory" = file.exists(twitter_path)
)
con <- file(blogs_path, "r"); blogs <- readLines(con, skipNul = TRUE); close(con)
con <- file(news_path, "r", encoding = "UTF-8"); news <- readLines(con, skipNul = TRUE); close(con)
con <- file(twitter_path, "r"); twitter <- readLines(con, skipNul = TRUE); close(con)
The three files are read in as plain text, one line per entry (one blog post, one news snippet, or one tweet per line).
The first thing worth checking, before any modeling, is simply: how big is this data, and how is it distributed across the three sources?
file_size_mb <- function(path) round(file.info(path)$size / 1024^2, 1)
word_count <- function(x) sum(stri_count_words(x))
summary_df <- data.frame(
Source = c("Blogs", "News", "Twitter"),
`File Size (MB)` = c(file_size_mb(blogs_path), file_size_mb(news_path), file_size_mb(twitter_path)),
`Lines` = c(length(blogs), length(news), length(twitter)),
`Total Words` = c(word_count(blogs), word_count(news), word_count(twitter)),
`Avg Words / Line` = round(c(word_count(blogs)/length(blogs),
word_count(news)/length(news),
word_count(twitter)/length(twitter)), 1),
`Longest Line (chars)` = c(max(nchar(blogs)), max(nchar(news)), max(nchar(twitter))),
check.names = FALSE
)
kable(summary_df, caption = "Table 1: Basic statistics for the three raw text sources")
| Source | File Size (MB) | Lines | Total Words | Avg Words / Line | Longest Line (chars) |
|---|---|---|---|---|---|
| Blogs | 200.4 | 899288 | 37546806 | 41.8 | 40833 |
| News | 196.3 | 1010206 | 34761151 | 34.4 | 11384 |
| 159.4 | 2360148 | 30096690 | 12.8 | 144 |
What this tells us, in plain terms:
This matters for the app: it means the model will need to learn from very different “styles” of writing — casual short bursts (Twitter), polished long-form writing (blogs), and formal reporting (news).
The full files are large (hundreds of MB combined), which is too slow to work with directly while exploring. So for this report, a random 1% sample of each file is used — enough to see reliable patterns without the processing time.
set.seed(1234)
sample_pct <- 0.01
sample_blogs <- sample(blogs, length(blogs) * sample_pct)
sample_news <- sample(news, length(news) * sample_pct)
sample_twitter <- sample(twitter, length(twitter) * sample_pct)
sample_all <- c(sample_blogs, sample_news, sample_twitter)
After basic cleaning (lowercasing, stripping punctuation and numbers), the most common words were counted across the combined sample.
clean_text <- function(x) {
x <- tolower(x)
x <- gsub("[^a-z' ]", " ", x)
x <- gsub("\\s+", " ", x)
trimws(x)
}
tokens <- unlist(stri_split_boundaries(clean_text(sample_all), type = "word"))
tokens <- tokens[grepl("^[a-z']+$", tokens) & nchar(tokens) > 0]
word_freq <- as.data.frame(table(tokens), stringsAsFactors = FALSE)
names(word_freq) <- c("word", "count")
word_freq <- word_freq[order(-word_freq$count), ]
kable(head(word_freq, 10), row.names = FALSE,
caption = "Table 2: 10 most frequent words in the sample")
| word | count |
|---|---|
| the | 47676 |
| to | 27748 |
| and | 24073 |
| a | 24015 |
| of | 20191 |
| i | 17314 |
| in | 16568 |
| for | 11047 |
| is | 10808 |
| that | 10641 |
top20 <- head(word_freq, 20)
ggplot(top20, aes(x = reorder(word, count), y = count)) +
geom_col(fill = "#4285F4") +
coord_flip() +
labs(title = "Top 20 Most Frequent Words",
x = NULL, y = "Frequency") +
theme_minimal(base_size = 12)
Finding: As expected, the most frequent words are short, common “function” words (like the, and, to, of). This is normal for any natural-language dataset — these words carry grammatical structure rather than meaning, and they will be very easy for a prediction model to get right. The interesting prediction challenge lies in the less frequent words.
A predictive keyboard doesn’t just look at word frequency — it looks at which words tend to follow each other. These pairs and triplets are called bigrams and trigrams, and they are the real foundation of the prediction algorithm.
get_ngrams <- function(tokens, n) {
ngrams <- vector("character", length(tokens) - n + 1)
for (i in 1:(length(tokens) - n + 1)) {
ngrams[i] <- paste(tokens[i:(i + n - 1)], collapse = " ")
}
ngrams
}
bigrams <- get_ngrams(tokens, 2)
trigrams <- get_ngrams(tokens, 3)
bigram_freq <- as.data.frame(table(bigrams), stringsAsFactors = FALSE)
trigram_freq <- as.data.frame(table(trigrams), stringsAsFactors = FALSE)
names(bigram_freq) <- c("bigram", "count")
names(trigram_freq) <- c("trigram", "count")
bigram_freq <- bigram_freq[order(-bigram_freq$count), ]
trigram_freq <- trigram_freq[order(-trigram_freq$count), ]
kable(head(bigram_freq, 10), row.names = FALSE, caption = "Table 3: Top 10 two-word sequences")
| bigram | count |
|---|---|
| of the | 4240 |
| in the | 4121 |
| for the | 2081 |
| to the | 2068 |
| on the | 1993 |
| to be | 1630 |
| at the | 1431 |
| and the | 1266 |
| in a | 1161 |
| with the | 1098 |
ggplot(head(bigram_freq, 15), aes(x = reorder(bigram, count), y = count)) +
geom_col(fill = "#34A853") +
coord_flip() +
labs(title = "Top 15 Most Frequent Word Pairs (Bigrams)",
x = NULL, y = "Frequency") +
theme_minimal(base_size = 12)
kable(head(trigram_freq, 10), row.names = FALSE, caption = "Table 4: Top 10 three-word sequences")
| trigram | count |
|---|---|
| one of the | 377 |
| a lot of | 321 |
| thanks for the | 249 |
| going to be | 194 |
| to be a | 190 |
| out of the | 157 |
| the end of | 153 |
| the u s | 151 |
| as well as | 148 |
| be able to | 147 |
Finding: Common bigrams and trigrams are dominated by everyday phrases (e.g. “of the”, “in the”, “one of the”). This confirms the data is well-suited to n-gram based prediction — the same handful of short phrases account for a disproportionate share of all word sequences, which is exactly the pattern a prediction model can exploit.
One practical question for building a fast, lightweight app: how many unique words are needed to cover most of what people actually type?
word_freq_sorted <- word_freq[order(-word_freq$count), ]
word_freq_sorted$cum_pct <- cumsum(word_freq_sorted$count) / sum(word_freq_sorted$count)
words_for_50 <- which(word_freq_sorted$cum_pct >= 0.5)[1]
words_for_90 <- which(word_freq_sorted$cum_pct >= 0.9)[1]
coverage_df <- data.frame(
`Coverage Target` = c("50% of all word instances", "90% of all word instances"),
`Unique Words Needed` = c(words_for_50, words_for_90),
check.names = FALSE
)
kable(coverage_df, caption = "Table 5: Vocabulary size needed for coverage")
| Coverage Target | Unique Words Needed |
|---|---|
| 50% of all word instances | 140 |
| 90% of all word instances | 6822 |
Finding: A relatively small “core vocabulary” covers half of everything written, and a modest dictionary (far smaller than the full set of unique words in the data) covers 90%. This is good news for app performance — the model doesn’t need to store every rare word to be useful; it can prioritize a manageable core dictionary and still predict well most of the time.
In plain terms: the app will work like this —
The final deliverable will be a simple, interactive web app (built with R’s Shiny framework) with:
The data has been successfully downloaded, loaded, and explored. It’s large, messy, and drawn from three very different writing styles, but the basic patterns — common words, common phrases, and a coverage “sweet spot” for vocabulary size — all point toward a workable, standard n-gram approach with backoff. The next phase of the project will build and tune this model, then wrap it in the Shiny app described above.