Describing Data with R

Pre-Study · Module 5 · Fri Sep 11

Welcome to the Module 5 pre-study. You’ve read the M05 Module — the “every paper has a Table 1” framing, the four levels of measurement, skimr and labelled for first looks and variable labels, gtsummary for report-ready tables, and the worked replication of Daly (2022). That was the textbook treatment. This pre-study is where you practice the moves yourself, in the browser.

M05 introduces plenty of new ground — skim(), variable labels, tbl_summary(), composite scales — but most of it you learn best by doing, so the bulk of this page is hands-on. The two short videos zero in on the two ideas that are hardest to get from text alone and worth watching before you practice: the three flavors of a cross-tab percentage, and building a composite scale from several items. Everything else — including your first tbl_summary() — you’ll pick up in the activities.

How this page is organized

A quick warm-up on the everyday summarize verbs, then two short videos, each on one idea that’s easy to misread from text alone and each followed by a focused activity. Then a culminating Final practice where you build a report-ready Table 1 from scratch.

  • Warm-up — count rows, summarize a number, then split that summary by group
  • Video 1 + Activities — the three percent flavors of a cross-tab, one activity each (row / column / cell)
  • Video 2 + Activity — building a composite scale, and the minimum-items rule
  • Final practice — assemble a stratified, customized Table 1 of the 2019 NSDUH adolescents

Every code activity uses a three-tab panel:

  • ✍️ Your Code — the starter with blanks for you to fill in
  • 💡 Hint — a nudge in the right direction
  • 👀 Spoiler — the full working code if you get stuck

Work through the ✍️ tab first. Run it, see what happens, debug if it errors. Only open 💡 or 👀 after you’ve tried it yourself — struggling a little is how this stuff sticks.

Plan to spend about 60 minutes. Don’t rush. The point is to leave able to build these outputs on your own.


The NSDUH dataset

The pre-study uses the same dataset as the M05 Module — the National Survey on Drug Use and Health (NSDUH) — but filtered to just the 2019 wave so activities run quickly in WebR. The dataset is already loaded into your sandbox as nsduh, with tidyverse, skimr, labelled, and gtsummary attached.

nsduh_2019 · 13,397 observations · 12 variables · NSDUH 2019 adolescent subsample · data/nsduh_2019.Rds

Adolescents aged 12–17 surveyed in the 2019 NSDUH wave, from data/nsduh_2019.Rds — depression, impairment, mental-health-care, and substance use disorder measures for each respondent. Because it carries both a depression indicator and a substance-use indicator on the same people, it supports the comorbidity cross-tabs you will build below. A note on the name. The dataset is nsduh_2019 — that is the file on disk, and that is what the M05 Module and the M06 lab call it. On this page your sandbox has already loaded it, trimmed it to the twelve variables listed below, and named the result simply nsduh. That shortening is purely for brevity: you type this object’s name in every activity on the page, and the shorter name keeps the code lines readable. It is the same 2019 data either way — just fewer columns and fewer characters. When you move into the Module and the lab, expect the full name, nsduh_2019.

  • year integer — Survey year
  • sex factor — Respondent’s sex as recorded by NSDUH
  • age integer — Age in completed years
  • raceeth factor — Race and ethnicity as classified by NSDUH’s recoded race variable
  • mde_pastyear factor — Whether the respondent experienced a major depressive episode in the past 12 months
  • mde_lifetime factor — Whether the respondent has ever experienced a major depressive episode, using NSDUH’s DSM-based classification
  • mh_sawprof factor — Whether the respondent saw or talked to a professional about depression in the past year
  • substance_disorder factor — Whether the respondent met criteria for a past-year alcohol or illicit-drug use disorder (abuse or dependence)
  • severity_chores numeric — Sheehan Disability Scale rating of how much past-year depression interfered with household chores, where 0 is no interference and 10 is very severe interference
  • severity_work numeric — Sheehan Disability Scale rating of interference with school or work, 0 to 10
  • severity_family numeric — Sheehan Disability Scale rating of interference with family relationships, 0 to 10
  • severity_social numeric — Sheehan Disability Scale rating of interference with social life, 0 to 10

