import polars as pl
import rtflite as rtf
from importlib.resources import files
5 Adverse Events Summary Table
This article demonstrates how to create an adverse events (AE) summary table for clinical study reports using rtflite.
5.1 Overview
Adverse events summary tables are critical safety assessments in clinical trials. They typically show the number and percentage of participants experiencing various categories of adverse events by treatment group.
5.2 Imports
5.3 Data Preparation
# Load and prepare safety population data
= pl.read_parquet(files("rtflite.data").joinpath("adsl.parquet"))
adsl = pl.read_parquet(files("rtflite.data").joinpath("adae.parquet"))
adae
# Safety population
= adsl.filter(pl.col("SAFFL") == "Y").select(["USUBJID", "TRT01A"])
adsl_safety = adae.join(adsl_safety, on="USUBJID").with_columns(pl.col("TRT01A").alias("TRTA")) adae_safety
5.4 AE Summary Calculations
# Define AE categories with their filter conditions
= {
ae_categories "Participants in population": None, # Special case - uses total N
"With any adverse event": pl.lit(True), # All AEs
"With drug-related adverse event": pl.col("AEREL").is_in(["POSSIBLE", "PROBABLE", "DEFINITE", "RELATED"]),
"With serious adverse event": pl.col("AESER") == "Y",
"With serious drug-related adverse event": (
"AESER") == "Y") &
(pl.col("AEREL").is_in(["POSSIBLE", "PROBABLE", "DEFINITE", "RELATED"])
pl.col(
),"Who died": pl.col("AEOUT") == "FATAL",
"Discontinued due to adverse event": pl.col("AEACN") == "DRUG WITHDRAWN"
}
# Calculate population totals
= adsl_safety.group_by("TRT01A").agg(pl.len().alias("N"))
pop_counts
# Calculate AE counts for each category
= []
results for category, filter_expr in ae_categories.items():
if category == "Participants in population":
# Special handling for population row
for row in pop_counts.iter_rows(named=True):
results.append({"Category": category,
"TRT01A": row["TRT01A"],
"n": row["N"],
"pct_display": ""
})else:
# Count unique subjects meeting criteria
= (
ae_counts
adae_safetyfilter(filter_expr)
."TRTA")
.group_by("USUBJID").n_unique().alias("n"))
.agg(pl.col(
)
# Join with population to calculate percentages
= (
merged
pop_counts="TRT01A", right_on="TRTA", how="left")
.join(ae_counts, left_on
.with_columns(["n").fill_null(0),
pl.col(100.0 * pl.col("n").fill_null(0) / pl.col("N")).round(1).alias("pct"),
("n").fill_null(0) > 0)
pl.when(pl.col("("), (100.0 * pl.col("n").fill_null(0) / pl.col("N")).round(1).cast(str), pl.lit(")")]))
.then(pl.concat_str([pl.lit("(0.0)"))
.otherwise(pl.lit("pct_display")
.alias(
])
)
for row in merged.iter_rows(named=True):
results.append({"Category": category,
"TRT01A": row["TRT01A"],
"n": row["n"],
"pct_display": row["pct_display"]
})
= pl.DataFrame(results) ae_summary
5.5 Format AE Summary Table
# Define treatment order and category order for consistent display
= ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
treatments = list(ae_categories.keys())
category_order
# Pivot to wide format with separate n and (%) columns
= (
df_ae_summary
ae_summary"Category").cast(pl.Enum(category_order)))
.with_columns(pl.col(
.pivot(=["n", "pct_display"],
values="Category",
index="TRT01A"
on
)f"n_{trt}").cast(str) for trt in treatments])
.with_columns([pl.col(
.select("Category"] +
[for trt in treatments for col in [f"n_{trt}", f"pct_display_{trt}"]]
[col
)"Category": ""})
.rename({
)
df_ae_summary
5.6 Create RTF Output
# Create RTF document
= rtf.RTFDocument(
doc_ae_summary =df_ae_summary,
df=rtf.RTFTitle(
rtf_title=[
text"Analysis of Adverse Event Summary",
"(Safety Analysis Population)"
]
),=[
rtf_column_header
rtf.RTFColumnHeader(= [""] + treatments,
text =[4, 2, 2, 2],
col_rel_width=["l", "c", "c", "c"],
text_justification
),
rtf.RTFColumnHeader(=[
text"", # Empty for first column
"n", "(%)", # Placebo columns
"n", "(%)", # Low Dose columns
"n", "(%)" # High Dose columns
],=[4] + [1] * 6,
col_rel_width=["l"] + ["c"] * 6,
text_justification= ["single"] + ["single", ""] * 3,
border_left = [""] + ["single"] * 6
border_top
)
],=rtf.RTFBody(
rtf_body=[4] + [1] * 6,
col_rel_width=["l"] + ["c"] * 6,
text_justification= ["single"] + ["single", ""] * 3
border_left
),=rtf.RTFFootnote(
rtf_footnote=[
text"Every subject is counted a single time for each applicable row and column."
]
),=rtf.RTFSource(
rtf_source=["Source: ADSL and ADAE datasets"],
text
)
)
# Write RTF file
"../rtf/tlf_ae_summary.rtf") doc_ae_summary.write_rtf(