Describing Data: Tables and Visualizations
You have clean data. Now you need to describe it. What’s in the dataset? What patterns are visible? This chapter teaches you to build frequency tables and publication-ready charts using the same Twitch dataset you wrangled in Chapter 4.
Describing data is not just a technical step. It is communication. Every table and chart in this chapter answers a question that a reader would have about your data.
Load Your Clean Data
library(tidyverse)
library(v2v)
analysis <- readRDS("data/twitch_analysis.RDS")
streams <- twitch_streams() # still useful: some questions are about streams, not messagesTwo tables, two units of analysis. analysis is one row per chat message, 35,267 of them. streams is one row per stream snapshot, 32,276 of them. Which one you reach for depends on what the question is about. A question about messages goes to analysis. A question about what was being streamed goes to streams.
Frequency Tables
A frequency table answers the simplest question: How many of each category do I have?
One-Variable Frequency Table
streams %>% count(game, sort = TRUE)# A tibble: 60 × 2
game n
<chr> <int>
1 Art 5298
2 Fortnite 3682
3 Just Chatting 2474
4 Hearthstone 2157
5 Pokemon: Let's Go, Pikachu!/Eevee! 1967
6 League of Legends 1952
7 Heroes of the Storm 1473
8 Counter-Strike: Global Offensive 1346
9 Rocket League 1321
10 World of Warcraft 920
# ℹ 50 more rows
Raw counts are useful. Percentages are usually more useful, because they tell a reader what share of the whole each category holds:
game_counts <- streams %>%
count(game, sort = TRUE) %>% # count and sort by frequency
mutate(
percent = n / sum(n) * 100, # add percentage column
percent = round(percent, 1) # round to one decimal
)
game_countsTwo things in that table are worth saying out loud. First, Art leads, which is not what most people would predict about Twitch. That is Bob Ross, streaming continuously through the collection week. Second, sixty categories is a lot, and most of them are tiny. A handful of games account for most of the snapshots and a long tail of fifty more accounts for the rest. That shape is going to constrain every chart in this chapter.
The message-level equivalent is the gaming split you built in Chapter 4:
analysis %>%
count(channel_type) %>%
mutate(percent = round(n / sum(n) * 100, 1))That table reports 3,457 messages from non-gaming channels, 31,309 from gaming channels, and 501 that could not be classified because their channels never recorded a category. Report the NA row. Dropping it silently is how a reader ends up with the wrong denominator.
Two-Variable Cross-Tabulation
A cross-tabulation shows how two categorical variables relate to each other. This is the foundation for the chi-square test in Chapter 6.
cross_tab <- analysis %>%
filter(!is.na(channel_type), !is.na(viewer_band)) %>% # drop unclassifiable rows
count(viewer_band, channel_type) %>% # count each combination
pivot_wider( # reshape long to wide
names_from = channel_type,
values_from = n,
values_fill = 0 # fill missing combos with 0
)
cross_tabpivot_wider() Do?
pivot_wider() reshapes your data from “long” format (one row per combination) to “wide” format (one row per viewer band, with a column for each channel type). This makes the table easier to read: it looks like the cross-tabulations you’ve seen in textbooks.
Adding Proportions
Raw counts are useful, but proportions tell a richer story:
analysis %>%
filter(!is.na(channel_type), !is.na(viewer_band)) %>%
count(viewer_band, channel_type) %>%
group_by(viewer_band) %>%
mutate(
prop = n / sum(n), # proportion within each viewer band
prop = round(prop, 3) # round to 3 decimals
) %>%
ungroup()Now you can see not just how many messages in each audience-size band came from gaming channels, but what percentage. If small channels are 90% gaming and large channels are 60% gaming, that is a meaningful difference, even though the large channels contribute far more messages in total.
The Grammar of Graphics (ggplot2)
R’s ggplot2 package builds charts in layers. Instead of clicking through menus like in Excel, you describe what you want:
ggplot(data, aes(x = ..., y = ..., fill = ...)) +
geom_*() +
labs() +
theme()
Each piece:
| Component | What It Does | Example |
|---|---|---|
ggplot() |
Sets up the canvas and data | ggplot(analysis, aes(x = channel_type)) |
aes() |
Maps variables to visual properties | aes(x = viewer_band, fill = channel_type) |
geom_*() |
Adds the visual layer (bars, points, etc.) | geom_col(), geom_boxplot() |
labs() |
Adds titles and labels | labs(title = "Chat Volume by Hour") |
theme() |
Controls appearance | theme_minimal(), v2v::theme_v2v() |
Think of it like building a sandwich: you start with the bread (the canvas), add the main ingredient (the geometry), then add toppings (labels, colors, themes).
Bar Chart: Game Category Distribution
Let’s build a chart in stages, from basic to publication-ready. The question: which categories dominate the collection?
Sixty categories will not fit on a readable chart, so the first decision is to show the top ten and say so in the subtitle. That is a choice you are making about the figure, and an undisclosed choice is a quiet distortion.
top_games <- game_counts %>% slice_max(n, n = 10)Stage 1: The Bare Minimum
ggplot(top_games, aes(x = game, y = n)) +
geom_col()This works, but it’s ugly, and the category names are long enough to overlap into mush. Let’s improve it step by step.
Stage 2: Sort the Bars
Unsorted bars make it harder to compare. Use fct_reorder() to sort by frequency:
ggplot(top_games, aes(x = fct_reorder(game, n), y = n)) +
geom_col() +
coord_flip() # horizontal bars are easier to read for long category labelsStage 3: Add Color and Labels
ggplot(top_games, aes(x = fct_reorder(game, n), y = n, fill = game)) +
geom_col(show.legend = FALSE) + # hide legend (redundant with axis)
coord_flip() +
scale_fill_brewer(palette = "Set2") + # colorblind-friendly palette
labs(
title = "Most-Streamed Categories",
subtitle = "Top 10 of 60 categories, Twitch stream snapshots (n = 32,276)",
x = NULL, # remove redundant axis label
y = "Stream Snapshots"
) +
theme_minimal(base_size = 12)Stage 4: Publication-Ready
ggplot(top_games, aes(x = fct_reorder(game, n), y = n, fill = game)) +
geom_col(show.legend = FALSE, width = 0.7) +
geom_text(aes(label = paste0(n, " (", percent, "%)")), # add count labels
hjust = -0.1, size = 3.5) +
coord_flip() +
v2v::scale_fill_v2v() +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) + # room for labels
labs(
title = "Most-Streamed Categories",
subtitle = "Top 10 of 60 categories, Twitch stream snapshots (n = 32,276)",
x = NULL,
y = "Stream Snapshots",
caption = "Source: v2v package, November 2018 Twitch collection"
) +
v2v::theme_v2v() +
theme(
plot.title = element_text(face = "bold"),
panel.grid.major.y = element_blank() # remove horizontal grid lines
)That’s the difference between a homework chart and a professional one. Every element serves a purpose: the sort order aids comparison, the labels provide exact values, and the clean theme reduces visual noise.
v2v::theme_v2v() and v2v::scale_fill_v2v() apply the course’s house style in one call each, so that every figure in your White Paper looks like a member of one set rather than a pile of unrelated charts. scale_fill_brewer(palette = "Set2") is a perfectly good alternative when you’re working outside this course.
Bar Chart: Counts Across Time
A second bar chart, and a different question: across the hours of the day, when is chat busiest? This is what the timestamp conversion in Chapter 4 was for. You cannot read an hour off a run of milliseconds, but you can read it off a <dttm>:
chat_by_hour <- analysis %>%
mutate(hour = hour(timestamp)) %>%
count(hour, name = "messages")
ggplot(chat_by_hour, aes(x = hour, y = messages)) +
geom_col(fill = "#2f7d8a") +
labs(
title = "Chat Volume by Hour of Day",
x = "Hour of day (UTC)",
y = "Messages in sample",
caption = "Source: v2v package, November 2018 Twitch collection"
) +
v2v::theme_v2v()The figure shows a clear daily pulse. Chat peaks at 13:00 UTC with 2,201 messages and bottoms out at 03:00 with 833, a swing of well over two to one between the loudest hour and the quietest. The shape is not mysterious: Twitch’s audience in 2018 was concentrated in the Americas and Europe, and UTC midday is the waking, screen-facing part of the day across that span. The point worth taking is what the bar chart does. It takes twenty-four numbers and makes a rhythm visible at a glance, which is exactly the job a bar chart is for.
geom_col() or geom_bar()?
geom_col() draws a bar whose height is a value you already computed, which is what count() produced above. geom_bar() does the counting itself from raw rows. Same picture, different division of labor. If you already have a column of counts, use geom_col().
Stacked Proportional Bar Chart
A stacked proportional chart shows how the composition of one variable changes across categories of another. This is perfect for the audience-size by channel-type relationship you’ll test in Chapter 6:
analysis %>%
filter(!is.na(channel_type), !is.na(viewer_band)) %>%
ggplot(aes(x = viewer_band, fill = channel_type)) +
geom_bar(position = "fill") + # "fill" makes it proportional
coord_flip() +
v2v::scale_fill_v2v() +
scale_y_continuous(labels = scales::percent) + # show as percentages
labs(
title = "Gaming and Non-Gaming Channels by Audience Size",
subtitle = "Twitch chat sample, November 2018",
x = NULL,
y = "Proportion of messages",
fill = "Channel type",
caption = "Source: v2v package, November 2018 Twitch collection"
) +
v2v::theme_v2v() +
theme(plot.title = element_text(face = "bold"))Use this chart when you want to compare the composition (not the count) across groups. It answers: “Does the mix of gaming and non-gaming channels differ across audience-size bands?” If all bars look roughly the same, there’s probably no relationship. If they differ visibly, that’s worth testing statistically (Chapter 6).
Boxplot: Comparing a Numeric Variable Across Groups
Boxplots show the distribution of a continuous variable across categories. They reveal medians, spread, and outliers at a glance. The study’s central question is a question of exactly this shape: do chat messages run longer on non-gaming channels than on gaming ones?
analysis %>%
filter(!is.na(channel_type)) %>%
mutate(length_shown = pmin(message_length, 120)) %>% # cap the view, see below
ggplot(aes(x = channel_type, y = length_shown, fill = channel_type)) +
geom_boxplot(show.legend = FALSE, alpha = 0.7) +
coord_flip() +
v2v::scale_fill_v2v() +
labs(
title = "Message Length by Channel Type",
subtitle = "Lengths capped at 120 characters for display",
x = NULL,
y = "Message length (characters, capped at 120)",
caption = "Source: v2v package, November 2018 Twitch collection"
) +
v2v::theme_v2v() +
theme(plot.title = element_text(face = "bold"))Reading a boxplot:
- The thick line in the middle is the median (50th percentile)
- The box spans the 25th to 75th percentile (the middle 50% of values)
- The whiskers extend to 1.5 times the box width, and anything beyond is an outlier (shown as dots)
pmin(message_length, 120) pushes every message longer than 120 characters down to 120, so the long tail does not stretch the axis and squash the part of the distribution where almost all the data lives. Twitch’s real limit is 500 characters, so that top value is a genuine pile-up of long messages compressed into one place. Capping is legitimate. Capping without saying so is not, which is why the axis label says “capped at 120.”
The Summary Table Behind the Picture
A figure sets up a comparison. A summary table gives the numbers behind it:
analysis %>%
filter(!is.na(is_gaming)) %>%
group_by(is_gaming) %>%
summarise(
n = n(),
mean = mean(message_length, na.rm = TRUE),
median = median(message_length, na.rm = TRUE),
sd = sd(message_length, na.rm = TRUE),
.groups = "drop"
) %>%
v2v::pretty_table()| is_gaming | n | mean | median | sd |
|---|---|---|---|---|
| FALSE | 3,457 | 33.70 | 16 | 60.68 |
| TRUE | 31,309 | 28.49 | 17 | 38.47 |
Read the mean column and a story jumps out. Non-gaming messages, the FALSE row, average 33.70 characters against 28.49 for gaming messages. Non-gaming chat looks meaningfully longer.
Now read the median column, the length of the exactly-middle message in each group, and the story collapses: 16 characters for non-gaming, 17 for gaming, all but identical, and pointing the other way. The typical message is the same length in both kinds of channel.
Both things are true, and the distribution explains how. The mean is sensitive to a long tail in a way the median is not. A relatively small number of very long messages pulls an average upward while leaving the middle value untouched. The non-gaming group has the heavier tail, which its much larger standard deviation, 60.68 against 38.47, records directly. So the gap in means is real arithmetic, but it is the work of the tail, not of the typical message.
Lean only on the mean here and you would tell your reader that non-gaming chat is longer. The median, sitting right next to it, says the everyday message is the same in both. Report the mean, the median, the standard deviation, and the group sizes together. A mean is one number standing in for thousands, and which thousands it stands in for is something only the spread can show.
Writing Your Interpretation
Numbers and charts don’t speak for themselves. Your assignment requires a 2 to 3 paragraph interpretation that cites specific numbers. Here’s how to structure it:
Paragraph 1: What the data contains
“The sample includes 35,267 chat messages drawn from 50 Twitch channels over five and a half days in November 2018, alongside 32,276 stream snapshots. Gaming channels account for 31,309 messages and non-gaming channels for 3,457, with a further 501 messages coming from channels whose category was never recorded and which are therefore excluded from the comparison.”
Paragraph 2: Key patterns
“Chat volume follows a clear daily rhythm, peaking at 13:00 UTC with 2,201 messages and falling to 833 at 03:00. Message length differs between the two kinds of channel, but not in a simple way: non-gaming messages average 33.70 characters against 28.49 for gaming messages, while the medians are nearly identical at 16 and 17. The gap in means is produced by a heavier tail of long messages on non-gaming channels, visible in the much larger standard deviation (60.68 versus 38.47).”
Paragraph 3: Implications
“These descriptive patterns warrant further investigation. Whether the five-character gap between the two group means reflects a real difference between gaming and non-gaming channels, or only the ordinary wobble any two samples produce, is a question the inferencing phase will test directly.”
“Non-gaming messages are longer” is weak. “Non-gaming messages average 33.70 characters against 28.49 for gaming messages, though the medians are 16 and 17” is strong. Specific numbers make your writing credible and verifiable, and in this case they also keep you from overstating the finding.
Try It Yourself
These exercises map directly to the Describing Data [R] assignment:
Build a frequency table for
gamewith both counts and percentages. Format it so a reader can immediately see which category is most and least common, and decide how many rows to show.Create a cross-tabulation of
viewer_bandbychannel_typewith both counts and proportions. Which band has the most lopsided mix?Build a horizontal bar chart that is publication-ready: sorted bars, colorblind-safe palette, count labels, full titles and captions.
Create a stacked proportional chart showing the mix of gaming and non-gaming messages within each audience-size band.
Choose one additional visualization (histogram, boxplot, line chart, or another type) and write 2 to 3 sentences justifying why you chose it and what it reveals. A histogram of
message_lengthis a strong candidate, because it shows the right skew that the mean alone hides.Write your interpretation: 2 to 3 paragraphs that cite specific numbers from your tables and describe the patterns you see.
For your White Paper, you’ll create a frequency table and bar chart using your own variables, not game category and channel type, but whatever you coded in your content analysis. The ggplot2 syntax is identical. Only the variable names change. The interpretation paragraphs become part of your Results section.