Full codebook for nsduh_2019 — values, levels, missingness, and how the file was prepared.

Want to confirm the data is loaded? Run the chunk below to see its size — rows and columns.


Warm-up — everyday summaries

Before the videos, shake the dust off the three grouping-and-summary tools you met in M04 — count(), group_by(), and summarize() (only the first and last actually summarize; group_by() sets up the split they run within). Every table on this page is built from these, so a two-minute warm-up now makes the rest go smoothly. (Reference: R4DS, Ch. 3 — Data transformation.)

How many — overall and per group?

The quickest look at any dataset is a head count. count() tallies rows by group — hand it a column name and it returns one row per level with the count in a new n column.

Your task: write the whole pipeline. Start from nsduh, pipe it into count(), and tally the adolescents by sex.

Why it matters: before you describe anything, you need to know how many people you’re describing — and whether the groups you’re about to compare are even close to the same size. Every table later on this page rests on this count.

Three blanks, reading top to bottom:

  • The data frame to start from — the 2019 NSDUH subset, already loaded in your sandbox as nsduh.
  • The verb that tallies rows by group: count().
  • The column to tally by: sex (bare, no quotes).

To count every row with no grouping at all, you’d use summarize(n = n()) instead.

The n column is the count. In fact count(sex) is just shorthand for group_by(sex) |> summarize(n = n()) — same result, less typing.

What’s a typical value?

For a numeric variable, the two everyday summaries are the mean and the median.

Your task: in a single summarize(), compute three things for age — the row count, the mean, and the median. Name the new columns yourself: n, mean_age, median_age.

Why it matters: the mean and median answer the same question (“what’s typical?”) two different ways, and comparing them is a fast first clue about asymmetry or extreme values — if they drift apart, something is pulling the mean. Treat it as a prompt to plot the distribution, not a verdict: values that land close together do not guarantee symmetry. Carrying n alongside is the habit from M04 — and because age is complete in this file, that n() is also the number the mean and median rest on. On a variable with gaps you would report sum(!is.na(age)) separately.

Inside summarize(), each line is new_column_name = what_to_compute. You’re choosing the names on the left and one function on the right:

  • The count column — call it n; n() takes no arguments.
  • The mean column — call it mean_age.
  • The median column — call it median_age, and the function is named exactly what you’d guess: median().

(If age had missing values you’d add na.rm = TRUE inside mean() / median(). It’s fully observed here, so you don’t need it.)

One row, three numbers: how many adolescents, their average age, and their middle age.

Now split it by group

A single number for the whole sample is a start, but almost every question in this course is comparative — does this differ by group?

Your task: take the pipeline you just wrote and add one line upstream so the same three summaries are computed separately for each sex. The summarize() itself doesn’t change at all — that’s the point.

Why it matters: this is the whole engine of Table 1. One line turns “here’s the sample” into “here are the groups, side by side,” and every stratified table you build for the rest of the semester is this move.

Before you run it, predict the shape: how many rows come back now, and why?

Two blanks on the new line:

  • The verb that partitions the data into groups without changing any values: group_by().
  • The column to split on: sex.

Everything downstream then runs once per group — so you get a row for Female and a row for Male instead of one row overall.

That grouped summarize() — a count plus a statistic for each group — is the computational engine behind a stratified Table 1. tbl_summary() automates it across many numeric and categorical variables at once in the Final practice; building one by hand first is how you know what that function is doing under the hood.


Video 1 — One cross-tab, three questions

