# remotes::install_github("davidgohel/flextable")
library(flextable)
library(tidyverse)
library(safetyData)
use_df_printer()
set_flextable_defaults(
theme_fun = theme_booktabs,
big.mark = " ",
font.color = "#666666",
border.color = "#666666",
padding = 3,
)
Shift tables are tables used in clinical trial analysis.
They show the progression of change from the baseline, with the progression often being along time; the number of subjects is displayed in different range (e.g. low, normal, or high) at baseline and at selected time points or intervals.
The two steps for the creation of these tables are the following:
flextable::shift_table()
. It calculates the counts and aggregates these
counts according to different dimensions in order to display subtotals.as_flextable()
.We used the article by (Luo 2017) to help us understand shift tables.
We will illustrate with a dataset named sdtm_lb
containing Laboratory Tests Results and available in
the “safetyData” package. From the manual of sdtm_lb, it contains:
One record per analyte per planned time point number per time point reference per visit per subject
adlb <- safetyData::sdtm_lb %>% as_tibble() %>%
filter(LBTEST %in% c("Albumin", "Alkaline Phosphatase"),
grepl("(WEEK|SCREENING)", VISIT))
adlb
STUDYID | DOMAIN | USUBJID | LBSEQ | LBTESTCD | LBTEST | LBCAT | LBORRES | LBORRESU | LBORNRLO | LBORNRHI | LBSTRESC | LBSTRESN | LBSTRESU | LBSTNRLO | LBSTNRHI | LBNRIND | LBBLFL | VISITNUM | VISIT | VISITDY | LBDTC | LBDY |
character | character | character | integer | character | character | character | character | character | numeric | numeric | character | numeric | character | numeric | numeric | character | character | numeric | character | integer | character | integer |
CDISCPILOT01 | LB | 01-701-1015 | 1 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | Y | 1 | SCREENING 1 | -7 | 2013-12-26T14:45 | -7 |
CDISCPILOT01 | LB | 01-701-1015 | 39 | ALB | Albumin | CHEMISTRY | 3.9 | g/dL | 3.3 | 4.9 | 39 | 39 | g/L | 33 | 49 | NORMAL | 4 | WEEK 2 | 14 | 2014-01-16T13:17 | 15 | |
CDISCPILOT01 | LB | 01-701-1015 | 74 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | 5 | WEEK 4 | 28 | 2014-01-30T08:50 | 29 | |
CDISCPILOT01 | LB | 01-701-1015 | 104 | ALB | Albumin | CHEMISTRY | 3.7 | g/dL | 3.3 | 4.9 | 37 | 37 | g/L | 33 | 49 | NORMAL | 7 | WEEK 6 | 42 | 2014-02-12T12:56 | 42 | |
CDISCPILOT01 | LB | 01-701-1015 | 134 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | 8 | WEEK 8 | 56 | 2014-03-05T12:25 | 63 | |
CDISCPILOT01 | LB | 01-701-1015 | 164 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | 9 | WEEK 12 | 84 | 2014-03-26T15:15 | 84 | |
CDISCPILOT01 | LB | 01-701-1015 | 199 | ALB | Albumin | CHEMISTRY | 3.7 | g/dL | 3.3 | 4.9 | 37 | 37 | g/L | 33 | 49 | NORMAL | 10 | WEEK 16 | 112 | 2014-05-07T11:21 | 126 | |
CDISCPILOT01 | LB | 01-701-1015 | 229 | ALB | Albumin | CHEMISTRY | 3.7 | g/dL | 3.3 | 4.9 | 37 | 37 | g/L | 33 | 49 | NORMAL | 11 | WEEK 20 | 140 | 2014-05-21T10:58 | 140 | |
CDISCPILOT01 | LB | 01-701-1015 | 259 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | 12 | WEEK 24 | 168 | 2014-06-18T13:00 | 168 | |
CDISCPILOT01 | LB | 01-701-1015 | 294 | ALB | Albumin | CHEMISTRY | 3.8 | g/dL | 3.3 | 4.9 | 38 | 38 | g/L | 33 | 49 | NORMAL | 13 | WEEK 26 | 182 | 2014-07-02T11:45 | 182 | |
n: 3546 |
The calculation of the shift table is a single call to shift_table()
:
SHIFT_TABLE <- shift_table(
x = adlb, cn_visit = "VISIT",
cn_grade = "LBNRIND",
cn_usubjid = "USUBJID",
cn_lab_cat = "LBTEST",
cn_is_baseline = "LBBLFL",
baseline_identifier = "Y",
grade_levels = c("LOW", "NORMAL", "HIGH"))
SHIFT_TABLE
LBTEST | VISIT | BASELINE | LBNRIND | N | PCT |
character | character | character | character | integer | numeric |
Albumin | WEEK 12 | HIGH | HIGH | 0 | 0.0 |
Albumin | WEEK 12 | HIGH | LOW | 0 | 0.0 |
Albumin | WEEK 12 | HIGH | MISSING | 0 | 0.0 |
Albumin | WEEK 12 | HIGH | NORMAL | 1 | 0.0 |
Albumin | WEEK 12 | LOW | HIGH | 0 | 0.0 |
Albumin | WEEK 12 | LOW | LOW | 2 | 0.0 |
Albumin | WEEK 12 | LOW | MISSING | 0 | 0.0 |
Albumin | WEEK 12 | LOW | NORMAL | 0 | 0.0 |
Albumin | WEEK 12 | MISSING | HIGH | 0 | 0.0 |
Albumin | WEEK 12 | MISSING | LOW | 0 | 0.0 |
n: 360 |
The data.frame produced is containing attributes that you can use for post-treatments, i.e. transform grades and visits as factor columns.
SHIFT_TABLE_VISIT <- attr(SHIFT_TABLE, "VISIT_N")
visit_as_factor <- attr(SHIFT_TABLE, "FUN_VISIT")
range_as_factor <- attr(SHIFT_TABLE, "FUN_GRADE")
# post treatments ----
SHIFT_TABLE <- SHIFT_TABLE |> mutate(
VISIT = visit_as_factor(VISIT),
BASELINE = range_as_factor(BASELINE),
LBNRIND = range_as_factor(LBNRIND))
SHIFT_TABLE_VISIT <- SHIFT_TABLE_VISIT |>
mutate(VISIT = visit_as_factor(VISIT))
SHIFT_TABLE
LBTEST | VISIT | BASELINE | LBNRIND | N | PCT |
character | factor | factor | factor | integer | numeric |
Albumin | WEEK 12 | High | High | 0 | 0.0 |
Albumin | WEEK 12 | High | Low | 0 | 0.0 |
Albumin | WEEK 12 | High | Missing | 0 | 0.0 |
Albumin | WEEK 12 | High | Normal | 1 | 0.0 |
Albumin | WEEK 12 | Low | High | 0 | 0.0 |
Albumin | WEEK 12 | Low | Low | 2 | 0.0 |
Albumin | WEEK 12 | Low | Missing | 0 | 0.0 |
Albumin | WEEK 12 | Low | Normal | 0 | 0.0 |
Albumin | WEEK 12 | Missing | High | 0 | 0.0 |
Albumin | WEEK 12 | Missing | Low | 0 | 0.0 |
n: 360 |
In order to have a short table when illustrating, we are going to filter data with only few visits.
SHIFT_TABLE <- SHIFT_TABLE |>
filter(VISIT %in% c("WEEK 4", "WEEK 12", "WEEK 16", "WEEK 26"))
Now the datasets are ready, we need to define a tabulator object that
can then be passed to as_flextable()
.
my_format <- function(z) {
formatC(z * 100, digits = 1, format = "f",
flag = "0", width = 4)
}
tab <- tabulator(
x = SHIFT_TABLE,
hidden_data = SHIFT_TABLE_VISIT,
row_compose = list(
VISIT = as_paragraph(VISIT, "\n(N=", N_VISIT, ")")
),
rows = c("LBTEST", "VISIT", "BASELINE"),
columns = c("LBNRIND"),
`n` = as_paragraph(N),
`%` = as_paragraph(as_chunk(PCT, formatter = my_format))
)
ft <- as_flextable(
x = tab, separate_with = "VISIT",
label_rows = c(LBTEST = "Lab Test", VISIT = "Visit",
BASELINE = "Reference Range Indicator")) |>
width(j = 3, width = 0.9)
ft
Lab Test | Visit | Reference Range Indicator | Low | Normal | High | Missing | ||||||||
n | % | n | % | n | % | n | % | |||||||
Albumin | WEEK 4 | Low | 3 | 01.4 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||
Normal | 12 | 05.4 | 205 | 92.8 | 0 | 00.0 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 0 | 00.0 | 1 | 00.5 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 15 | 06.8 | 205 | 92.8 | 1 | 00.5 | 0 | 00.0 | ||||||
WEEK 12 | Low | 2 | 01.2 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 4 | 02.4 | 159 | 95.2 | 1 | 00.6 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 1 | 00.6 | 0 | 00.0 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 6 | 03.6 | 160 | 95.8 | 1 | 00.6 | 0 | 00.0 | ||||||
WEEK 16 | Low | 2 | 01.4 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 2 | 01.4 | 139 | 95.9 | 2 | 01.4 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 4 | 02.8 | 139 | 95.9 | 2 | 01.4 | 0 | 00.0 | ||||||
WEEK 26 | Low | 1 | 00.9 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 2 | 01.9 | 105 | 97.2 | 0 | 00.0 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 3 | 02.8 | 105 | 97.2 | 0 | 00.0 | 0 | 00.0 | ||||||
Alkaline Phosphatase | WEEK 4 | Low | 4 | 01.8 | 2 | 00.9 | 0 | 00.0 | 0 | 00.0 | ||||
Normal | 1 | 00.4 | 205 | 91.9 | 3 | 01.3 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 2 | 00.9 | 6 | 02.7 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 5 | 02.2 | 209 | 93.7 | 9 | 04.0 | 0 | 00.0 | ||||||
WEEK 12 | Low | 2 | 01.2 | 2 | 01.2 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 1 | 00.6 | 154 | 92.2 | 3 | 01.8 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 0 | 00.0 | 5 | 03.0 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 3 | 01.8 | 156 | 93.4 | 8 | 04.8 | 0 | 00.0 | ||||||
WEEK 16 | Low | 2 | 01.4 | 2 | 01.4 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 1 | 00.7 | 131 | 90.3 | 4 | 02.8 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 1 | 00.7 | 4 | 02.8 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 3 | 02.1 | 134 | 92.4 | 8 | 05.5 | 0 | 00.0 | ||||||
WEEK 26 | Low | 2 | 01.9 | 2 | 01.9 | 0 | 00.0 | 0 | 00.0 | |||||
Normal | 2 | 01.9 | 97 | 89.8 | 1 | 00.9 | 0 | 00.0 | ||||||
High | 0 | 00.0 | 1 | 00.9 | 3 | 02.8 | 0 | 00.0 | ||||||
Missing | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | 0 | 00.0 | ||||||
Sum | 4 | 03.7 | 100 | 92.6 | 4 | 03.7 | 0 | 00.0 |
This type of table is often too large to be displayed on a single page of a document. We will use a programmatic approach to generate a Word document containing one sub-table per page with some pagination markers or titles.
First, let’s load package ‘officer’ and define a post processing function that will add the page number (as a Word field) in the top line of the table.
library(officer)
set_flextable_defaults(
post_process_docx = function(x) {
x <- add_header_lines(x, "Page N°") |>
append_chunks(i = 1, part = "header", j = 1,
as_word_field(x = "Page")) |>
align(part = "header", align = "right", i = 1) |>
hline_top(part = "header", border = fp_border_default(width = 0))
x
}
)
The function that create the flextable for each subset of data is the following:
small_shift_ft <- function(x) {
tab <- tabulator(
x = x,
rows = c("VISIT", "BASELINE"),
columns = c("LBNRIND"),
`n` = as_paragraph(N),
`%` = as_paragraph(as_chunk(PCT, formatter = my_format))
)
ft <- as_flextable(
x = tab, separate_with = "VISIT",
label_rows = c(VISIT = "Visit", BASELINE = "Reference Range Indicator"))
ft
}
Then, split or nest sub tables. We will use tidyr::nest()
.
The Word template being used can be downloaded here: template.docx. We have added our logo and page numbers at the bottom of each page.
subdata <- nest(SHIFT_TABLE, data = all_of(c("VISIT", "BASELINE", "LBNRIND", "N", "PCT")))
doc <- read_docx(path = "template.docx") |>
body_add_par("Table of content", style = "Title") |>
body_add_toc()
for (i in seq_len(nrow(subdata))) {
ft <- small_shift_ft(subdata[[i, "data"]])
doc <- body_add_break(doc) |>
body_add_par(subdata[[i, "LBTEST"]], style = "heading 1") |>
body_add_flextable(ft)
}
print(doc, target = "illustration.docx")
The resulting Word document can be downloaded here: illustration.docx. The miniatures below show the expected document.