The whole table in one line
Session 4 practical exercises
Before you start
Continue working on the same script and make sure gtsummary is loaded along with the rest of libraries in p_load().
Open any surveillance report, any epidemiological paper, any outbreak investigation. Somewhere near the beginning there is a table describing the population under study: age, sex, the main exposures, the main outcomes, sometimes split into two columns to compare groups. It is so predictable that everyone simply calls it Table 1.
You could build one right now with what you learned in the previous exercises. Numeric variables need a median and a range, categorical variables need counts and percentages, and each of them needs its own summarise() before you stack everything together. It would work. It would also take you the rest of the afternoon.
Which is why someone wrote a package for it.
Part 7 · Table 1 with a line of code
Sometimes it’s just too much
Today we will use the single most important function of gtsummary: tbl_summary(). As always, start by looking at the function documentation to have a general idea about its arguments and potential. But let’s start step by step:
Action — Pass the whole dataset to tbl_summary() and look at what comes back. It’s a tidy-friendly function, so you know what to do. You don’t need to assign any object yet
The resulting table is large enough that you need to scroll down the viewer pane to see all table rows. If you want a better glance of the table, you can always click on the ‘Show in new window’ button next to the broom. Since tbl_summary() produces an HTML object, it will open in your browser
Two things are true about that output at the same time.
It is impressive: without telling it anything, the function went through every column, worked out whether it was numeric or categorical, chose a median with its interquartile range for the first kind and counts with percentages for the second, counted the missing values separately, and formatted the whole thing.
It is also useless. Dates, identifiers, variables nobody asked about and are not relevant for the report, all summarised with equal enthusiasm. Nobody can read it and nobody would put it in a report.
The problem is not the function. The problem is that you gave it everything you had.
Making better choices
tbl_summary() summarises every column it receives. That single sentence tells you where the real work is: not in the table function, but in deciding what reaches it. And that decision is not a technical one. Which variables belong in Table 1 is an epidemiological question about what your reader needs to understand the population — nobody, and no package, can answer it for you.
Action — Using select(), keep only age_years, sex, clinical_presentation, death and region_id, then build the summary again. Assign it to imd_tbl.
Readable, complete, and produced by two functions. Notice what it did with age_years compared to the other four: it recognized the numeric variable and gave it a median, while the rest got counts and percentages. It also picked up the labels straight from your column names.
Part 8 · Comparing two groups
Kassandra’s request lands mid-afternoon: “Can we see it split by sex?”
Before changing anything, check the variable you are about to split by. A grouping variable full of unknowns will produce a column of unknowns. Since you created imd_tbl before, it’s easy to judge
Action — Now rebuild the summary adding the argument by = sex. Pay attention to any message or warning after executing the code.
Look at what happened to sex itself: it is no longer a row. It became the columns, and every other variable is now described separately within each of them. One argument turned a description into a comparison.
You have now met this three times in one session. In tabyl(age_group, year) the second variable became columns. In pivot_wider() the values of year became columns. Here, the levels of sex become columns.
Different packages, different syntax, same underlying move: take the categories of one variable and turn them into the width of the table. Once you recognise the move, the syntax is a detail you can look up.
Action — Can you spot any meaningful difference between groups in any variable?
Action — Pipe the table into add_p() and look at the new column.
add_p() is one line of code and it will change how people read your table. It deserves more thought than it costs.
The function chose a statistical test for you, based on the type of each variable. It did not ask what you were testing, whether you had a hypothesis, or whether comparing every variable at once was a sensible thing to do. It cannot: it has no idea what your study is about.
Two habits will protect you here. First, know which test was run for each row before you show the table to anyone — add_p() documents its choices, and you can override them. Second, ask yourself whether you actually want a p-value in a descriptive table. Describing who got sick and testing a hypothesis are different jobs, and a column of asterisks invites your reader to confuse them.
For today, the point is narrower: the easier a statistic is to produce, the more deliberate you have to be about producing it.
More than two groups
tbl_summary() also incorporate test that compare more than two categories. Let’s try with region_id and the same set of variables:
Action — Copy the code from your last table with p-values, but change the grouping variable to region_id. Assign the result to imd_table_region and check the result
Part 9 · Polish and Export
Our main table imd_tbl is right, but it is not finished. Two small additions turn it into something you can actually consider sharing.
Action — Add a new argument, missing = "no", inside tbl_summary() and compare the result with the previous version.
The rows counting unknowns disappear. Use this when the missing values are few and already reported elsewhere — and not when they are part of the story. Hiding a variable that is 40% missing does not make it less missing, it only makes it invisible to your reader.
Action — Pipe the table into bold_labels().
One last thing. Probably you are already thinking about it because of the last exercise. What about variable names in the table?
Action — Considering the code for this table, would you still rely on rename() for this task? why?
When improving our previous table, we explained how renaming at the very end fixed the table appearance easily, and you could use complex variable names not affecting their use because the data was already in table format.
Now, the tbl_summary() function is making use of variable names (sex or region_id), therefore changing any of them can have an impact, and may require the use of backticks i.e., for the label Age (Years). Therefore, you need to think twice about the convenience of rename() in this, or similar situations
Luckily for you, tbl_summary() already has the solution to modify variable labels (names on the table) without actually having to change variable names.
Action — Go to the function help page and look for the argument label. Read it: would you be able to use it?
It’s a bit complicated for someone in a R intro course. The argument takes a list of pairs var name = "desired label") as argument. It’s normal that you did not understand the explanation with your base level of R, but it’s good that you can slowly familiarize yourself with this approach as it is quite common across many functions.
Here is the answer, and this is how you use lists of arguments:
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = sex,
missing = "no",
label = list(
age_years ~ "Age (years)",
clinical_presentation ~ "Clinical presentation",
death ~ "Died",
region_id ~ "Region"
)) %>%
add_p() %>%
bold_labels() Action — Add the label argument to your tbl_summary() call
Export your final table
Your gtsummary table is neither a dataframe nor a flextable.
Action — Check its class.
To export it you first translate it into something flextable understands, and from there the road is the one you already walked twice today.
Action — Convert the table with as_flex_table(), apply autofit(), and save it to your outputs folder as a Word document. Assign the result to an object imd_table_4 and then save it.
As you saw, gtsummary has its own version of the flextable() function. You could have very well used it and the result would have been the same, but when libraries implement their own version it is wise to use it. You never know which improvements or specific options they incorporated.
Other than that, hopefully exporting a table to a Word document is no longer something exotic, but a repetitive step.
Why do you need select() before tbl_summary()?
What does the argument by = sex do in tbl_summary()?
What kind of object does tbl_summary() return?
Exercise Summary
Look back at where this session started. You were counting one variable with a function that returned an object you could not use. You are finishing it by producing a complete, publication-shaped, group-comparing descriptive table with about five lines of code in the simplest versions.
Kassandra is quite satisfied with the tables you produced for the meeting. For now, the working group is satisfied with the results, but sooner than later a formal report will be needed, and tables alone are a poor way of communicating with colleagues, the press or the general public. Get ready, because tomorrow you will produce your first epidemiological graphs, and learn one of the most powerful tools of R: ggplot2
tbl_summary() makes dozens of decisions for you — which statistic for which variable type, how to format the percentages, what to do with the unknowns, which test to run. Every one of those decisions is one you now know how to make yourself, because you spent three exercises making them by hand. When the automatic table looks wrong, you will know where to look; when a reviewer asks how a number was calculated, you will be able to answer.
Had we started here, you would have a table you could not defend. That is the difference between using a tool and depending on one, and this applies to every aspect of programming as you will almost always find a library or a function that automates something for you. For instance, creating age groups is super simple thanks to libraries like epikit::age_categories(). But it’s always better to know how to do it yourself, as every function has limitations
| Function | Package | What it does |
|---|---|---|
tbl_summary() |
gtsummary | Builds a descriptive table, choosing statistics by variable type |
by = |
gtsummary | Splits the table into columns by the levels of a variable |
missing = "no" |
gtsummary | Hides the rows counting missing values |
labels = list() |
gtsummary | Format the name of variables in the table |
bold_labels() |
gtsummary | Puts the variable labels in bold |
add_p() |
gtsummary | Adds a column of p-values, choosing the test automatically |
as_flex_table() |
gtsummary | Converts the table into a flextable so it can be exported |
# Everything at once: impressive and unusable
imd %>%
tbl_summary()
# Choosing what belongs in the table
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary()
imd_tbl
# Splitting by sex
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = sex)
imd_tbl
# Compare groups
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = sex) %>%
add_p()
imd_tbl
# More than two groups
imd_table_region <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = region_id) %>%
add_p()
imd_table_region
# Polished version
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = sex,
missing = "no") %>%
add_p() %>%
bold_labels()
imd_tbl
# With variable labels
imd_tbl <- imd %>%
select(age_years, sex, clinical_presentation, death, region_id) %>%
tbl_summary(by = sex,
missing = "no",
label = list(
age_years = "Age (years)",
clinical_presentation = "Clinical presentation",
death = "Died",
region_id = "Region"
)) %>%
add_p() %>%
bold_labels()
imd_tbl
# Check object class
class(imd_tbl)
# Export to Word
imd_table_4 <- imd_tbl %>%
as_flex_table() %>%
autofit()
save_as_docx(imd_table_4, path = here("outputs", "Table_4.docx"))