What to listen for:

  • A cross-tabulation of two yes/no variables lands every person in one of four boxes — the raw counts
  • A percentage needs a denominator, and one table hides three of them — so one cross-tab answers three different questions
  • Row percent (each row sums to 100%) → of those with X, how many also have Y? · Column percent → of those with Y, how many have X? · Cell percent → of everyone, how many have both?
  • The trap: quietly swapping the denominator — always name the group your percentage is a percentage of
  • In R it’s one argument: tbl_cross(…, percent = “row” / “column” / “cell”)

Practice the techniques:

In the activities below, we’ll cross past-year major depressive episode with past-year substance use disorder — a real comorbidity question — and build the table three ways, one percent flavor per activity. Each cell holds the same four counts; all that changes is the denominator.

Here is the table with just counts:

Past-Year Substance Use Disorder
Total
Negative Positive
Past-Year Major Depressive Episode


    Negative 10,460 392 10,852
    Positive 1,856 242 2,098
Total 12,316 634 12,950

Now, in the code chunks below, first drop_na() to keep only adolescents with both values recorded, then use tbl_cross() with the row, col, and percent arguments according to the instructions.

Video 1 puts a single call on screen — nsduh |> tbl_cross(row = …, col = …, percent = …) — because the argument that changes the answer is the only thing that slide is about.

Here you’ll write two lines ahead of it: a select() keeping the two variables, and a drop_na() dropping anyone missing either one. Those aren’t a different analysis — they are how the table in the video gets built. The video’s counts already reflect them: its N of 12,950 is the complete-case total, not all 13,397 adolescents in the file.

Writing them yourself is the point. The denominator of every percentage you are about to compute is decided by that drop_na() line, and a percentage whose denominator you didn’t choose on purpose is a percentage you can’t defend.

Keep two denominators in view

The file holds 13,397 adolescents. The cross-tabs below use the 12,950 with an observed value on both variables — drop_na() sets the other 447 aside.

So every percentage on this page is a share of that complete-case analytic subset, and every one of them is unweighted. Neither is a flaw; both are things you have to say out loud when you report a number. “X% of adolescents” is a claim about the country; “X% of adolescents in this complete-case subset” is a claim you can actually defend from these tables.

Activity 1.1 — Row percent: condition on the row

Start with the row percent — each row sums to 100%, which lets you answer: “of adolescents with past-year MDE, what share also had a substance use disorder?”

Your task: build the whole pipeline. Keep the two variables, drop the incomplete rows, and cross-tabulate them with row percents. The tbl_cross() call is empty — you supply all three of its arguments yourself.

Why it matters: this is the one activity where you write tbl_cross() from nothing. Activities 1.2 and 1.3 change exactly one thing about it, so getting the skeleton into your fingers now is what makes the next two feel like the idea they’re teaching rather than more typing.

Work through the blanks top to bottom:

  • select() — the two variables are past-year MDE and substance use disorder: mde_pastyear and substance_disorder.
  • ___() — the verb that drops rows with a missing value is drop_na() (from M04). With no arguments it drops a row missing any column — fine here, since select() already narrowed you to two.
  • The last ___() — the function is tbl_cross(), and it needs three arguments: row (the variable down the side), col (the variable across the top), and percent. For row percents — each row summing to 100%, so you read “of those with MDE…” — set percent = “row”.

Read across the MDE-positive row: of the 2,098 adolescents with past-year MDE, 12% also had a substance use disorder (and 88% did not). The denominator here is the row — adolescents with MDE.

Activity 1.2 — Column percent: condition on the column

Now flip the question: “of adolescents with a substance use disorder, what share had past-year MDE?”

Your task: write the tbl_cross() call again — all three arguments — so that each column sums to 100%. The select() and drop_na() lines are given; only the table call is yours.

Why it matters: watch what you type. row and col are identical to Activity 1.1 — the table doesn’t move, the counts don’t move. You change one string, and the answer to the question changes completely. That’s the whole lesson of this video, and typing it is how you feel it.

Same three arguments as Activity 1.1, and the first two take the same values:

  • row = the variable down the side — mde_pastyear.
  • col = the variable across the top — substance_disorder.
  • percent = the only thing that changes. Each column should now sum to 100%, so the string is "column".

