Clinical tables and TLF generation
Lesson 12 — Python
Learning objectives
- Produce submission-quality RTF tables from Python with
rtflite - Use Polars for the data preparation that feeds them
- Build a demographics table and an adverse event table end to end
- Control titles, spanning headers, column widths, footnotes and pagination
- Separate computation from formatting so the numbers stay testable
- Understand where the Python clinical reporting stack now stands
The gap that closed
Earlier lessons said plainly that Python had no counterpart to R’s r2rtf — that if you needed submission-grade RTF output, you used R.
That is no longer true. rtflite is a pharmaverse package that produces production-quality RTF for clinical reporting, written by the same author as r2rtf and deliberately mirroring its design. Version 1.0.0 shipped in 2025.
uv add rtflite polars
# or
pip install rtflite
pip install "rtflite[docx]" # adds DOCX assemblyThe stack this lesson uses:
| Layer | Tool | Role |
|---|---|---|
| Data | Polars (or pandas) | Filter, derive, group, pivot |
| Statistics | statsmodels, scipy |
The numbers (lesson 10) |
| Presentation | rtflite |
RTF layout and output |
The separation is deliberate and matches the argument made for R: compute first, format second. rtflite does no data manipulation at all.
rtflite closes the output gap. The derivation gap remains — there is still no Python admiral, no metacore, no xportr. A realistic Python-first project today reads ADaM datasets someone else produced (or that you built by hand, as in lesson 9) and generates the TLFs from them.
That is a genuinely useful slice, and it is the slice pycsr documents end to end.
Polars, briefly
rtflite works with either Polars or pandas. The clinical Python material mostly uses Polars, so it is worth being able to read it.
import polars as pl
adsl = pl.read_parquet("data/adsl.parquet")
adsl.select(["USUBJID", "TRT01P", "AGE", "SEX"])
adsl.filter(pl.col("SEX") == "Female")
adsl.filter(pl.col("AGE") >= 65)
adsl.with_columns(
pl.when(pl.col("AGE") < 65).then(pl.lit("<65"))
.otherwise(pl.lit(">=65")).alias("AGEGR1")
)
adsl.group_by("TRT01P").len().sort("TRT01P")
adsl.group_by("TRT01P").agg([
pl.col("AGE").mean().round(1).alias("mean_age"),
pl.col("AGE").std().round(2).alias("sd_age"),
])
adsl.group_by(["TRT01P", "SEX"]).agg(pl.len().alias("n")) \
.pivot(values="n", index="SEX", on="TRT01P")Mapped to what you already know:
| Task | pandas | Polars | dplyr |
|---|---|---|---|
| Read Parquet | pd.read_parquet() |
pl.read_parquet() |
arrow::read_parquet() |
| Select | df[["a","b"]] |
.select(["a","b"]) |
select(a, b) |
| Filter | df[df.a > 1] |
.filter(pl.col("a") > 1) |
filter(a > 1) |
| Derive | .assign(b=...) |
.with_columns(...) |
mutate(b = ...) |
| Group | .groupby("g").agg() |
.group_by("g").agg() |
summarise(.by = g) |
| Pivot wide | .pivot() |
.pivot(on=, index=, values=) |
pivot_wider() |
| Join | pd.merge() |
.join(other, on=, how=) |
left_join() |
Polars is columnar, lazily evaluated and considerably faster than pandas on large data. Its expression syntax — pl.col("x") inside a verb — reads closer to dplyr than pandas does. Everything in this lesson works with pandas too; rtflite accepts either.
The clinical Python workflow converts .xpt to .parquet once, then works in Parquet. It preserves types exactly, reads an order of magnitude faster, and R, Python and Julia all read it without conversion.
import pyreadstat
df, meta = pyreadstat.read_xport("adsl.xpt")
pl.from_pandas(df).write_parquet("data/adsl.parquet")XPT remains the submission format — see lesson 9. Parquet is what you work in.
rtflite components
An RTF table is assembled from component objects, each mapping to a visible part of the output.
import rtflite as rtf| Class | Controls |
|---|---|
RTFPage |
Orientation, margins, rows per page |
RTFPageHeader |
Page numbering |
RTFPageFooter |
Attribution, notices |
RTFTitle |
Title and subtitle lines |
RTFColumnHeader |
Column headers, including spanning |
RTFBody |
The table body: widths, alignment, borders |
RTFFootnote |
Footnotes |
RTFSource |
Data source line |
RTFDocument |
Assembles them and encodes the RTF |
The minimal case:
doc = rtf.RTFDocument(
df=tbl,
rtf_body=rtf.RTFBody(),
)
doc.write_rtf("rtf/table.rtf")Anyone who has used r2rtf will recognise the shape immediately — the same components, the same col_rel_width, the same three-step build.
A demographics table
The full pipeline: read, summarise, format, render.
1. Read and prepare
import polars as pl
import rtflite as rtf
adsl = pl.read_parquet("data/adsl.parquet").select(
["USUBJID", "TRT01P", "AGE", "SEX", "RACE"]
)
ARMS = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]2. Compute — numbers only, no formatting
def summarise_continuous(df: pl.DataFrame, var: str) -> pl.DataFrame:
"""Descriptive statistics for a continuous variable, by treatment."""
return (
df.group_by("TRT01P")
.agg([
pl.col(var).count().alias("n"),
pl.col(var).mean().round(1).alias("mean"),
pl.col(var).std().round(2).alias("sd"),
pl.col(var).median().alias("median"),
pl.col(var).min().alias("min"),
pl.col(var).max().alias("max"),
])
)
def summarise_categorical(df: pl.DataFrame, var: str) -> pl.DataFrame:
"""Counts and percentages, with big-N denominators from the population."""
counts = df.group_by(["TRT01P", var]).agg(pl.len().alias("n"))
totals = df.group_by("TRT01P").agg(pl.len().alias("N"))
return (
counts.join(totals, on="TRT01P")
.with_columns((100.0 * pl.col("n") / pl.col("N")).round(1).alias("pct"))
)
age_stats = summarise_continuous(adsl, "AGE")
sex_stats = summarise_categorical(adsl, "SEX")
race_stats = summarise_categorical(adsl, "RACE")totals counts subjects per arm; counts counts subjects per arm and category. Percentages divide by the population total.
If age were missing for two subjects, age_stats["n"] would be 84 while the denominator for every percentage stays 86. Conflating those two numbers is the single most common finding in a demographics table review — the same point made in the R TLF lesson.
3. Format — strings only, no computation
def fmt_mean_sd(row: dict) -> str:
return f"{row['mean']:.1f} ({row['sd']:.2f})"
def fmt_median_range(row: dict) -> str:
return f"{row['median']:.1f} [{row['min']:.0f}, {row['max']:.0f}]"
def fmt_n_pct(n: int, pct: float) -> str:
return f"{n} ({pct:.1f})"
def lookup(df: pl.DataFrame, arm: str, col: str, default: str = "0 (0.0)") -> str:
"""Value for one treatment arm, or a default when the group is absent."""
hit = df.filter(pl.col("TRT01P") == arm)
return hit[col][0] if hit.height else defaultKeeping formatting in separate functions matters for the same reason it does in R: the numeric summary can be tested and QC-compared, and the strings are a presentation concern applied last.
4. Assemble the table body
rows: list[list[str]] = []
# --- Age -------------------------------------------------------------------
age_fmt = age_stats.with_columns([
pl.format("{} ({})", pl.col("mean"), pl.col("sd")).alias("mean_sd"),
pl.format("{} [{}, {}]", pl.col("median"), pl.col("min"), pl.col("max"))
.alias("median_range"),
])
rows.append(["Age (years)", "", "", ""])
rows.append([" Mean (SD)"] + [lookup(age_fmt, a, "mean_sd", "") for a in ARMS])
rows.append([" Median [Min, Max]"] +
[lookup(age_fmt, a, "median_range", "") for a in ARMS])
# --- Sex -------------------------------------------------------------------
sex_fmt = sex_stats.with_columns(
pl.format("{} ({}%)", pl.col("n"), pl.col("pct")).alias("n_pct")
)
rows.append(["Sex, n (%)", "", "", ""])
for level in ["Female", "Male"]:
subset = sex_fmt.filter(pl.col("SEX") == level)
rows.append([f" {level}"] + [lookup(subset, a, "n_pct") for a in ARMS])
# --- Race ------------------------------------------------------------------
race_fmt = race_stats.with_columns(
pl.format("{} ({}%)", pl.col("n"), pl.col("pct")).alias("n_pct")
)
RACE_LEVELS = ["White", "Black Or African American",
"American Indian Or Alaska Native"]
rows.append(["Race, n (%)", "", "", ""])
for level in RACE_LEVELS:
subset = race_fmt.filter(pl.col("RACE") == level)
rows.append([f" {level}"] + [lookup(subset, a, "n_pct") for a in ARMS])
baseline = pl.DataFrame(
rows,
schema=["Characteristic", *ARMS],
orient="row",
)default argument is what puts zeros in the table
lookup(..., default="0 (0.0)") is the mechanism that makes a category with no subjects in one arm appear as 0 (0.0) rather than blank.
A blank cell reads as not evaluated; 0 (0.0) reads as evaluated, none occurred. They mean different things to a reviewer, and iterating over an explicit RACE_LEVELS list rather than over the levels present in the data is what guarantees every row appears.
5. Render
n_by_arm = adsl.group_by("TRT01P").agg(pl.len().alias("N"))
big_n = {a: n_by_arm.filter(pl.col("TRT01P") == a)["N"][0] for a in ARMS}
doc = rtf.RTFDocument(
df=baseline,
rtf_title=rtf.RTFTitle(
text=[
"Table 14.1.1",
"Demographic and Baseline Characteristics",
"Safety Analysis Set",
]
),
rtf_column_header=rtf.RTFColumnHeader(
text=[
"Characteristic",
*[f"{a}\\line(N={big_n[a]})" for a in ARMS],
],
text_format="b",
text_justification=["l", "c", "c", "c"],
col_rel_width=[4, 2, 2, 2],
),
rtf_body=rtf.RTFBody(
col_rel_width=[4, 2, 2, 2],
text_justification=["l", "c", "c", "c"],
),
rtf_footnote=rtf.RTFFootnote(
text=[
"N = number of subjects in the safety analysis set; "
"percentages are based on N.",
"SD = standard deviation.",
]
),
rtf_source=rtf.RTFSource(
text="Source: ADSL. Program: t_14_1_1_demographics.py"
),
)
doc.write_rtf("rtf/t_14_1_1_demographics.rtf")\\line inserts a line break inside a cell — the same escape r2rtf uses, so the big-N sits under the arm name.
An adverse event table
The AE table exercises pagination and spanning headers.
adae = pl.read_parquet("data/adae.parquet")
# Subject incidence, not event counts
incidence = (
adae.filter(pl.col("TRTEMFL") == "Y")
.select(["USUBJID", "TRTA", "AEBODSYS", "AEDECOD"])
.unique() # one row per subject per term
.group_by(["TRTA", "AEDECOD"])
.agg(pl.len().alias("n"))
.join(n_by_arm.rename({"TRT01P": "TRTA"}), on="TRTA")
.with_columns((100.0 * pl.col("n") / pl.col("N")).round(1).alias("pct"))
.with_columns(pl.format("{} ({}%)", pl.col("n"), pl.col("pct")).alias("cell"))
.pivot(values="cell", index="AEDECOD", on="TRTA")
.fill_null("0") # zero, not blank
.sort("AEDECOD")
).unique() is what makes this subject incidence
Without it, a subject with three headaches is counted three times and the percentage can exceed 100%.
An AE table reports the number of subjects with at least one event. In R this is handled by the AOCCPFL flag derived in ADaM; if your ADAE has those flags, filter on them instead — the logic then lives in the dataset where it can be tested once, rather than in every table program.
adae.filter((pl.col("TRTEMFL") == "Y") & (pl.col("AOCCPFL") == "Y"))Rendering with pagination and a spanning header:
doc = rtf.RTFDocument(
df=incidence,
rtf_page=rtf.RTFPage(
orientation="landscape",
nrow=25, # body rows per page
),
rtf_page_header=rtf.RTFPageHeader(),
rtf_title=rtf.RTFTitle(
text=[
"Table 14.3.1",
"Subjects With Treatment-Emergent Adverse Events by Preferred Term",
"Safety Analysis Set",
]
),
rtf_column_header=[
rtf.RTFColumnHeader(
text=[" ", "Treatment Group"],
col_rel_width=[5, 6],
border_bottom=["", "single"],
),
rtf.RTFColumnHeader(
text=["Preferred Term", *[f"{a}\\line(N={big_n[a]})" for a in ARMS]],
col_rel_width=[5, 2, 2, 2],
text_justification=["l", "c", "c", "c"],
),
],
rtf_body=rtf.RTFBody(
col_rel_width=[5, 2, 2, 2],
text_justification=["l", "c", "c", "c"],
),
rtf_footnote=rtf.RTFFootnote(
text=[
"A treatment-emergent adverse event is one with onset on or after "
"the first dose and no later than 30 days after the last dose.",
"Subjects are counted once per preferred term.",
]
),
rtf_source=rtf.RTFSource(text="Source: ADAE."),
)
doc.write_rtf("rtf/t_14_3_1_ae.rtf")Two RTFColumnHeader objects in a list produce a two-row header. The first has col_rel_width=[5, 6] so “Treatment Group” spans the three arm columns, and border_bottom=["", "single"] draws the rule only under the spanned portion.
Formatting reference
# Column widths — relative, normalised to the page
rtf.RTFBody(col_rel_width=[4, 2, 2, 2]) # same as [2, 1, 1, 1]
# Alignment: l, c, r, j — one per column
rtf.RTFBody(text_justification=["l", "c", "c", "c"])
# Text format: b bold, i italic, u underline, s strikethrough
rtf.RTFColumnHeader(text=[...], text_format="b")
# Borders: single, double, thick, dotted, dashed; "" for none
rtf.RTFBody(
border_top=["single", "single", "single", "single"],
border_left=["single", "", "", ""],
)
# Pagination
rtf.RTFPage(orientation="landscape", nrow=25)nrow is body rows per page. Too high and rows spill; too low and you waste pages. Worse, a table that paginates mid-block — a section heading on one page and its categories on the next — reads badly.
Render the full table, open it, and adjust. This is not something to set once from a template and forget.
Program structure
Every TLF program should have the same shape, and it is the same shape as the R version:
"""
Program: t_14_1_1_demographics.py
Purpose: Table 14.1.1 Demographic and Baseline Characteristics
Input: data/adsl.parquet
Output: rtf/t_14_1_1_demographics.rtf
rtf/t_14_1_1_demographics.parquet (numbers, for QC)
SAP: Section 7.1
"""
from pathlib import Path
import polars as pl
import rtflite as rtf
from study.summaries import summarise_continuous, summarise_categorical
from study.formats import fmt_n_pct, lookup
OUT = Path("rtf")
OUT.mkdir(exist_ok=True)
# --- 1. Read ---------------------------------------------------------------
adsl = pl.read_parquet("data/adsl.parquet").filter(pl.col("SAFFL") == "Y")
# --- 2. Compute ------------------------------------------------------------
age_stats = summarise_continuous(adsl, "AGE")
sex_stats = summarise_categorical(adsl, "SEX")
# --- 3. Assemble -----------------------------------------------------------
baseline = build_baseline_table(age_stats, sex_stats)
# --- 4. Render -------------------------------------------------------------
build_rtf(baseline).write_rtf(OUT / "t_14_1_1_demographics.rtf")
# --- 5. Save the numbers for QC -------------------------------------------
baseline.write_parquet(OUT / "t_14_1_1_demographics.parquet")Step 5 is worth doing on every table. A QC programmer comparing numbers with a dataset comparison (lesson 7) is doing something far more useful than reading an RTF file, and byte-comparing RTF fails on timestamps anyway.
Testing a TLF program
Because the computation is separated from the formatting, both are testable.
import polars as pl
import pytest
from study.summaries import summarise_categorical
@pytest.fixture
def adsl():
return pl.DataFrame({
"USUBJID": ["001", "002", "003", "004"],
"TRT01P": ["Placebo", "Placebo", "Drug A", "Drug A"],
"SEX": ["F", "M", "F", "F"],
})
def test_percentages_use_the_population_denominator(adsl):
out = summarise_categorical(adsl, "SEX")
placebo_f = out.filter(
(pl.col("TRT01P") == "Placebo") & (pl.col("SEX") == "F")
)
assert placebo_f["n"][0] == 1
assert placebo_f["pct"][0] == 50.0 # 1 of 2, not 1 of 4
def test_absent_category_is_zero_not_blank():
# Placebo has no subjects in one category
assert lookup(pl.DataFrame(schema={"TRT01P": pl.Utf8, "n_pct": pl.Utf8}),
"Placebo", "n_pct") == "0 (0.0)"
def test_rtf_file_is_written(tmp_path):
doc = rtf.RTFDocument(df=small_table, rtf_body=rtf.RTFBody())
out = tmp_path / "test.rtf"
doc.write_rtf(str(out))
assert out.exists()
assert out.read_text().startswith("{\\rtf1")The last test is a weak but worthwhile smoke check: it confirms the file was written and begins with a valid RTF header. For the numbers, test the summary functions directly.
Where the Python stack now stands
An updated, honest assessment.
| Capability | R | Python |
|---|---|---|
| Read SAS files | haven |
pyreadstat — equivalent |
| Write XPT | haven + xportr |
pyreadstat — no conformance layer |
| ADaM derivations | admiral |
Nothing equivalent |
| Metadata / specifications | metacore, metatools |
Nothing equivalent |
| Table computation | Tplyr, rtables |
Polars/pandas by hand |
| RTF output | r2rtf |
rtflite — equivalent |
| DOCX assembly | officer |
rtflite[docx] |
| Figures | ggplot2 |
matplotlib, seaborn, plotnine |
| define.xml | defineR, commercial |
Nothing equivalent |
| Interactive review | Shiny, teal |
Streamlit, Shiny for Python |
The picture has changed meaningfully: presentation is solved, derivation is not. If your ADaM datasets already exist, a Python TLF pipeline is now a reasonable choice. If you need to build ADaM, R remains substantially ahead.
Python for Clinical Study Reports and Submission by Yilong Zhang and Nan Xiao works through disposition, population, baseline, adverse events and ANCOVA tables, then covers packaging the analysis and assembling an eCTD submission. It is the most complete treatment of this workflow available and the natural next step after this lesson.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Percentages divided by the variable’s n |
Wrong denominators | Big N from the population |
| Missing categories omitted | Blank cells read as “not evaluated” | Iterate an explicit level list; default "0 (0.0)" |
| Counting events not subjects | Incidence overstated, can exceed 100% | .unique() or the AOCC* flags |
| Formatting mixed into the computation | Numbers untestable | Separate functions |
| Not saving the numeric result | QC has to read RTF | Write a Parquet sidecar |
nrow never checked against real data |
Rows spill, blocks split | Render and inspect |
| Alphabetical treatment ordering | Placebo in the middle | Explicit ARMS list |
| No source footnote | Output untraceable | Program name and dataset |
Exercise 12.1 — A disposition table
Build a subject disposition table: subjects randomised, completed, and discontinued by reason, with n (%) by treatment arm and a Total column. Render it to RTF with correct zero handling.
Show solution
from pathlib import Path
import polars as pl
import rtflite as rtf
ARMS = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
COLUMNS = [*ARMS, "Total"]
DISCONTINUATION_REASONS = [
"ADVERSE EVENT",
"LACK OF EFFICACY",
"LOST TO FOLLOW-UP",
"WITHDRAWAL BY SUBJECT",
"PHYSICIAN DECISION",
"PROTOCOL VIOLATION",
"DEATH",
"OTHER",
]
def add_total_arm(df: pl.DataFrame, arm_col: str = "TRT01P") -> pl.DataFrame:
"""Duplicate every row under a 'Total' arm so summaries include it."""
return pl.concat([df, df.with_columns(pl.lit("Total").alias(arm_col))])
def disposition_counts(adsl: pl.DataFrame) -> tuple[pl.DataFrame, dict]:
"""Counts and percentages for each disposition category."""
d = add_total_arm(adsl)
totals = d.group_by("TRT01P").agg(pl.len().alias("N"))
big_n = {r["TRT01P"]: r["N"] for r in totals.to_dicts()}
def count_where(expr: pl.Expr, label: str) -> pl.DataFrame:
return (
d.filter(expr)
.group_by("TRT01P").agg(pl.len().alias("n"))
.join(totals, on="TRT01P")
.with_columns([
(100.0 * pl.col("n") / pl.col("N")).round(1).alias("pct"),
pl.lit(label).alias("category"),
])
)
parts = [
count_where(pl.lit(True), "Randomised"),
count_where(pl.col("EOSSTT") == "COMPLETED", "Completed"),
count_where(pl.col("EOSSTT") == "DISCONTINUED", "Discontinued"),
]
parts += [
count_where(
(pl.col("EOSSTT") == "DISCONTINUED") & (pl.col("DCSREAS") == reason),
reason.title(),
)
for reason in DISCONTINUATION_REASONS
]
return pl.concat(parts), big_n
def cell(counts: pl.DataFrame, arm: str, category: str,
as_pct: bool = True) -> str:
"""Formatted cell, defaulting to an explicit zero."""
hit = counts.filter(
(pl.col("TRT01P") == arm) & (pl.col("category") == category)
)
if hit.height == 0:
return "0" if not as_pct else "0 (0.0)"
n, pct = hit["n"][0], hit["pct"][0]
return f"{n}" if not as_pct else f"{n} ({pct:.1f})"
def build_disposition_table(counts: pl.DataFrame) -> pl.DataFrame:
rows: list[list[str]] = []
# Randomised — a count, no percentage (it IS the denominator)
rows.append(["Subjects randomised"] +
[cell(counts, a, "Randomised", as_pct=False) for a in COLUMNS])
rows.append(["", "", "", "", ""])
rows.append(["Completed study, n (%)"] +
[cell(counts, a, "Completed") for a in COLUMNS])
rows.append(["Discontinued study, n (%)"] +
[cell(counts, a, "Discontinued") for a in COLUMNS])
# Reasons, indented under Discontinued
for reason in DISCONTINUATION_REASONS:
label = reason.title()
rows.append([f" {label}"] +
[cell(counts, a, label) for a in COLUMNS])
return pl.DataFrame(rows, schema=["Disposition", *COLUMNS], orient="row")
def render(table: pl.DataFrame, big_n: dict, path: Path) -> None:
header = ["Disposition"] + [f"{a}\\line(N={big_n[a]})" for a in COLUMNS]
widths = [5, 2, 2, 2, 2]
doc = rtf.RTFDocument(
df=table,
rtf_page=rtf.RTFPage(orientation="portrait", nrow=30),
rtf_title=rtf.RTFTitle(
text=["Table 14.1.2",
"Summary of Subject Disposition",
"All Randomised Subjects"]
),
rtf_column_header=rtf.RTFColumnHeader(
text=header,
text_format="b",
text_justification=["l", "c", "c", "c", "c"],
col_rel_width=widths,
),
rtf_body=rtf.RTFBody(
col_rel_width=widths,
text_justification=["l", "c", "c", "c", "c"],
),
rtf_footnote=rtf.RTFFootnote(
text=["Percentages are based on the number of randomised subjects "
"in each treatment group.",
"Subjects are counted once under their primary reason for "
"discontinuation."]
),
rtf_source=rtf.RTFSource(
text="Source: ADSL. Program: t_14_1_2_disposition.py"
),
)
doc.write_rtf(str(path))
# --- Run --------------------------------------------------------------------
adsl = pl.read_parquet("data/adsl.parquet")
counts, big_n = disposition_counts(adsl)
table = build_disposition_table(counts)
render(table, big_n, Path("rtf/t_14_1_2_disposition.rtf"))
table.write_parquet("rtf/t_14_1_2_disposition.parquet") # for QC
print(table)shape: (13, 5)
┌───────────────────────────┬─────────────┬──────────────────────┬──────────────────────┬────────────┐
│ Disposition ┆ Placebo ┆ Xanomeline Low Dose ┆ Xanomeline High Dose ┆ Total │
╞═══════════════════════════╪═════════════╪══════════════════════╪══════════════════════╪════════════╡
│ Subjects randomised ┆ 86 ┆ 84 ┆ 84 ┆ 254 │
│ ┆ ┆ ┆ ┆ │
│ Completed study, n (%) ┆ 60 (69.8) ┆ 32 (38.1) ┆ 28 (33.3) ┆ 120 (47.2) │
│ Discontinued study, n (%) ┆ 26 (30.2) ┆ 52 (61.9) ┆ 56 (66.7) ┆ 134 (52.8) │
│ Adverse Event ┆ 10 (11.6) ┆ 32 (38.1) ┆ 38 (45.2) ┆ 80 (31.5) │
│ Lack Of Efficacy ┆ 0 (0.0) ┆ 1 (1.2) ┆ 0 (0.0) ┆ 1 (0.4) │
│ Death ┆ 2 (2.3) ┆ 1 (1.2) ┆ 1 (1.2) ┆ 4 (1.6) │
└───────────────────────────┴─────────────┴──────────────────────┴──────────────────────┴────────────┘
Five decisions worth explaining
The Total column via row duplication. add_total_arm() concatenates the data to itself with the arm relabelled "Total". Every subsequent group_by produces the Total automatically, with the correct denominator. Computing it separately and joining is more code and one more place to get the denominator wrong.
“Subjects randomised” has no percentage. It is the denominator; showing 86 (100.0) is noise. The as_pct=False flag handles it.
Reasons are iterated from an explicit list, not from the data. A reason with no subjects in any arm still gets a row showing 0 (0.0) across the board. If the SAP specifies the reason categories — and it does — the table must show all of them.
cell() defaults to "0 (0.0)". Combined with the explicit list, this is what guarantees no blank cells. Note the two different defaults: "0" for the count row, "0 (0.0)" for percentage rows.
The Parquet sidecar. QC compares the numeric table, not the RTF.
One thing to check with the statistician. “Subjects are counted once under their primary reason” assumesDCSREAS holds a single primary reason. If your ADSL permits multiple reasons, the percentages will not sum to the discontinued total, and the footnote must say so.
Recap
rtfliteis the pharmaverse Python counterpart tor2rtf— the RTF gap is closed- Polars or pandas for the data;
rtflitedoes presentation only - Component classes mirror
r2rtf: title, column header, body, footnote, source - Big N from the population, not from the variable’s non-missing count
- Iterate an explicit level list and default to
"0 (0.0)"so no cell is blank .unique()for subject incidence, or use the ADaMAOCC*flags- Two
RTFColumnHeaderobjects give a spanning header; checknrowon real data - Save a Parquet sidecar so QC compares numbers, not RTF
- Presentation is now solved in Python; derivation (
admiral,xportr) is not
Next: Streamlit and Python Shiny.