Clinical tables and TLF generation

Lesson 12 — Python

Lesson 12 of 20 Advanced ~120 min

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 assembly

The 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.

NoteWhat is still missing

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.

TipParquet, not XPT, as the working format

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")
ImportantBig N comes from the population, not the variable

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 default

Keeping 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",
)
WarningThe 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")
)
Important.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)
TipCheck the pagination against real data

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 pyreadstatno 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.

NoteFurther reading

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” assumes DCSREAS 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

  • rtflite is the pharmaverse Python counterpart to r2rtf — the RTF gap is closed
  • Polars or pandas for the data; rtflite does 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 ADaM AOCC* flags
  • Two RTFColumnHeader objects give a spanning header; check nrow on 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.

Back to top