Read down the substance-disorder-positive column: of the 634 adolescents with a substance use disorder, 38% had past-year MDE. It’s the same 242 adolescents as in Activity 1.1 — but now they’re a share of the 634 with a substance disorder, not the 2,098 with MDE. Same cell, different denominator, very different percent (12% → 38%).

Activity 1.3 — Cell percent: share of everyone

Finally, divide every cell by the complete-case total — the adolescents with an observed value for both variables. Now the both-positive cell answers “of those adolescents, what share had both conditions?” — the unweighted joint proportion in this analytic subset.

Your task: write the tbl_cross() call once more, with the denominator set to the whole table.

Why it matters: this is the third and last denominator, and the three answers you’ll have produced — from the same four counts — are all correct and all mean different things. Reporting the wrong one isn’t a rounding error; it’s a different claim about the world. (A population prevalence for a paper’s abstract would be a fourth thing again: it would apply NSDUH’s survey weights and account for the complex sample design. Everything on this page is unweighted.)

Identical to Activity 1.2 except the last string:

  • row = mde_pastyear, col = substance_disorder — unchanged again.
  • percent = every cell is now a slice of the grand total, so the string is "cell".

Now every cell is a share of the 12,950 adolescents with both variables observed: 1.9% were positive for both, 81% for neither, 14% for MDE only, and 3.0% for a substance disorder only. Those four cells sum to 100% (up to rounding).

You’ve now built all three tables. Same four counts, three denominators, three genuinely different answers — 12%, 38%, 1.9%. The one you’d report depends entirely on the question you’re asking. Use the three tables to answer the Quick check below.

Quick check

Answer each question using the three tables you just built — you’ll see green (correct) or pink (incorrect) feedback as you click. The goal is to feel what each denominator is doing.

1. In your row-percent table (Activity 1.1), the MDE-positive row shows 12% in the substance-disorder-positive column. What does that 12% mean?

2. In your column-percent table (Activity 1.2), the substance-disorder-positive column shows 38% in the MDE-positive row. That 38% is the share of…

3. You want the comorbidity prevalence — the share of all adolescents who had both conditions. Which table gives it, and what is the value?

4. The same 242 adolescents sit in the both-positive cell, yet the row percent calls them 12% and the column percent calls them 38%. Why the difference?

5. In your cell-percent table (Activity 1.3), the MDE-negative / substance-disorder-negative cell reads 81%. This tells you…

6. A colleague asks: “Among adolescents who have a substance use disorder, how common is a major depressive episode?” Which percent answers it — and what’s the number?

7. A classmate writes: “1.9% of depressed adolescents also had a substance use disorder.” Using your tables, what’s the error?


Video 2 — One score from many items

What to listen for:

  • Several items that measure one construct (the four 0–10 severity items → overall impairment) get combined into one number by averaging across them
  • The R move: rowMeans(pick(starts_with(“severity_”))) — one mean per person, across the item columns
  • The catch: plain rowMeans() returns NA for anyone with even one missing item — one skipped question drops the whole person
  • The fix: a minimum-items rule — form the scale if the person answered at least k items, via case_when() + rowSums(!is.na(…)) (which counts the answered items)
  • The threshold k comes from the measure’s manual, your grant application, or your pre-registration plan — you decide it, deliberately

Activity 2.1 — Build an illustrative mean across four domains

A composite score turns several items that measure the same underlying thing into a single number. Here you’ll average the four interference ratings (chores, work, family, social life) into one score per adolescent — a move you will use for real, on a different measure, in this week’s lab.

This score is ours, not NSDUH’s

What you build below is an instructor-defined summary, created so you can practice row-wise calculation. It is not NSDUH’s official overall-impairment score: NSDUH classifies role impairment from the highest rating across the four domains, not their mean. The M05 Module works through that contrast and verifies it against the data.

Your task: the severity items were asked only of adolescents with a past-year MDE, so first filter() to them; then create a new severity_scale column that is each adolescent’s mean across the four severity_ items.

Why it matters: averaging the items is what lets you treat “impairment” as one measured quantity instead of four separate questions. And notice the direction — you want one score per person (a mean across that person’s row), not one number for the whole sample (a mean down a column). That row-vs-column distinction is the thing to get right.

Video 2 puts the mutate() on screen by itself, because averaging across the row is what that slide is teaching. Your first line here is a filter() the video only mentions in passing — “the NSDUH severity items ask, of adolescents who had a depressive episode…”

That “of adolescents who had a depressive episode” is the filter. The items were asked only of respondents who screened positive for past-year MDE, so every other row is blank by design. Leave the filter off and the code still runs — you just get a column of NA for adolescents without a past year MDE.

We’ll explicitly drop the adolescents without an MDE in the activities below.

Three blanks, top to bottom:

  • The filter value — the severity items exist only for adolescents whose mde_pastyear is "Positive".
  • The averaging function — the row-wise cousin of colMeans(), one mean per person: rowMeans().
  • The starts_with() prefix — all four items begin with severity_.

Each adolescent now has a single severity_scale — the average of their four item scores.

But those first six rows all answered every item, so they hide the problem. Almost everyone here answered all four; the handful who didn’t are scattered through the file, and head() will not show them to you. Count the coverage directly instead:

Now pull one adolescent from each coverage level so you can see what plain rowMeans() does to them:

There it is. Only the row with all four items gets a score; every row with even one blank comes back NA, because plain rowMeans() refuses to average when a value is missing. That is the problem the next activity fixes — and notice you had to go looking for it. A default that quietly drops people is exactly the kind of thing head() will never show you.

What if the columns didn’t share a prefix? starts_with(“severity_”) is a convenience that works because all four items begin with severity_. If they’d been named something unrelated — say chores, mood_work, fam3, and social_life — there’d be no pattern to match, so you’d list each column by name inside pick() instead. You can do exactly that here too, and it gives the identical scale:

Either way, pick() hands rowMeans() the same four columns — the select helper just saves typing (and typos) when the names share a pattern.

Reminder — tidyselect select helpers

Inside pick() — and select(), across(), and friends — you can choose columns by pattern instead of typing every name:

  • starts_with(“severity_”) — names that begin with severity_
  • ends_with(“_score”) — names that end with _score
  • contains(“age”) — names that contain age anywhere
  • matches(“^sev”) — names matching a regular expression (this is a more advanced pattern-matching tool)
  • num_range(“q”, 1:10) — a numbered range: the columns q1, q2, …, q10 (handy for numbered survey items like q1q10)

These come from the tidyselect package and work anywhere dplyr expects a set of columns. When your variables follow a naming convention, they collapse a whole list of column names into one short, self-documenting expression.

Activity 2.2 — The minimum-items rule

In Activity 2.1 you saw the catch: plain rowMeans() returns NA for anyone missing even one item, so a single skipped question throws away the whole person. Real research is more forgiving — it forms the scale as long as a person answered at least k of the items.

Your task: build the scale with a minimum-items rule of k = 3. Two pieces do the work, and both are yours to complete: the threshold the person must clear, and the na.rm setting that tells rowMeans() to average whatever items they did answer. (The counting line, rowSums(!is.na(…)), is given — it tallies how many of the four items each person answered.)

In Video 2 the count is written inside the case_when(), so rowSums(!is.na(…)) appears twice — once in each arm. Here you’ll do the identical thing with one change: compute the count once, into its own column called n_answered, then test that in both arms.

The logic is exactly the same. Two things get better:

  • The rule reads as a rule. n_answered >= 3 says what it means at a glance; a nested rowSums(!is.na(pick(starts_with(…)))) makes you decode it twice.
  • The count becomes something you can look at. This is the one that matters. Inline, the number of answered items is computed, used, and thrown away — invisible. As a column, it’s right there in the output, so you can see which respondents got kept and which got dropped, and why.

You’ll use that second advantage immediately: n_answered is what makes the rule’s effect visible in the table below.

Why it matters: this is the honest middle path. Requiring all items discards good data over one blank; averaging no matter how few items fabricates a score from too little. Some instruments allow a score once a prespecified minimum is answered; others require every item, use a sum, or score differently altogether. Follow the measure’s documentation — and where no established rule exists, specify and justify k before you look at the results, not after.

Three blanks:

  • The two threshold blanks are the same number: keep anyone who answered at least 3 of the 4 items (75% of the scale).
  • The na.rm blank is TRUE — that’s what makes rowMeans() average the answered items instead of returning NA the moment one is missing.

Compare this against Activity 2.1’s table, row for row. The 4-item adolescent scores the same either way. The 3-item adolescent now gets a score — the mean of the three they answered, thanks to na.rm = TRUE — where plain rowMeans() gave them NA. The 2-, 1-, and 0-item adolescents still get NA, because the rule says they answered too little to summarize. The n_answered column makes the whole decision visible on the page.

That threshold of 3 is a choice, not a discovery — and one you would record before looking at results.

Reading the case_when(), piece by piece

You met each of these pieces back in M04is.na() and !is.na(), na.rm = TRUE, and typed NAs in a case_when() — but they’re new here in combination. Read it from the inside out:

  1. pick(starts_with(“severity_”)) — grabs the four severity columns as a little 4-column table, one row per adolescent (from Activity 2.1).
  2. is.na() — asks of every cell, “is this value missing?” It returns a grid of TRUE/FALSE the same shape — TRUE wherever an item is blank.
  3. !is.na() — the same !is.na() you used in M04 to keep non-missing rows. The ! means “not,” so it flips every value: now TRUE marks the items the person did answer. Read it aloud as “is not missing.” (The double-negative trips everyone up at first — say the words and it clicks.)
  4. rowSums() — adds across each row. R counts each TRUE as 1 and each FALSE as 0, so summing the “answered?” grid counts how many items the person answered — that becomes n_answered, a number from 0 to 4.
  5. case_when() — checks its rules top to bottom and hands back the value from the first rule that is TRUE for that person (a tidy stack of if/else):
    • answered ≥ 3 → compute their rowMeans(), with na.rm = TRUE telling it to skip the one missing item and average the rest.
    • answered < 3 → return missing — too few items to trust a score.

One subtle bit — NA vs. NA_real_. This is the same type-matching rule you met in M04, where a case_when() fallback used NA_character_. NA is R’s marker for a missing value, and bare NA is technically the logical (true/false) flavor — but every arm of a case_when() has to return the same type, and the other arm here (rowMeans()) returns a number. So we write NA_real_, the numeric (double) flavor of NA — exactly as M04 used NA_character_, the text flavor, when its other arms returned text. Rule of thumb: match the NA to the arm’s type — NA_real_ for a missing number, NA_character_ for missing text.

Quick check

Answer each question — you’ll see green (correct) or pink (incorrect) feedback as you click.

1. An adolescent answered 3 of the 4 severity items. What does plain rowMeans(pick(starts_with(“severity_”))) (no na.rm) return for their scale?

2. In the minimum-items rule, what is rowSums(!is.na(pick(starts_with(“severity_”)))) computing for each adolescent?


Final practice — build a Table 1

Why this matters for behavioral scientists

Every quantitative paper you’ll read or write opens with a Table 1 — the participant-characteristics table. It’s the first thing a reviewer checks, and building one cleanly is a skill you’ll use in every project this semester and beyond. In the next four steps you’ll build one from scratch on the 2019 NSDUH adolescents. By the end you’ll be able to:

  1. Look at a new dataset with glimpse() before summarizing it (and skim() once you’re in RStudio)
  2. Summarize it with a stratified tbl_summary()
  3. Polish it — labels, custom statistics, and a title — into something report-ready

What you’re building

Before you start typing, it helps to know where you’re headed. All four Parts build the same table — the one below — and each Part adds one capability to it. Here is the finished product, described in words:

Rows Race/ethnicity, age, and past-year MDE — three participant characteristics
Columns Female and Male, side by side (the table is stratified by sex)
Statistics Count and percent for the two categorical rows; mean (SD) for age
Labels “Race/ethnicity,” not raceeth — readable row names
Missing An “Unknown” row wherever a variable has missing values
Title A descriptive title above the table

Those same three variables carry through every Part; what changes is what tbl_summary() does with them:

Part What it adds What you see change
A (nothing yet) — look at the data first The columns, their types, and a preview of values
B tbl_summary() with no arguments One summary column for the whole sample
C by = sex That one column splits into Female and Male
D labels, type, statistic, missing Readable row names, and age becomes one mean (SD) row instead of six count rows

So if a Part’s output surprises you, compare it against the row above: exactly one thing should have changed.

Part A — Take a first look

Your first move on any new dataset is to see what’s in it before you summarize a thing — the columns, their types, and a peek at the values. In the browser we use glimpse() (from dplyr): one compact line per column.

Your task: pipe the nsduh data frame into glimpse().

Why it matters: summarizing data you haven’t looked at is how silent mistakes slip in — a column that loaded as text when you expected a number, a variable that isn’t actually there. Ten seconds with glimpse() catches it before it becomes a wrong Table 1.

Two blanks:

  • The data frame — the 2019 NSDUH subset, already loaded in your sandbox as nsduh.
  • The function — the compact one-line-per-column view you met in M03/M04, starting with “g”: glimpse().

In RStudio, reach for skim()

The richer first look is skim() from the skimr package — it adds missingness counts, the most common factor levels, and (for numeric variables) means, quartiles, and tiny inline histograms, laid out as a factor table and a numeric table. You’ll use it in the M05 Module examples and in your lab, which both run in RStudio. That two-table display doesn’t render in this WebR browser sandbox (you’d get one half or the other, never both), so in the pre-study we stick with glimpse().

Part B — Your first tbl_summary

Now the headline function of M05. tbl_summary() (from gtsummary) takes a tidy data frame and returns a report-ready summary — counts and percentages for categorical variables, median and IQR for numeric — in a single call.

Your task: summarize three variables — raceeth, age, and mde_pastyear. select() them first (so the table doesn’t try to describe all twelve columns), then pipe into tbl_summary() with no arguments — the defaults do the rest. These same three rows carry through Parts C and D; each Part adds one capability, not new variables.

Why it matters: this one function replaces the by-hand counting and percentages you did earlier on this page. And selecting first is the habit that keeps a Table 1 to the variables you actually mean to report, instead of every column that happens to be in the file.

Four blanks:

  • The three columns to summarize — raceeth, age, and mde_pastyear (bare, no quotes).
  • The function — short, starts with tbl_, and takes no arguments for this first version: tbl_summary().

Three variables summarized: race/ethnicity and past-year MDE (a count and column percentage for each level) and age. Notice that age shows a count for each year (12–17) rather than a mean or median — with only a handful of distinct values, tbl_summary() guesses it’s categorical. (You’ll tell it to treat age as continuous in Part D.) Even so, the table is already report-ready — count + percent in parentheses, neat formatting, no extra typing. This is the win gtsummary delivers.

Part C — Stratify with by = sex

Time for the move that turns a one-variable summary into a real Table 1: stratify. Adding by = sex splits the table into two columns — one for female adolescents, one for male — with every statistic computed within each group.

Your task: add the by argument to tbl_summary() so the table is broken out by sex. (The select() gains exactly one column — sex itself — because you can’t split by a column you didn’t keep. The three variables being summarized are the same three as Part B, which is what lets you set the two tables side by side and see precisely what by changed.)

Why it matters: many Table 1s describe the overall sample; others place meaningful groups side by side — treatment vs. control, exposed vs. not. The by argument is what creates the stratified version, and it’s the same move whether you split by sex, study arm, or survey wave.

by takes a column name, unquoted. You want to split the table by sex — the column named sex.

Now you have a stratified Table 1. The headers show each sex group’s size at the top; each variable below is broken down by group. That is a common structure for a stratified Table 1 — and all it took was three letters: by = sex.

Part D — Polish into a report-ready Table 1

Last move — turn Part C’s table into a polished Table 1 with four customizations. The variable labels are filled in for you; the other three are yours to complete.

  1. Variable labelsset_variable_labels() so the table shows “Race/ethnicity” instead of raceeth (given)
  2. Treat age as continuous — by default tbl_summary() guesses each variable’s type from how many distinct values it has, and age — just six of them (12–17) — gets guessed categorical, so it prints a row for each year. Setting type tells it to summarize age as a number instead.
  3. Custom statistic — override the default median (IQR) with mean (SD) for continuous variables (this only takes effect once age is continuous — otherwise there are no continuous variables to reformat)
  4. Decide what happens to missing valuesmissing controls whether the table carries an “Unknown” row. It is tempting to set "no" because the table looks tidier, but suppressing the row does not remove the missing data; it removes the reader’s ability to see it, while every percentage quietly switches to a denominator of the people who answered. Write the default "ifany" explicitly, so the choice is visible in your code rather than assumed.

Your task: fill the four blanks — the type keyword that makes age continuous, the two statistic keywords (mean, then SD), and the missing setting.

Why it matters: these arguments are the difference between a rough draft and a table you’d put in a manuscript. Each one is a decision a reader will notice — what counts as a number versus a category, which statistic you report, and how you handle missing values. The last one is the easiest to get wrong quietly, which is why the M05 Module spends a section on it.

Four blanks:

  • type = list(age ~ "___") — the keyword that treats a variable as a number rather than categories: "continuous".
  • statistic "{___} ({___})" — the gtsummary keywords for the mean and the standard deviation: {mean}, then {sd}.
  • missing = "___" — leave it at the default "ifany", which keeps an “Unknown” row for any variable that has missing values.

There it is — a report-ready Table 1. The rows read in plain English (labels), age now shows a single mean (SD) row instead of a count for every year — because we set its type to continuous, then set statistic to mean (SD) — and the table carries a descriptive title. (A title alone doesn’t make a table APA-formatted; journals differ, and you’d check the target’s requirements before submitting.) Note the title says unweighted and analytic sample: these percentages describe the respondents in this file, not the U.S. adolescent population. About ten lines of code, and you just built the kind of table that opens most quantitative papers in the field.

See the Module for the full argument list

You’ve now used four of tbl_summary()’s arguments — by, type, statistic, and missing. It has a few more worth knowing — label, digits, and percent (row / column / cell, just like tbl_cross()). The M05 Module has a reference box that lists its key arguments in one place — a handy page to bookmark for your own projects.


Three things to carry into lecture

  1. One cross-tab answers three different questions — the denominator decides which. The row percent conditions on the row (of those with X…), the column percent conditions on the column (of those with Y…), and the cell percent divides by everyone (of all adolescents…). Same four counts, three flavors — always name the group your percentage is a percentage of.
  2. A composite scale is the row-mean of several items — with a minimum-items rule. rowMeans(pick(…)) averages one construct across its items into a single score per person; a case_when() on rowSums(!is.na(…)) keeps anyone who answered at least k of the n items and returns NA for the rest. You choose k deliberately — it comes from the measure’s manual or your analysis plan, not a default.
  3. tbl_summary() turns a data frame into a report-ready Table 1. Point it at a tidy data frame, add by = sex to stratify, then polish with labels, a continuous type, a custom statistic, and a title — about ten lines of code for the table that opens every quantitative paper in the field.