Author a Clinical Trial SAP and TLF Pipeline
Build an ICH E9-compliant SAP and automated table pipeline for a vaccine trial.
Introduction
30 Second Summary
Every time a vaccine reaches your local pharmacy, it passed through a rigorous statistical gauntlet. Biostatisticians planned every analysis months before the first patient enrolled, then automated the tables that proved the vaccine worked.
In this project, you will author a Statistical Analysis Plan for a hypothetical Phase III vaccine trial. You will then build a Python script that simulates patient data and generates clinical-ready tables complete with statistical tests and confidence intervals.
What You'll Build
A complete biostatistics portfolio piece covering SAP authorship, mock table shell design, and automated TLF generation for a randomized, double-blind, placebo-controlled vaccine immunogenicity trial (Protocol VX-SAP-001).
By the end of this project, you'll have:
- A structured SAP document aligned with ICH E9 guidelines, specifying objectives, estimands, analysis populations, and statistical methods for a 500-subject trial.
- Mock table shells for Table 14.1 (Demographics) and Table 14.2 (Primary Efficacy: Seroconversion Rates), designed following pharma industry conventions.
- A Python automation script that simulates 500 subjects and populates the mock shells into finalized tables with Fisher's Exact test, Chi-square test, and Clopper-Pearson confidence intervals.
- Secret Mission: Add a Kaplan-Meier time-to-seroconversion figure with matplotlib, producing a publication-quality PDF output.
Are there any prerequisites?
This project assumes familiarity with clinical trial concepts (phases, endpoints, randomization) and basic Python proficiency (DataFrames, functions). You do not need prior experience with biostatistics programming or SAP authorship.
Set Up Your Python Environment
To build an automated TLF pipeline, you need Python with scientific computing libraries that handle data manipulation and statistical testing.
In this step, you will verify that Python 3.11+ is installed, create your project directory, and install the three core packages: pandas, NumPy, and SciPy.
In this step, get ready to:
- Verify Python 3.11+ is installed and create the project directory.
- Install pandas, NumPy, and SciPy.
- Confirm all packages load correctly with a version check.
Install Python and create a project directory
This project requires Python 3.11 or higher for full compatibility with the latest versions of pandas, NumPy, and SciPy.
- Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.11 or higher
You are all set. Move on to the next instruction below.
ⓧ I see an older version
You have Python installed, but the version is below 3.11. On macOS, the system-installed Python is often an older version that shadows any newer install.
- Download the latest Python 3.11+ installer from python.org/downloads.
- Run the installer and follow the prompts. On macOS, this installs alongside the system Python. On Windows, check the Add Python to PATH box during installation.
- After installation, close and reopen your terminal, then run the version check again.
- If macOS still shows the old version, try python3.11 --version or python3.12 --version to confirm the new install is available. Use that specific binary for all commands in this project.
ⓧ Command not found
Python is not installed on your machine yet.
- Go to python.org/downloads and download the latest Python 3.11+ installer for your operating system.
- Run the installer. On Windows, check the Add Python to PATH box before clicking Install Now.
- Close and reopen your terminal after installation completes.
- Run the version check command again to confirm it works.
Now create your project folder on the Desktop. This gives you a consistent, easy-to-find location.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder with its output/ subdirectory by running these commands:
mkdir -p vaccine_sap_portfolio/output
cd vaccine_sap_portfolio
- Confirm the directory structure by running:
ls
You should see the output folder listed in the output.
What did these commands do?
- mkdir -p creates the folder and any parent directories in one go. The -p flag prevents errors if the folder already exists.
- cd vaccine_sap_portfolio moves your terminal session into the new project folder so all future commands run from there.
Next, open your code editor in this project directory.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor if you use Cursor) and press Enter to open it.
- In the editor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the vaccine_sap_portfolio folder, then click Open.
Use the integrated terminal
VS Code and Cursor have a built-in terminal. Open it with Ctrl+` (backtick key, same shortcut on both macOS and Windows). Use this integrated terminal for the remaining commands in this step.
Install required packages
Your TLF pipeline uses three scientific Python libraries. pandas handles tabular data. NumPy provides random number generation for simulating trial data. SciPy supplies the statistical tests (Fisher's Exact, Chi-square, Beta distribution).
- Install all three packages by running this command:
macOS
pip3 install pandas numpy scipy
Windows
pip install pandas numpy scipy
You should see download progress bars followed by a success message for each package.
Seeing a permission error?
- On macOS, try pip3 install --user pandas numpy scipy to install into your user directory instead.
- On Windows, open PowerShell as Administrator (right-click and select Run as administrator) and retry.
- If pip3 is not found, try python3 -m pip install pandas numpy scipy instead.
help me fix a pip install error for pandas, numpy, or scipy
Verify the installation
A quick import check confirms all three packages installed correctly and shows the exact versions on your machine.
- Run this verification command in your terminal:
macOS
python3 -c "import pandas; import numpy; import scipy; print('pandas', pandas.__version__); print('numpy', numpy.__version__); print('scipy', scipy.__version__)"
Windows
python -c "import pandas; import numpy; import scipy; print('pandas', pandas.__version__); print('numpy', numpy.__version__); print('scipy', scipy.__version__)"
You should see three version numbers printed, one per line. The exact version numbers may vary, but as long as all three print without errors, your environment is ready.
What should I see?
Your output will look something like this (version numbers may differ):
pandas 2.2.3
numpy 2.1.3
scipy 1.14.1
The important thing is that no error messages appear. If you see ModuleNotFoundError, one of the packages did not install correctly. Rerun the pip install command from the previous substep.
Seeing ModuleNotFoundError?
- Make sure you used the same Python binary for both installing and running. If you installed with pip3, run with python3. Mixing pip and python3 can install to a different Python version than the one you invoke.
- Try python3 -m pip install pandas numpy scipy to ensure pip matches your Python 3 interpreter.
help me fix ModuleNotFoundError when importing pandas, numpy, or scipy
Your environment is ready. Next up, you will author the Statistical Analysis Plan document that defines the trial's objectives, populations, and statistical methods.
Author the Statistical Analysis Plan Document
Your Python environment is ready. Now it is time to author the document that drives every statistical decision in a clinical trial: the Statistical Analysis Plan.
A SAP is a pre-specified, regulatory-required document that locks in your analysis populations, endpoints, hypothesis tests, and table specifications before data is unblinded. Writing it first ensures the automation script you build later has a clear specification to implement.
In this step, get ready to:
- Write the SAP header, study objectives, and trial design sections.
- Define the Intent-to-Treat and Per-Protocol analysis populations.
- Specify the primary and secondary statistical methodology.
Create the SAP header and study design sections
Every SAP begins with protocol identification, study objectives, estimand specification, and the trial design summary. These sections anchor the entire document to the regulatory framework defined by ICH E9 and its R1 addendum on estimands.
- In VS Code, create a new file called sap_document.md inside your vaccine_sap_portfolio/ folder.
- Paste the following content into the file to establish the protocol header, introduction, objectives, estimand table, and study design:
# Statistical Analysis Plan
## Protocol VX-SAP-001: A Phase III, Randomized, Double-Blind, Placebo-Controlled Trial to Evaluate the Immunogenicity of Vaccine Candidate VX-2026 in Healthy Adults
**SAP Version:** 1.0
**Date:** 2026-05-21
**Prepared by:** [Biostatistician Name]
---
## 1. Introduction
This Statistical Analysis Plan (SAP) describes the planned statistical analyses for Protocol VX-SAP-001. This document provides detailed methodology for summary and statistical analyses of the data collected in this study and should be read in conjunction with the study protocol.
## 2. Study Objectives and Endpoints
### 2.1 Primary Objective
To demonstrate that VX-2026 induces a superior seroconversion rate compared to placebo at Day 28 post-vaccination.
**Primary Endpoint:** Seroconversion rate at Day 28, defined as the proportion of participants achieving a >= 4-fold rise in serum antibody titre from baseline.
### 2.2 Secondary Objectives
- To compare geometric mean titres (GMT) between VX-2026 and placebo at Day 28
- To evaluate seroconversion rates at Day 14
**Secondary Endpoints:**
- GMT of serum antibodies at Day 28
- Seroconversion rate at Day 14
### 2.3 Estimand (per ICH E9(R1))
| Attribute | Description |
|-----------|-------------|
| Population | Healthy adults aged 18-65 randomized and receiving at least one dose |
| Variable | Binary seroconversion (>= 4-fold rise) at Day 28 |
| Intercurrent Events | Treatment discontinuation: treatment policy strategy (include regardless) |
| Population-level Summary | Difference in proportions (Vaccine minus Placebo) |
## 3. Study Design
- **Design:** Phase III, randomized, double-blind, placebo-controlled
- **Randomization:** 1:1 (Vaccine : Placebo), stratified by age group (18-40, 41-65)
- **Sample Size:** N = 500 (250 per arm)
- **Blinding:** Double-blind (observer-blind for injection)
What does this content establish?
The header identifies the protocol and SAP version. Section 1 provides a brief introduction linking the SAP to the study protocol.
Section 2 defines the primary endpoint (seroconversion at Day 28) and the estimand per ICH E9(R1). The estimand table locks in four attributes: the target population, the variable of interest, how intercurrent events are handled, and the population-level summary measure.
Section 3 summarizes the trial design. A 1:1 randomization ratio with 500 total subjects gives 250 per arm, which is standard for demonstrating immunogenicity superiority.
- Save sap_document.md.
- Open the Markdown preview by pressing Ctrl+Shift+V (both macOS and Windows). You should see the formatted SAP title, the estimand table rendered with columns, and the study design bullet points.
Preview looks like raw text?
Make sure the file is saved with the .md extension. VS Code only renders Markdown preview for files it recognizes as Markdown.
If the table looks misaligned in the preview, check that each row has the same number of pipe characters. The header separator row (|---|---|) must match the number of columns.
help me fix my Markdown table formatting in VS Code
Define analysis populations and endpoints
Clinical trials analyze data in defined populations. The Intent-to-Treat population is the most conservative (all randomized subjects), while the Per-Protocol population excludes subjects with major deviations. Pre-specifying both in the SAP prevents post-hoc population shopping.
- In sap_document.md, add the following Section 4 content directly below the study design section:
## 4. Analysis Populations
### 4.1 Intent-to-Treat (ITT) Population
All randomized participants who received at least one dose of study vaccine or placebo. This is the primary analysis population.
### 4.2 Per-Protocol (PP) Population
All ITT participants who:
- Received the complete vaccination regimen per protocol
- Had no major protocol deviations that could affect immunogenicity assessment
- Had evaluable serology results at Day 28
The PP population serves as the sensitivity analysis population.
Why two analysis populations?
ICH E9 Section 5.2 requires that clinical trials define analysis sets before unblinding. The ITT population (all randomized and dosed) is the primary analysis set because it preserves the benefit of randomization and avoids selection bias.
The PP population excludes subjects with protocol deviations (roughly 10% in most trials). It serves as a sensitivity analysis: if both ITT and PP results agree, the conclusions are robust.
- Save sap_document.md.
- In the Markdown preview, scroll down past the study design. You should see Section 4 with the ITT and PP population definitions rendered as separate subsections.
Section 4 not appearing in preview?
Make sure you added the new content below Section 3, not inside it. There should be a blank line between the last bullet of Section 3 and the ## 4. Analysis Populations heading.
help me position Section 4 correctly in my SAP Markdown file
Specify the statistical methodology
The methods section is the most critical part of the SAP. It locks in the exact hypothesis tests, confidence interval methods, and multiplicity strategy before data is unblinded. Regulatory reviewers check this section against the final analysis to confirm results were not cherry-picked.
- In sap_document.md, add the following Sections 5 through 8 directly below the analysis populations section:
## 5. Statistical Methods
### 5.1 Primary Analysis
The primary endpoint (seroconversion rate at Day 28) will be analyzed using the ITT population.
**Point Estimates:** Seroconversion rates per treatment group with exact (Clopper-Pearson) two-sided 95% confidence intervals.
**Hypothesis Test:**
- H0: p_vaccine = p_placebo
- H1: p_vaccine != p_placebo
Fisher's Exact test will be used for the primary comparison. If all expected cell frequencies exceed 5, the Pearson Chi-square test will be reported as a supportive analysis.
**Treatment Difference:** The difference in proportions (Vaccine minus Placebo) with the Newcombe-Wilson 95% CI.
### 5.2 Secondary Analyses
- **GMT Comparison:** Geometric mean titres will be computed as the antilog of the arithmetic mean of log-transformed titres. Between-group comparison via t-test on log-transformed values. Results presented as GMT ratio with 95% CI.
- **Day 14 Seroconversion:** Same methodology as primary analysis applied to Day 14 timepoint.
### 5.3 Sensitivity Analyses
- Primary analysis repeated on the PP population
- Subgroup analyses by age stratum (18-40, 41-65) and sex
### 5.4 Multiplicity
No adjustment for multiplicity is applied as there is a single primary endpoint. Secondary endpoints are tested at the 0.05 significance level without adjustment.
### 5.5 Missing Data
Participants with missing Day 28 serology will be classified as non-responders in the ITT analysis (conservative approach). The PP analysis excludes participants with missing data.
## 6. Tables, Listings, and Figures
### 6.1 Tables
- Table 14.1: Summary of Demographic and Baseline Characteristics (ITT Population)
- Table 14.2: Seroconversion Rates at Day 28 (ITT Population)
- Table 14.3: Geometric Mean Titres at Day 28 (ITT Population)
### 6.2 Listings
- Listing 16.1: Subject Disposition
- Listing 16.2: Protocol Deviations
### 6.3 Figures
- Figure 14.1: Time to Seroconversion by Treatment Group (Kaplan-Meier)
## 7. Software
Statistical analyses will be performed using Python (version 3.11+) with pandas, NumPy, and SciPy libraries. All programs will be independently verified.
## 8. References
- ICH E9: Statistical Principles for Clinical Trials (1998)
- ICH E9(R1): Addendum on Estimands and Sensitivity Analysis (2019)
- Gamble C, et al. Guidelines for the Content of Statistical Analysis Plans in Clinical Trials. JAMA. 2017;318(23):2337-2343.
What do these methods specify?
Section 5.1 pre-specifies Fisher's Exact test as the primary hypothesis test for comparing seroconversion rates. This is the standard test for 2x2 contingency tables in vaccine trials, as confirmed in the FDA's GARDASIL statistical review. Clopper-Pearson confidence intervals are exact (not approximate), which regulators prefer for binary endpoints.
Section 5.4 avoids multiplicity adjustment because there is a single primary endpoint. Section 5.5 handles missing data conservatively by classifying missing subjects as non-responders in the ITT analysis. This protects against bias from differential dropout.
Section 6 lists the TLF outputs you will automate in later steps. The table numbering follows ICH E3 conventions (14.x for efficacy tables, 16.x for listings).
- Save sap_document.md.
- In the Markdown preview, scroll to the bottom of the document. You should see Section 8 (References) with three citation entries as the final content.
Sections 5-8 not rendering correctly?
Check that the hypothesis test lines (H0: and H1:) are formatted as list items with a - prefix. Without the dash, Markdown treats them as inline text and merges them.
If the TLF list in Section 6 appears on one line, ensure each table entry starts on its own line with - before it.
help me debug why my SAP Markdown sections are rendering incorrectly
Why this approach instead of writing in Word?
Markdown keeps the SAP in a plain-text, version-controllable format that lives alongside the code it specifies. When you update a statistical method, the SAP change and the code change can be tracked in the same commit. Word documents cannot be meaningfully diffed in version control.
In production pharma, SAPs are often authored in Word with tracked changes for stakeholder review. The Markdown format here gives you the same structural content in a format that integrates with the automation pipeline you will build next.
✔️ Awesome, I've got everything!
Great. Double check you have saved sap_document.md with all 8 sections before moving on.
ⓧ I'd like to double check the full code
Here is the complete sap_document.md file. Compare your version against this:
# Statistical Analysis Plan
## Protocol VX-SAP-001: A Phase III, Randomized, Double-Blind, Placebo-Controlled Trial to Evaluate the Immunogenicity of Vaccine Candidate VX-2026 in Healthy Adults
**SAP Version:** 1.0
**Date:** 2026-05-21
**Prepared by:** [Biostatistician Name]
---
## 1. Introduction
This Statistical Analysis Plan (SAP) describes the planned statistical analyses for Protocol VX-SAP-001. This document provides detailed methodology for summary and statistical analyses of the data collected in this study and should be read in conjunction with the study protocol.
## 2. Study Objectives and Endpoints
### 2.1 Primary Objective
To demonstrate that VX-2026 induces a superior seroconversion rate compared to placebo at Day 28 post-vaccination.
**Primary Endpoint:** Seroconversion rate at Day 28, defined as the proportion of participants achieving a >= 4-fold rise in serum antibody titre from baseline.
### 2.2 Secondary Objectives
- To compare geometric mean titres (GMT) between VX-2026 and placebo at Day 28
- To evaluate seroconversion rates at Day 14
**Secondary Endpoints:**
- GMT of serum antibodies at Day 28
- Seroconversion rate at Day 14
### 2.3 Estimand (per ICH E9(R1))
| Attribute | Description |
|-----------|-------------|
| Population | Healthy adults aged 18-65 randomized and receiving at least one dose |
| Variable | Binary seroconversion (>= 4-fold rise) at Day 28 |
| Intercurrent Events | Treatment discontinuation: treatment policy strategy (include regardless) |
| Population-level Summary | Difference in proportions (Vaccine minus Placebo) |
## 3. Study Design
- **Design:** Phase III, randomized, double-blind, placebo-controlled
- **Randomization:** 1:1 (Vaccine : Placebo), stratified by age group (18-40, 41-65)
- **Sample Size:** N = 500 (250 per arm)
- **Blinding:** Double-blind (observer-blind for injection)
## 4. Analysis Populations
### 4.1 Intent-to-Treat (ITT) Population
All randomized participants who received at least one dose of study vaccine or placebo. This is the primary analysis population.
### 4.2 Per-Protocol (PP) Population
All ITT participants who:
- Received the complete vaccination regimen per protocol
- Had no major protocol deviations that could affect immunogenicity assessment
- Had evaluable serology results at Day 28
The PP population serves as the sensitivity analysis population.
## 5. Statistical Methods
### 5.1 Primary Analysis
The primary endpoint (seroconversion rate at Day 28) will be analyzed using the ITT population.
**Point Estimates:** Seroconversion rates per treatment group with exact (Clopper-Pearson) two-sided 95% confidence intervals.
**Hypothesis Test:**
- H0: p_vaccine = p_placebo
- H1: p_vaccine != p_placebo
Fisher's Exact test will be used for the primary comparison. If all expected cell frequencies exceed 5, the Pearson Chi-square test will be reported as a supportive analysis.
**Treatment Difference:** The difference in proportions (Vaccine minus Placebo) with the Newcombe-Wilson 95% CI.
### 5.2 Secondary Analyses
- **GMT Comparison:** Geometric mean titres will be computed as the antilog of the arithmetic mean of log-transformed titres. Between-group comparison via t-test on log-transformed values. Results presented as GMT ratio with 95% CI.
- **Day 14 Seroconversion:** Same methodology as primary analysis applied to Day 14 timepoint.
### 5.3 Sensitivity Analyses
- Primary analysis repeated on the PP population
- Subgroup analyses by age stratum (18-40, 41-65) and sex
### 5.4 Multiplicity
No adjustment for multiplicity is applied as there is a single primary endpoint. Secondary endpoints are tested at the 0.05 significance level without adjustment.
### 5.5 Missing Data
Participants with missing Day 28 serology will be classified as non-responders in the ITT analysis (conservative approach). The PP analysis excludes participants with missing data.
## 6. Tables, Listings, and Figures
### 6.1 Tables
- Table 14.1: Summary of Demographic and Baseline Characteristics (ITT Population)
- Table 14.2: Seroconversion Rates at Day 28 (ITT Population)
- Table 14.3: Geometric Mean Titres at Day 28 (ITT Population)
### 6.2 Listings
- Listing 16.1: Subject Disposition
- Listing 16.2: Protocol Deviations
### 6.3 Figures
- Figure 14.1: Time to Seroconversion by Treatment Group (Kaplan-Meier)
## 7. Software
Statistical analyses will be performed using Python (version 3.11+) with pandas, NumPy, and SciPy libraries. All programs will be independently verified.
## 8. References
- ICH E9: Statistical Principles for Clinical Trials (1998)
- ICH E9(R1): Addendum on Estimands and Sensitivity Analysis (2019)
- Gamble C, et al. Guidelines for the Content of Statistical Analysis Plans in Clinical Trials. JAMA. 2017;318(23):2337-2343.
Your SAP is complete. You now have a pre-specified document that defines exactly what your automation script must produce. Next up, you will design the mock table shells that serve as empty templates for the final statistical outputs.
Design the Mock Table Shells
Your SAP document now specifies exactly which tables your trial needs. The next step is translating those specifications into concrete table layouts before writing any analysis code.
In pharma, a mock table shell is an empty template showing the exact structure of a clinical table: column headers, row categories, and placeholder cells. Shells are reviewed by the study team before any programming begins, ensuring everyone agrees on what the final output looks like.
In this step, get ready to:
- Set up the Python script that will generate your trial tables.
- Design the Table 14.1 Demographics shell with treatment group columns and demographic row categories.
- Design the Table 14.2 Primary Efficacy shell with seroconversion rates, confidence intervals, and p-values.
Create the Table 14.1 Demographics shell
Table 14.1 summarizes baseline characteristics of your trial population. Its job is to demonstrate that randomization produced balanced treatment arms.
The shell defines the structure before data fills it. Here is what Table 14.1 needs:
- Three column groups: Vaccine (N=250), Placebo (N=250), and Total (N=500).
- Row categories for continuous variables: Age displayed as mean(SD), BMI displayed as mean(SD).
- Row categories for categorical variables: Sex displayed as n(%), Race displayed as n(%).
- A p-value column showing the result of a statistical test for baseline comparability between arms.
Why design shells before coding?
In a real trial, the biostatistician creates mock shells, circulates them for review, and only begins programming after sign-off. This prevents expensive rework. Your shell defines the exact output format your Python functions will target.
The p-value column uses Chi-square tests for categorical variables and independent t-tests for continuous variables. These are standard tests for assessing baseline balance.
Now create the Python file that will eventually produce these tables. This file starts with the configuration constants that control the entire pipeline.
- In VS Code, create a new file called generate_tlfs.py in your vaccine_sap_portfolio/ folder.
- Add the following configuration code to set up the script's foundation:
"""\ngenerate_tlfs.py\n================\nVaccine Trial VX-SAP-001: Simulated Data Generation and TLF Population\nGenerates Table 14.1 (Demographics) and Table 14.2 (Seroconversion Rates)\n\nRequirements: numpy, pandas, scipy\n"""\n\nimport os\nimport numpy as np\nimport pandas as pd\nfrom scipy import stats\n\n# =============================================================================\n# CONFIGURATION\n# =============================================================================\nSEED = 42\nN_SUBJECTS = 500\nN_PER_ARM = 250\nOUTPUT_DIR = "output"\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)
What does this code do?
The docstring at the top documents what this script produces. The imports bring in the libraries you installed in Step 1.
The configuration block sets trial-wide constants. SEED = 42 ensures the random number generator produces identical results every time. N_SUBJECTS = 500 and N_PER_ARM = 250 match the SAP's specified sample size.
The os.makedirs(OUTPUT_DIR, exist_ok=True) line creates the output/ directory if it doesn't already exist. This is where your finalized tables will be saved.
- Save generate_tlfs.py.
- Confirm the file is listed in your VS Code Explorer panel under vaccine_sap_portfolio/.
File not appearing in the Explorer?
Make sure you created the file inside the vaccine_sap_portfolio/ folder, not in a parent directory. Check the file extension is .py and not .py.txt.
help me find where my file was saved
Create the Table 14.2 Primary Efficacy shell
Table 14.2 is the most important table in your trial. It presents the primary endpoint result that determines whether the vaccine works.
Here is what the Table 14.2 shell requires:
- Two column groups: Vaccine and Placebo.
- Responders row showing n/N format (number who seroconverted out of total evaluated).
- Seroconversion Rate row showing the percentage for each arm.
- 95% CI row computed using the Clopper-Pearson exact method.
- Treatment Difference row showing Vaccine minus Placebo.
- Fisher's Exact test p-value as the primary comparison.
- Chi-square test p-value as the supportive analysis.
Why Clopper-Pearson and not the normal approximation?
The Clopper-Pearson interval is an exact method that guarantees coverage at or above the nominal confidence level. The normal approximation can produce intervals below 0% or above 100% for extreme proportions. Regulatory agencies prefer the exact method for vaccine efficacy reporting.
The method uses the Beta distribution to invert two one-sided binomial tests. You will implement this using scipy.stats.beta.ppf in a later step.
Why two tests (Fisher's Exact and Chi-square)?
Your SAP specifies Fisher's Exact test as the primary analysis because it is valid regardless of cell size. The Chi-square test is reported as a supportive analysis when all expected cell frequencies exceed 5. This dual-reporting approach follows conventions from published vaccine SAPs.
Both Table 14.1 and Table 14.2 will be populated programmatically in Steps 5 and 6. Your configuration constants (N_SUBJECTS, N_PER_ARM, OUTPUT_DIR) will drive the table headers and output paths automatically.
✔️ Awesome, I've got everything!
Great. Make sure you have saved generate_tlfs.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete generate_tlfs.py file at this point:
"""\ngenerate_tlfs.py\n================\nVaccine Trial VX-SAP-001: Simulated Data Generation and TLF Population\nGenerates Table 14.1 (Demographics) and Table 14.2 (Seroconversion Rates)\n\nRequirements: numpy, pandas, scipy\n"""\n\nimport os\nimport numpy as np\nimport pandas as pd\nfrom scipy import stats\n\n# =============================================================================\n# CONFIGURATION\n# =============================================================================\nSEED = 42\nN_SUBJECTS = 500\nN_PER_ARM = 250\nOUTPUT_DIR = "output"\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)
Your mock shells are designed and your script foundation is in place. Next up, you will write the data simulation code that generates 500 synthetic trial subjects to populate these table structures.
Simulate the Clinical Trial Dataset
Your SAP document defines the trial's statistical methods on paper. Now it's time to bring those methods to life with data.
In this step, you will build a simulation function that generates a realistic 500-subject clinical trial dataset. The function produces demographics, treatment assignments, antibody titres, and derived endpoints that mirror what a real Phase III vaccine trial would collect.
In this step, get ready to:
- Generate a subject-level dataset with demographics, treatment arms, and antibody titres.
- Derive analysis population flags and the binary seroconversion endpoint.
- Export the simulated data to CSV and validate that population sizes are clinically plausible.
Generate the subject-level dataset
A reproducible simulation needs a fixed random seed so every run produces identical results. You will use NumPy's default_rng generator to create 500 subjects with realistic demographics and antibody responses.
- In generate_tlfs.py, add the following function below the os.makedirs(OUTPUT_DIR, exist_ok=True) line:
# =============================================================================
# SECTION 1: DATA SIMULATION
# =============================================================================
def simulate_trial_data(seed: int = SEED) -> pd.DataFrame:
"""Simulate a 500-subject Phase III vaccine immunogenicity trial dataset."""
rng = np.random.default_rng(seed)
subject_ids = [f"VX-{i:04d}" for i in range(1, N_SUBJECTS + 1)]
# Treatment assignment: 1:1 randomization
treatment = np.array(["Vaccine"] * N_PER_ARM + ["Placebo"] * N_PER_ARM)
rng.shuffle(treatment)
# Demographics
age = rng.normal(loc=42, scale=12, size=N_SUBJECTS).clip(18, 65).round(0).astype(int)
sex = rng.choice(["Male", "Female"], size=N_SUBJECTS, p=[0.52, 0.48])
race = rng.choice(
["White", "Black", "Asian", "Other"],
size=N_SUBJECTS,
p=[0.60, 0.18, 0.15, 0.07],
)
bmi = rng.normal(loc=27.5, scale=4.5, size=N_SUBJECTS).clip(18, 45).round(1)
# Baseline antibody titres (log-normal, low pre-existing immunity)
baseline_titre = rng.lognormal(mean=2.5, sigma=0.8, size=N_SUBJECTS).round(1)
# Post-vaccination titres at Day 28
# Vaccine arm: substantial response (mean ~8-fold rise)
# Placebo arm: minimal change (noise only)
fold_rise = np.where(
treatment == "Vaccine",
rng.lognormal(mean=2.1, sigma=0.7, size=N_SUBJECTS), # ~8x geometric mean
rng.lognormal(mean=0.2, sigma=0.3, size=N_SUBJECTS), # ~1.2x (noise)
)
day28_titre = (baseline_titre * fold_rise).round(1)
What does this code do?
This is the first half of your simulation function. It creates the raw clinical data that a real trial would collect at enrolment and at the Day 28 visit.
- np.random.default_rng(seed) creates a reproducible random number generator using the PCG64 algorithm. Every run with seed 42 produces identical data.
- Treatment assignment creates equal arms (250 per group) and shuffles them so assignment order is random.
- Demographics use realistic distributions clipped to plausible ranges (e.g., age 18-65 for a healthy adult trial).
- Antibody titres use log-normal distributions because biological measurements are typically right-skewed. The vaccine arm gets a large fold-rise (~8x geometric mean) while placebo gets noise (~1.2x).
Why log-normal for antibody titres?
Antibody concentrations in blood span several orders of magnitude across a population. A log-normal distribution models this natural skew. The mean parameter in rng.lognormal is the mean of the underlying normal distribution (on the log scale), not the mean of the output values.
You won't see output yet. The next substep completes the function by deriving clinical endpoints.
Derive analysis flags and endpoints
Your SAP defines two analysis populations (ITT and Per-Protocol) and a binary primary endpoint (seroconversion). Now you will compute these derived variables from the raw data.
- Still inside the simulate_trial_data function, add this code directly below the day28_titre line:
# Seroconversion: >= 4-fold rise from baseline
seroconversion = (day28_titre / baseline_titre >= 4.0).astype(int)
# Protocol deviations (~10% of subjects)
has_deviation = rng.choice([0, 1], size=N_SUBJECTS, p=[0.90, 0.10])
# Population flags
itt_flag = np.ones(N_SUBJECTS, dtype=int) # All randomized + dosed
pp_flag = ((has_deviation == 0) & (itt_flag == 1)).astype(int)
df = pd.DataFrame(
{
"subject_id": subject_ids,
"treatment": treatment,
"age": age,
"sex": sex,
"race": race,
"bmi": bmi,
"baseline_titre": baseline_titre,
"day28_titre": day28_titre,
"fold_rise": (day28_titre / baseline_titre).round(2),
"seroconversion": seroconversion,
"itt_flag": itt_flag,
"pp_flag": pp_flag,
"protocol_deviation": has_deviation,
}
)
return df
What does this code do?
- seroconversion computes the primary endpoint from your SAP: a binary flag (1/0) for whether the subject achieved a 4-fold or greater rise in antibody titre from baseline.
- has_deviation randomly assigns ~10% of subjects as having a major protocol deviation. This simulates real-world attrition.
- itt_flag is 1 for all subjects (all randomized and dosed). pp_flag excludes subjects with deviations, matching the Per-Protocol definition in your SAP.
- The final DataFrame assembles all columns into a tidy dataset and the function returns it.
You won't see output yet. The next substep adds the execution block that calls this function and validates the results.
Export and validate the simulated data
With the simulation function complete, you need an execution block that calls it, saves the dataset to CSV, and prints summary statistics so you can confirm the data looks clinically plausible.
- Below the simulate_trial_data function (outside and below it, at the module level), add the main execution block:
# =============================================================================
# MAIN EXECUTION
# =============================================================================
if __name__ == "__main__":
print("=" * 60)
print("VX-SAP-001: Generating TLFs for Vaccine Immunogenicity Trial")
print("=" * 60)
# Step 1: Simulate data
print("\n[1/4] Simulating trial data (N=500)...")
trial_data = simulate_trial_data()
trial_data.to_csv(os.path.join(OUTPUT_DIR, "trial_data.csv"), index=False)
print(f" Dataset saved to {OUTPUT_DIR}/trial_data.csv")
# Print summary
print("\n --- Data Summary ---")
print(f" ITT Population: {trial_data['itt_flag'].sum()}")
print(f" PP Population: {trial_data['pp_flag'].sum()}")
vax_data = trial_data[trial_data["treatment"] == "Vaccine"]
pbo_data = trial_data[trial_data["treatment"] == "Placebo"]
print(f" Vaccine arm: {len(vax_data)} subjects")
print(f" Placebo arm: {len(pbo_data)} subjects")
print(
f" Seroconversion: Vaccine={vax_data['seroconversion'].mean()*100:.1f}%, "
f"Placebo={pbo_data['seroconversion'].mean()*100:.1f}%"
)
What does this code do?
- if __name__ == "__main__": ensures this block only runs when you execute the script directly (not when importing the function later).
- The function is called, and the resulting DataFrame is saved to output/trial_data.csv for traceability.
- Summary statistics print ITT/PP population counts, arm sizes, and seroconversion rates so you can spot-check plausibility before generating tables.
- The [1/4] label anticipates the table generation steps you will add later.
- Save generate_tlfs.py.
- Run the script by executing this command in your terminal:
python generate_tlfs.py
You should see output showing 500 ITT subjects, approximately 450 PP subjects, 250 per arm, and seroconversion rates around 80-85% for Vaccine and 15-20% for Placebo.
Seeing an error when running the script?
- IndentationError: Make sure the seroconversion/flags code from substep 2 is indented at the same level as the demographics code (inside the function body).
- NameError on N_SUBJECTS or SEED: Confirm the configuration constants at the top of the file are still present above the function.
- The return df line must be indented inside the function. If it is at the module level, you will get a SyntaxError.
help me debug my generate_tlfs.py script
- Confirm that output/trial_data.csv was created by running:
ls output/
You should see trial_data.csv listed in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and that the terminal output shows plausible seroconversion rates (vaccine arm much higher than placebo).
ⓧ I'd like to double check the full code
Here is the complete generate_tlfs.py file as it should look at this point:
"""
generate_tlfs.py
================
Vaccine Trial VX-SAP-001: Simulated Data Generation and TLF Population
Generates Table 14.1 (Demographics) and Table 14.2 (Seroconversion Rates)
Requirements: numpy, pandas, scipy
"""
import os
import numpy as np
import pandas as pd
from scipy import stats
# =============================================================================
# CONFIGURATION
# =============================================================================
SEED = 42
N_SUBJECTS = 500
N_PER_ARM = 250
OUTPUT_DIR = "output"
os.makedirs(OUTPUT_DIR, exist_ok=True)
# =============================================================================
# SECTION 1: DATA SIMULATION
# =============================================================================
def simulate_trial_data(seed: int = SEED) -> pd.DataFrame:
"""Simulate a 500-subject Phase III vaccine immunogenicity trial dataset."""
rng = np.random.default_rng(seed)
subject_ids = [f"VX-{i:04d}" for i in range(1, N_SUBJECTS + 1)]
# Treatment assignment: 1:1 randomization
treatment = np.array(["Vaccine"] * N_PER_ARM + ["Placebo"] * N_PER_ARM)
rng.shuffle(treatment)
# Demographics
age = rng.normal(loc=42, scale=12, size=N_SUBJECTS).clip(18, 65).round(0).astype(int)
sex = rng.choice(["Male", "Female"], size=N_SUBJECTS, p=[0.52, 0.48])
race = rng.choice(
["White", "Black", "Asian", "Other"],
size=N_SUBJECTS,
p=[0.60, 0.18, 0.15, 0.07],
)
bmi = rng.normal(loc=27.5, scale=4.5, size=N_SUBJECTS).clip(18, 45).round(1)
# Baseline antibody titres (log-normal, low pre-existing immunity)
baseline_titre = rng.lognormal(mean=2.5, sigma=0.8, size=N_SUBJECTS).round(1)
# Post-vaccination titres at Day 28
# Vaccine arm: substantial response (mean ~8-fold rise)
# Placebo arm: minimal change (noise only)
fold_rise = np.where(
treatment == "Vaccine",
rng.lognormal(mean=2.1, sigma=0.7, size=N_SUBJECTS), # ~8x geometric mean
rng.lognormal(mean=0.2, sigma=0.3, size=N_SUBJECTS), # ~1.2x (noise)
)
day28_titre = (baseline_titre * fold_rise).round(1)
# Seroconversion: >= 4-fold rise from baseline
seroconversion = (day28_titre / baseline_titre >= 4.0).astype(int)
# Protocol deviations (~10% of subjects)
has_deviation = rng.choice([0, 1], size=N_SUBJECTS, p=[0.90, 0.10])
# Population flags
itt_flag = np.ones(N_SUBJECTS, dtype=int) # All randomized + dosed
pp_flag = ((has_deviation == 0) & (itt_flag == 1)).astype(int)
df = pd.DataFrame(
{
"subject_id": subject_ids,
"treatment": treatment,
"age": age,
"sex": sex,
"race": race,
"bmi": bmi,
"baseline_titre": baseline_titre,
"day28_titre": day28_titre,
"fold_rise": (day28_titre / baseline_titre).round(2),
"seroconversion": seroconversion,
"itt_flag": itt_flag,
"pp_flag": pp_flag,
"protocol_deviation": has_deviation,
}
)
return df
# =============================================================================
# MAIN EXECUTION
# =============================================================================
if __name__ == "__main__":
print("=" * 60)
print("VX-SAP-001: Generating TLFs for Vaccine Immunogenicity Trial")
print("=" * 60)
# Step 1: Simulate data
print("\n[1/4] Simulating trial data (N=500)...")
trial_data = simulate_trial_data()
trial_data.to_csv(os.path.join(OUTPUT_DIR, "trial_data.csv"), index=False)
print(f" Dataset saved to {OUTPUT_DIR}/trial_data.csv")
# Print summary
print("\n --- Data Summary ---")
print(f" ITT Population: {trial_data['itt_flag'].sum()}")
print(f" PP Population: {trial_data['pp_flag'].sum()}")
vax_data = trial_data[trial_data["treatment"] == "Vaccine"]
pbo_data = trial_data[trial_data["treatment"] == "Placebo"]
print(f" Vaccine arm: {len(vax_data)} subjects")
print(f" Placebo arm: {len(pbo_data)} subjects")
print(
f" Seroconversion: Vaccine={vax_data['seroconversion'].mean()*100:.1f}%, "
f"Placebo={pbo_data['seroconversion'].mean()*100:.1f}%"
)
Your simulated dataset is ready. Next up, you will use this data to populate the Table 14.1 demographics shell with real summary statistics and hypothesis tests.
Populate the Demographics Table
Your simulated dataset is ready with 500 subjects, treatment assignments, and derived endpoints. Now it is time to produce the first clinical-ready output: Table 14.1, the demographics and baseline characteristics summary.
In clinical trials, a demographics table proves that randomization worked by showing the treatment arms are balanced at baseline. You will compute summary statistics for each arm, test for any imbalances with Chi-square and t-tests, then assemble everything into a formatted table that matches your mock shell design.
In this step, get ready to:
- Build reusable statistical helper functions for confidence intervals and formatting.
- Create the demographics table function with Chi-square and t-test comparisons.
- Generate and save the finalized Table 14.1 to your output folder.
Compute demographic summary statistics
Before building the full table function, you need helper utilities that compute and format clinical statistics. These small functions keep the main table logic clean and reusable for Table 14.2 later.
- In generate_tlfs.py, find the comment # SECTION 1: DATA SIMULATION and scroll past the entire simulate_trial_data function.
- Add the following section header and function below it:
# =============================================================================
# SECTION 2: STATISTICAL HELPER FUNCTIONS
# =============================================================================
def clopper_pearson_ci(x: int, n: int, alpha: float = 0.05) -> tuple:
"""
Compute Clopper-Pearson (exact) confidence interval for a binomial proportion.
Parameters
----------
x : int
Number of successes (responders)
n : int
Number of trials (total subjects)
alpha : float
Significance level (default 0.05 for 95% CI)
Returns
-------
tuple of (lower, upper) bounds
"""
if n == 0:
return (0.0, 0.0)
if x == 0:
lower = 0.0
else:
lower = stats.beta.ppf(alpha / 2, x, n - x + 1)
if x == n:
upper = 1.0
else:
upper = stats.beta.ppf(1 - alpha / 2, x + 1, n - x)
return (lower, upper)
What does this function do?
The Clopper-Pearson method computes an exact confidence interval for a binomial proportion (like a seroconversion rate). It uses the inverse Beta distribution (stats.beta.ppf) to find the lower and upper bounds without relying on normal approximations.
This is the gold standard in vaccine trials because it guarantees the stated coverage probability even with small sample sizes. You will use it in Table 14.2 for seroconversion rate CIs.
- Directly below clopper_pearson_ci, add these two formatting helpers:
def format_n_pct(n: int, total: int) -> str:
"""Format count and percentage as 'n (xx.x%)'."""
pct = 100.0 * n / total if total > 0 else 0.0
return f"{n} ({pct:.1f}%)"
def format_mean_sd(values: pd.Series) -> str:
"""Format mean and SD as 'mean (SD)'."""
return f"{values.mean():.1f} ({values.std():.1f})"
What do these helpers do?
format_n_pct takes a count and total, returning the standard clinical reporting format like 132 (52.8%).
format_mean_sd computes and formats the mean and standard deviation of a pandas Series. Together these enforce consistent decimal precision across your entire table.
- Save generate_tlfs.py.
Seeing a NameError on stats?
Make sure your imports at the top of the file include from scipy import stats. The clopper_pearson_ci function calls stats.beta.ppf which requires this import.
help me fix my SciPy imports for the helper functions
Apply statistical tests for baseline comparability
The demographics table groups subjects by treatment arm and tests whether the arms are balanced. Categorical variables (sex, race) use the Chi-square test. Continuous variables (age, BMI) use the independent t-test.
- Below your formatting helpers, add the Section 3 header and the full generate_demographics_table function:
# =============================================================================
# SECTION 3: TABLE 14.1 - DEMOGRAPHICS
# =============================================================================
def generate_demographics_table(df: pd.DataFrame) -> str:
"""
Generate Table 14.1: Summary of Demographic and Baseline Characteristics.
Uses ITT population.
"""
itt = df[df["itt_flag"] == 1].copy()
vaccine = itt[itt["treatment"] == "Vaccine"]
placebo = itt[itt["treatment"] == "Placebo"]
n_vax = len(vaccine)
n_pbo = len(placebo)
n_total = len(itt)
lines = []
lines.append("=" * 85)
lines.append("Table 14.1: Summary of Demographic and Baseline Characteristics")
lines.append(" (Intent-to-Treat Population)")
lines.append("=" * 85)
lines.append(
f"{'Characteristic':<25} {'Vaccine':<20} {'Placebo':<20} {'Total':<15} {'p-value':<10}"
)
lines.append(f"{'':25} {'(N=' + str(n_vax) + ')':<20} {'(N=' + str(n_pbo) + ')':<20} {'(N=' + str(n_total) + ')':<15} {'':<10}")
lines.append("-" * 85)
# Age (continuous)
age_vax = format_mean_sd(vaccine["age"])
age_pbo = format_mean_sd(placebo["age"])
age_tot = format_mean_sd(itt["age"])
_, age_p = stats.ttest_ind(vaccine["age"], placebo["age"])
lines.append(f"{'Age (years), mean (SD)':<25} {age_vax:<20} {age_pbo:<20} {age_tot:<15} {age_p:.4f}")
# Sex (categorical)
lines.append(f"{'Sex, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for sex_cat in ["Male", "Female"]:
n_v = (vaccine["sex"] == sex_cat).sum()
n_p = (placebo["sex"] == sex_cat).sum()
n_t = (itt["sex"] == sex_cat).sum()
lines.append(
f" {sex_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
# Chi-square for sex
sex_table = pd.crosstab(itt["treatment"], itt["sex"])
chi2_sex, p_sex, _, _ = stats.chi2_contingency(sex_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_sex:.4f}")
# Race (categorical)
lines.append(f"{'Race, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for race_cat in ["White", "Black", "Asian", "Other"]:
n_v = (vaccine["race"] == race_cat).sum()
n_p = (placebo["race"] == race_cat).sum()
n_t = (itt["race"] == race_cat).sum()
lines.append(
f" {race_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
race_table = pd.crosstab(itt["treatment"], itt["race"])
chi2_race, p_race, _, _ = stats.chi2_contingency(race_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_race:.4f}")
# BMI (continuous)
bmi_vax = format_mean_sd(vaccine["bmi"])
bmi_pbo = format_mean_sd(placebo["bmi"])
bmi_tot = format_mean_sd(itt["bmi"])
_, bmi_p = stats.ttest_ind(vaccine["bmi"], placebo["bmi"])
lines.append(f"{'BMI (kg/m2), mean (SD)':<25} {bmi_vax:<20} {bmi_pbo:<20} {bmi_tot:<15} {bmi_p:.4f}")
lines.append("-" * 85)
lines.append("Note: p-values from independent t-test (continuous) or Chi-square test (categorical).")
lines.append(f"Source: generate_tlfs.py | Executed: {pd.Timestamp.now().strftime('%Y-%m-%d')}")
lines.append("=" * 85)
return "\n".join(lines)
What does this function do?
- Filters the DataFrame to the ITT population (itt_flag == 1) and splits into Vaccine and Placebo arms.
- For continuous variables (age, BMI), it calls format_mean_sd per arm and runs stats.ttest_ind to test whether the means differ significantly.
- For categorical variables (sex, race), it loops through categories, computes format_n_pct per arm, then uses pd.crosstab and stats.chi2_contingency to test for distributional differences.
- Assembles all rows into an 85-character-wide table string with a header, column labels, footnotes, and an execution timestamp.
- Save generate_tlfs.py.
Seeing an AttributeError on chi2_contingency?
The function stats.chi2_contingency returns a result object. The unpacking chi2_sex, p_sex, _, _ extracts the statistic, p-value, degrees of freedom, and expected frequencies. Make sure you have exactly four variables on the left side of the assignment.
help me debug the chi2_contingency unpacking in my demographics table function
Format and output the finalized table
The function returns a formatted string. Now you need to call it from your main execution block and write the result to a file.
- Scroll to your if __name__ == "__main__": block at the bottom of the file.
- Find the last print statement from the data summary section.
- Add the following code below it to generate and save Table 14.1:
# Step 2: Generate Table 14.1
print("\n[2/4] Generating Table 14.1 (Demographics)...")
demo_table = generate_demographics_table(trial_data)
demo_path = os.path.join(OUTPUT_DIR, "table_14_1_demographics.txt")
with open(demo_path, "w") as f:
f.write(demo_table)
print(f" Saved to {demo_path}")
What does this code do?
It calls your new generate_demographics_table function with the simulated data, then writes the returned string to output/table_14_1_demographics.txt. The console message confirms the file location after writing.
- Save generate_tlfs.py.
- Run your script by executing this command in the terminal:
python generate_tlfs.py
- You should see console output including [2/4] Generating Table 14.1 (Demographics)... followed by the saved file path.
- Verify the output file exists by running:
cat output/table_14_1_demographics.txt
You should see a formatted table with columns for Vaccine, Placebo, Total, and p-value. The p-values for age, sex, race, and BMI should all be above 0.05, confirming balanced randomization.
Table not appearing or file is empty?
Check that your output/ directory exists. The os.makedirs(OUTPUT_DIR, exist_ok=True) line near the top of the file should create it automatically.
If you see a NameError: name 'generate_demographics_table' is not defined, make sure the function definition appears above your if __name__ == "__main__": block, not inside it.
help me fix my Table 14.1 output generation
✔️ Awesome, I've got everything!
Great. Double check you have saved generate_tlfs.py and that output/table_14_1_demographics.txt contains your formatted table.
ⓧ I'd like to double check the full code
Here is the complete generate_tlfs.py file as it should look at the end of this step:
"""
generate_tlfs.py
================
Vaccine Trial VX-SAP-001: Simulated Data Generation and TLF Population
Generates Table 14.1 (Demographics) and Table 14.2 (Seroconversion Rates)
Requirements: numpy, pandas, scipy
"""
import os
import numpy as np
import pandas as pd
from scipy import stats
# =============================================================================
# CONFIGURATION
# =============================================================================
SEED = 42
N_SUBJECTS = 500
N_PER_ARM = 250
OUTPUT_DIR = "output"
os.makedirs(OUTPUT_DIR, exist_ok=True)
# =============================================================================
# SECTION 1: DATA SIMULATION
# =============================================================================
def simulate_trial_data(seed: int = SEED) -> pd.DataFrame:
"""Simulate a 500-subject Phase III vaccine immunogenicity trial dataset."""
rng = np.random.default_rng(seed)
subject_ids = [f"VX-{i:04d}" for i in range(1, N_SUBJECTS + 1)]
# Treatment assignment: 1:1 randomization
treatment = np.array(["Vaccine"] * N_PER_ARM + ["Placebo"] * N_PER_ARM)
rng.shuffle(treatment)
# Demographics
age = rng.normal(loc=42, scale=12, size=N_SUBJECTS).clip(18, 65).round(0).astype(int)
sex = rng.choice(["Male", "Female"], size=N_SUBJECTS, p=[0.52, 0.48])
race = rng.choice(
["White", "Black", "Asian", "Other"],
size=N_SUBJECTS,
p=[0.60, 0.18, 0.15, 0.07],
)
bmi = rng.normal(loc=27.5, scale=4.5, size=N_SUBJECTS).clip(18, 45).round(1)
# Baseline antibody titres (log-normal, low pre-existing immunity)
baseline_titre = rng.lognormal(mean=2.5, sigma=0.8, size=N_SUBJECTS).round(1)
# Post-vaccination titres at Day 28
# Vaccine arm: substantial response (mean ~8-fold rise)
# Placebo arm: minimal change (noise only)
fold_rise = np.where(
treatment == "Vaccine",
rng.lognormal(mean=2.1, sigma=0.7, size=N_SUBJECTS), # ~8x geometric mean
rng.lognormal(mean=0.2, sigma=0.3, size=N_SUBJECTS), # ~1.2x (noise)
)
day28_titre = (baseline_titre * fold_rise).round(1)
# Seroconversion: >= 4-fold rise from baseline
seroconversion = (day28_titre / baseline_titre >= 4.0).astype(int)
# Protocol deviations (~10% of subjects)
has_deviation = rng.choice([0, 1], size=N_SUBJECTS, p=[0.90, 0.10])
# Population flags
itt_flag = np.ones(N_SUBJECTS, dtype=int) # All randomized + dosed
pp_flag = ((has_deviation == 0) & (itt_flag == 1)).astype(int)
df = pd.DataFrame(
{
"subject_id": subject_ids,
"treatment": treatment,
"age": age,
"sex": sex,
"race": race,
"bmi": bmi,
"baseline_titre": baseline_titre,
"day28_titre": day28_titre,
"fold_rise": (day28_titre / baseline_titre).round(2),
"seroconversion": seroconversion,
"itt_flag": itt_flag,
"pp_flag": pp_flag,
"protocol_deviation": has_deviation,
}
)
return df
# =============================================================================
# SECTION 2: STATISTICAL HELPER FUNCTIONS
# =============================================================================
def clopper_pearson_ci(x: int, n: int, alpha: float = 0.05) -> tuple:
"""
Compute Clopper-Pearson (exact) confidence interval for a binomial proportion.
Parameters
----------
x : int
Number of successes (responders)
n : int
Number of trials (total subjects)
alpha : float
Significance level (default 0.05 for 95% CI)
Returns
-------
tuple of (lower, upper) bounds
"""
if n == 0:
return (0.0, 0.0)
if x == 0:
lower = 0.0
else:
lower = stats.beta.ppf(alpha / 2, x, n - x + 1)
if x == n:
upper = 1.0
else:
upper = stats.beta.ppf(1 - alpha / 2, x + 1, n - x)
return (lower, upper)
def format_n_pct(n: int, total: int) -> str:
"""Format count and percentage as 'n (xx.x%)'."""
pct = 100.0 * n / total if total > 0 else 0.0
return f"{n} ({pct:.1f}%)"
def format_mean_sd(values: pd.Series) -> str:
"""Format mean and SD as 'mean (SD)'."""
return f"{values.mean():.1f} ({values.std():.1f})"
# =============================================================================
# SECTION 3: TABLE 14.1 - DEMOGRAPHICS
# =============================================================================
def generate_demographics_table(df: pd.DataFrame) -> str:
"""
Generate Table 14.1: Summary of Demographic and Baseline Characteristics.
Uses ITT population.
"""
itt = df[df["itt_flag"] == 1].copy()
vaccine = itt[itt["treatment"] == "Vaccine"]
placebo = itt[itt["treatment"] == "Placebo"]
n_vax = len(vaccine)
n_pbo = len(placebo)
n_total = len(itt)
lines = []
lines.append("=" * 85)
lines.append("Table 14.1: Summary of Demographic and Baseline Characteristics")
lines.append(" (Intent-to-Treat Population)")
lines.append("=" * 85)
lines.append(
f"{'Characteristic':<25} {'Vaccine':<20} {'Placebo':<20} {'Total':<15} {'p-value':<10}"
)
lines.append(f"{'':25} {'(N=' + str(n_vax) + ')':<20} {'(N=' + str(n_pbo) + ')':<20} {'(N=' + str(n_total) + ')':<15} {'':<10}")
lines.append("-" * 85)
# Age (continuous)
age_vax = format_mean_sd(vaccine["age"])
age_pbo = format_mean_sd(placebo["age"])
age_tot = format_mean_sd(itt["age"])
_, age_p = stats.ttest_ind(vaccine["age"], placebo["age"])
lines.append(f"{'Age (years), mean (SD)':<25} {age_vax:<20} {age_pbo:<20} {age_tot:<15} {age_p:.4f}")
# Sex (categorical)
lines.append(f"{'Sex, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for sex_cat in ["Male", "Female"]:
n_v = (vaccine["sex"] == sex_cat).sum()
n_p = (placebo["sex"] == sex_cat).sum()
n_t = (itt["sex"] == sex_cat).sum()
lines.append(
f" {sex_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
# Chi-square for sex
sex_table = pd.crosstab(itt["treatment"], itt["sex"])
chi2_sex, p_sex, _, _ = stats.chi2_contingency(sex_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_sex:.4f}")
# Race (categorical)
lines.append(f"{'Race, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for race_cat in ["White", "Black", "Asian", "Other"]:
n_v = (vaccine["race"] == race_cat).sum()
n_p = (placebo["race"] == race_cat).sum()
n_t = (itt["race"] == race_cat).sum()
lines.append(
f" {race_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
race_table = pd.crosstab(itt["treatment"], itt["race"])
chi2_race, p_race, _, _ = stats.chi2_contingency(race_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_race:.4f}")
# BMI (continuous)
bmi_vax = format_mean_sd(vaccine["bmi"])
bmi_pbo = format_mean_sd(placebo["bmi"])
bmi_tot = format_mean_sd(itt["bmi"])
_, bmi_p = stats.ttest_ind(vaccine["bmi"], placebo["bmi"])
lines.append(f"{'BMI (kg/m2), mean (SD)':<25} {bmi_vax:<20} {bmi_pbo:<20} {bmi_tot:<15} {bmi_p:.4f}")
lines.append("-" * 85)
lines.append("Note: p-values from independent t-test (continuous) or Chi-square test (categorical).")
lines.append(f"Source: generate_tlfs.py | Executed: {pd.Timestamp.now().strftime('%Y-%m-%d')}")
lines.append("=" * 85)
return "\n".join(lines)
# =============================================================================
# MAIN EXECUTION
# =============================================================================
if __name__ == "__main__":
print("=" * 60)
print("VX-SAP-001: Generating TLFs for Vaccine Immunogenicity Trial")
print("=" * 60)
# Step 1: Simulate data
print("\n[1/4] Simulating trial data (N=500)...")
trial_data = simulate_trial_data()
trial_data.to_csv(os.path.join(OUTPUT_DIR, "trial_data.csv"), index=False)
print(f" Dataset saved to {OUTPUT_DIR}/trial_data.csv")
# Print summary
print("\n --- Data Summary ---")
print(f" ITT Population: {trial_data['itt_flag'].sum()}")
print(f" PP Population: {trial_data['pp_flag'].sum()}")
vax_data = trial_data[trial_data["treatment"] == "Vaccine"]
pbo_data = trial_data[trial_data["treatment"] == "Placebo"]
print(f" Vaccine arm: {len(vax_data)} subjects")
print(f" Placebo arm: {len(pbo_data)} subjects")
print(
f" Seroconversion: Vaccine={vax_data['seroconversion'].mean()*100:.1f}%, "
f"Placebo={pbo_data['seroconversion'].mean()*100:.1f}%"
)
# Step 2: Generate Table 14.1
print("\n[2/4] Generating Table 14.1 (Demographics)...")
demo_table = generate_demographics_table(trial_data)
demo_path = os.path.join(OUTPUT_DIR, "table_14_1_demographics.txt")
with open(demo_path, "w") as f:
f.write(demo_table)
print(f" Saved to {demo_path}")
print("\n" + "=" * 60)
print("TLF generation complete. All outputs in ./output/")
print("=" * 60)
Your first clinical table is complete. Next up, you will populate Table 14.2 with the primary efficacy analysis. This is the table that answers the trial's central question: does the vaccine induce significantly higher seroconversion than placebo?
Populate the Primary Efficacy Table
Your demographics table proves the two treatment arms are balanced at baseline. Now it is time to answer the trial's primary question: does the vaccine produce a significantly higher seroconversion rate than placebo?
In this step you will build the efficacy analysis function that computes seroconversion rates with exact confidence intervals, runs the formal hypothesis test, and formats a print-ready Table 14.2. You will also wire up the full __main__ block so the entire pipeline runs end-to-end with a single command.
In this step, get ready to:
- Calculate seroconversion rates and Clopper-Pearson confidence intervals for each arm.
- Run Fisher's Exact test as the primary hypothesis test.
- Format and output the finalized efficacy table to a text file.
Calculate seroconversion rates and confidence intervals
The primary endpoint is binary: each subject either achieved a 4-fold rise in antibody titre (seroconverted) or did not. Your function needs to count responders per arm, compute proportions, and wrap each proportion in a Clopper-Pearson exact 95% CI.
- In generate_tlfs.py, scroll to the bottom of the file (below the generate_demographics_table function).
- Add the following generate_efficacy_table function:
# =============================================================================
# SECTION 4: TABLE 14.2 - SEROCONVERSION RATES
# =============================================================================
def generate_efficacy_table(df: pd.DataFrame) -> str:
"""
Generate Table 14.2: Seroconversion Rates at Day 28.
Uses ITT population. Fisher's Exact test for primary comparison.
"""
itt = df[df["itt_flag"] == 1].copy()
vaccine = itt[itt["treatment"] == "Vaccine"]
placebo = itt[itt["treatment"] == "Placebo"]
# Seroconversion counts
vax_resp = vaccine["seroconversion"].sum()
vax_n = len(vaccine)
pbo_resp = placebo["seroconversion"].sum()
pbo_n = len(placebo)
# Proportions
vax_rate = vax_resp / vax_n
pbo_rate = pbo_resp / pbo_n
diff = vax_rate - pbo_rate
# Clopper-Pearson CIs
vax_ci = clopper_pearson_ci(vax_resp, vax_n)
pbo_ci = clopper_pearson_ci(pbo_resp, pbo_n)
# Fisher's Exact Test
contingency = np.array(
[[vax_resp, vax_n - vax_resp], [pbo_resp, pbo_n - pbo_resp]]
)
fisher_result = stats.fisher_exact(contingency)
fisher_p = fisher_result.pvalue
# Chi-square test (supportive)
chi2_result = stats.chi2_contingency(contingency, correction=False)
chi2_p = chi2_result.pvalue
lines = []
lines.append("=" * 85)
lines.append("Table 14.2: Seroconversion Rates at Day 28")
lines.append(" (Intent-to-Treat Population)")
lines.append("=" * 85)
lines.append(f"{'':30} {'Vaccine':<25} {'Placebo':<25}")
lines.append(f"{'':30} {'(N=' + str(vax_n) + ')':<25} {'(N=' + str(pbo_n) + ')':<25}")
lines.append("-" * 85)
lines.append(
f"{'Responders, n/N':<30} {str(vax_resp) + '/' + str(vax_n):<25} {str(pbo_resp) + '/' + str(pbo_n):<25}"
)
lines.append(
f"{'Seroconversion Rate (%)':<30} {vax_rate*100:.1f}{'':20} {pbo_rate*100:.1f}"
)
lines.append(
f"{'95% CI (Clopper-Pearson)':<30} ({vax_ci[0]*100:.1f}, {vax_ci[1]*100:.1f}){'':13} ({pbo_ci[0]*100:.1f}, {pbo_ci[1]*100:.1f})"
)
lines.append("-" * 85)
lines.append(f"{'Treatment Difference (V-P)':<30} {diff*100:.1f}%")
lines.append(f"{'Fisher Exact Test p-value':<30} {'<0.0001' if fisher_p < 0.0001 else f'{fisher_p:.4f}'}")
lines.append(f"{'Chi-square Test p-value [a]':<30} {'<0.0001' if chi2_p < 0.0001 else f'{chi2_p:.4f}'}")
lines.append("-" * 85)
lines.append("Note: Seroconversion defined as >= 4-fold rise in antibody titre from baseline.")
lines.append(" 95% CIs computed using the Clopper-Pearson (exact) method.")
lines.append("[a] Pearson Chi-square without continuity correction (supportive analysis).")
lines.append(f"Source: generate_tlfs.py | Executed: {pd.Timestamp.now().strftime('%Y-%m-%d')}")
lines.append("=" * 85)
return "\n".join(lines)
What does this code do?
This function performs the complete primary efficacy analysis in four stages:
- Filters the ITT population and splits into Vaccine and Placebo arms, then counts seroconversion responders in each.
- Calls your clopper_pearson_ci() helper to compute exact 95% confidence intervals around each arm's proportion.
- Builds a 2x2 contingency table (Responder/Non-responder x Vaccine/Placebo) and passes it to stats.fisher_exact for the primary hypothesis test and stats.chi2_contingency as a supportive analysis.
- Assembles the results into an 85-character wide formatted table string with proper clinical reporting conventions.
Perform the primary hypothesis test
The SAP specifies Fisher's Exact test as the primary comparison for seroconversion rates. This is the standard approach for vaccine immunogenicity trials.
Why Fisher's Exact test instead of Chi-square?
Fisher's Exact test computes the exact probability of observing the data under the null hypothesis. It makes no large-sample approximation, so it remains valid even when cell counts are small.
The Chi-square test is included as a supportive analysis (footnoted as [a] in the table) because regulatory reviewers expect to see both when expected cell frequencies exceed 5. This is standard practice in FDA statistical reviews of vaccine submissions.
What is a Clopper-Pearson confidence interval?
The Clopper-Pearson method produces an "exact" confidence interval for a binomial proportion. It uses the relationship between the binomial distribution and the Beta distribution to find bounds that guarantee at least 95% coverage.
In your code, scipy.stats.beta.ppf computes the inverse CDF of the Beta distribution at the alpha/2 and 1-alpha/2 quantiles. Regulatory agencies prefer this method because it is conservative and never produces below-nominal coverage.
Format and output the finalized efficacy table
The function is ready, but nothing calls it yet. You need a __main__ block that orchestrates the full pipeline: simulate data, generate both tables, and write the output files.
- At the very bottom of generate_tlfs.py, below the generate_efficacy_table function, add the main execution block:
# =============================================================================
# MAIN EXECUTION
# =============================================================================
if __name__ == "__main__":
print("=" * 60)
print("VX-SAP-001: Generating TLFs for Vaccine Immunogenicity Trial")
print("=" * 60)
# Step 1: Simulate data
print("\n[1/4] Simulating trial data (N=500)...")
trial_data = simulate_trial_data()
trial_data.to_csv(os.path.join(OUTPUT_DIR, "trial_data.csv"), index=False)
print(f" Dataset saved to {OUTPUT_DIR}/trial_data.csv")
# Print summary
print("\n --- Data Summary ---")
print(f" ITT Population: {trial_data['itt_flag'].sum()}")
print(f" PP Population: {trial_data['pp_flag'].sum()}")
vax_data = trial_data[trial_data["treatment"] == "Vaccine"]
pbo_data = trial_data[trial_data["treatment"] == "Placebo"]
print(f" Vaccine arm: {len(vax_data)} subjects")
print(f" Placebo arm: {len(pbo_data)} subjects")
print(
f" Seroconversion: Vaccine={vax_data['seroconversion'].mean()*100:.1f}%, "
f"Placebo={pbo_data['seroconversion'].mean()*100:.1f}%"
)
# Step 2: Generate Table 14.1
print("\n[2/4] Generating Table 14.1 (Demographics)...")
demo_table = generate_demographics_table(trial_data)
demo_path = os.path.join(OUTPUT_DIR, "table_14_1_demographics.txt")
with open(demo_path, "w") as f:
f.write(demo_table)
print(f" Saved to {demo_path}")
# Step 3: Generate Table 14.2
print("\n[3/4] Generating Table 14.2 (Seroconversion Rates)...")
efficacy_table = generate_efficacy_table(trial_data)
efficacy_path = os.path.join(OUTPUT_DIR, "table_14_2_seroconversion.txt")
with open(efficacy_path, "w") as f:
f.write(efficacy_table)
print(f" Saved to {efficacy_path}")
# Step 4: Print tables to console
print("\n[4/4] Displaying generated tables...\n")
print(demo_table)
print()
print(efficacy_table)
print("\n" + "=" * 60)
print("TLF generation complete. All outputs in ./output/")
print("=" * 60)
What does the main block do?
- Calls simulate_trial_data() to generate the 500-subject dataset and saves it as trial_data.csv.
- Prints a quick summary of population sizes and seroconversion rates so you can sanity-check the data.
- Runs both table-generation functions and writes their output to text files in the output/ directory.
- Prints both tables to the console so you can review them immediately.
- Save generate_tlfs.py.
- Run the script by executing this command in your terminal:
python generate_tlfs.py
You should see the console print a data summary followed by both Table 14.1 and Table 14.2. Table 14.2 should show a Fisher Exact p-value of <0.0001, confirming a statistically significant difference between arms.
- Verify the output files exist by running:
ls output/
You should see table_14_1_demographics.txt, table_14_2_seroconversion.txt, and trial_data.csv in the listing.
Seeing an error when running the script?
- If you see NameError: name 'generate_efficacy_table' is not defined, make sure the function is defined above the if __name__ == "__main__" block, not inside it.
- If you see an indentation error, check that the if __name__ line starts at column 0 with no leading spaces.
- If fisher_exact returns unexpected results, confirm you imported from scipy import stats at the top of the file.
Help me debug my generate_tlfs.py script
✔️ Awesome, I've got everything!
Great. Double check you have saved generate_tlfs.py and that both output files exist in output/.
ⓧ I'd like to double check the full code
Here is the complete generate_tlfs.py file as it should look at the end of this step:
"""
generate_tlfs.py
================
Vaccine Trial VX-SAP-001: Simulated Data Generation and TLF Population
Generates Table 14.1 (Demographics) and Table 14.2 (Seroconversion Rates)
Requirements: numpy, pandas, scipy
"""
import os
import numpy as np
import pandas as pd
from scipy import stats
# =============================================================================
# CONFIGURATION
# =============================================================================
SEED = 42
N_SUBJECTS = 500
N_PER_ARM = 250
OUTPUT_DIR = "output"
os.makedirs(OUTPUT_DIR, exist_ok=True)
# =============================================================================
# SECTION 1: DATA SIMULATION
# =============================================================================
def simulate_trial_data(seed: int = SEED) -> pd.DataFrame:
"""Simulate a 500-subject Phase III vaccine immunogenicity trial dataset."""
rng = np.random.default_rng(seed)
subject_ids = [f"VX-{i:04d}" for i in range(1, N_SUBJECTS + 1)]
# Treatment assignment: 1:1 randomization
treatment = np.array(["Vaccine"] * N_PER_ARM + ["Placebo"] * N_PER_ARM)
rng.shuffle(treatment)
# Demographics
age = rng.normal(loc=42, scale=12, size=N_SUBJECTS).clip(18, 65).round(0).astype(int)
sex = rng.choice(["Male", "Female"], size=N_SUBJECTS, p=[0.52, 0.48])
race = rng.choice(
["White", "Black", "Asian", "Other"],
size=N_SUBJECTS,
p=[0.60, 0.18, 0.15, 0.07],
)
bmi = rng.normal(loc=27.5, scale=4.5, size=N_SUBJECTS).clip(18, 45).round(1)
# Baseline antibody titres (log-normal, low pre-existing immunity)
baseline_titre = rng.lognormal(mean=2.5, sigma=0.8, size=N_SUBJECTS).round(1)
# Post-vaccination titres at Day 28
# Vaccine arm: substantial response (mean ~8-fold rise)
# Placebo arm: minimal change (noise only)
fold_rise = np.where(
treatment == "Vaccine",
rng.lognormal(mean=2.1, sigma=0.7, size=N_SUBJECTS), # ~8x geometric mean
rng.lognormal(mean=0.2, sigma=0.3, size=N_SUBJECTS), # ~1.2x (noise)
)
day28_titre = (baseline_titre * fold_rise).round(1)
# Seroconversion: >= 4-fold rise from baseline
seroconversion = (day28_titre / baseline_titre >= 4.0).astype(int)
# Protocol deviations (~10% of subjects)
has_deviation = rng.choice([0, 1], size=N_SUBJECTS, p=[0.90, 0.10])
# Population flags
itt_flag = np.ones(N_SUBJECTS, dtype=int) # All randomized + dosed
pp_flag = ((has_deviation == 0) & (itt_flag == 1)).astype(int)
df = pd.DataFrame(
{
"subject_id": subject_ids,
"treatment": treatment,
"age": age,
"sex": sex,
"race": race,
"bmi": bmi,
"baseline_titre": baseline_titre,
"day28_titre": day28_titre,
"fold_rise": (day28_titre / baseline_titre).round(2),
"seroconversion": seroconversion,
"itt_flag": itt_flag,
"pp_flag": pp_flag,
"protocol_deviation": has_deviation,
}
)
return df
# =============================================================================
# SECTION 2: STATISTICAL HELPER FUNCTIONS
# =============================================================================
def clopper_pearson_ci(x: int, n: int, alpha: float = 0.05) -> tuple:
"""
Compute Clopper-Pearson (exact) confidence interval for a binomial proportion.
Parameters
----------
x : int
Number of successes (responders)
n : int
Number of trials (total subjects)
alpha : float
Significance level (default 0.05 for 95% CI)
Returns
-------
tuple of (lower, upper) bounds
"""
if n == 0:
return (0.0, 0.0)
if x == 0:
lower = 0.0
else:
lower = stats.beta.ppf(alpha / 2, x, n - x + 1)
if x == n:
upper = 1.0
else:
upper = stats.beta.ppf(1 - alpha / 2, x + 1, n - x)
return (lower, upper)
def format_n_pct(n: int, total: int) -> str:
"""Format count and percentage as 'n (xx.x%)'."""
pct = 100.0 * n / total if total > 0 else 0.0
return f"{n} ({pct:.1f}%)"
def format_mean_sd(values: pd.Series) -> str:
"""Format mean and SD as 'mean (SD)'."""
return f"{values.mean():.1f} ({values.std():.1f})"
# =============================================================================
# SECTION 3: TABLE 14.1 - DEMOGRAPHICS
# =============================================================================
def generate_demographics_table(df: pd.DataFrame) -> str:
"""
Generate Table 14.1: Summary of Demographic and Baseline Characteristics.
Uses ITT population.
"""
itt = df[df["itt_flag"] == 1].copy()
vaccine = itt[itt["treatment"] == "Vaccine"]
placebo = itt[itt["treatment"] == "Placebo"]
n_vax = len(vaccine)
n_pbo = len(placebo)
n_total = len(itt)
lines = []
lines.append("=" * 85)
lines.append("Table 14.1: Summary of Demographic and Baseline Characteristics")
lines.append(" (Intent-to-Treat Population)")
lines.append("=" * 85)
lines.append(
f"{'Characteristic':<25} {'Vaccine':<20} {'Placebo':<20} {'Total':<15} {'p-value':<10}"
)
lines.append(f"{'':25} {'(N=' + str(n_vax) + ')':<20} {'(N=' + str(n_pbo) + ')':<20} {'(N=' + str(n_total) + ')':<15} {'':<10}")
lines.append("-" * 85)
# Age (continuous)
age_vax = format_mean_sd(vaccine["age"])
age_pbo = format_mean_sd(placebo["age"])
age_tot = format_mean_sd(itt["age"])
_, age_p = stats.ttest_ind(vaccine["age"], placebo["age"])
lines.append(f"{'Age (years), mean (SD)':<25} {age_vax:<20} {age_pbo:<20} {age_tot:<15} {age_p:.4f}")
# Sex (categorical)
lines.append(f"{'Sex, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for sex_cat in ["Male", "Female"]:
n_v = (vaccine["sex"] == sex_cat).sum()
n_p = (placebo["sex"] == sex_cat).sum()
n_t = (itt["sex"] == sex_cat).sum()
lines.append(
f" {sex_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
# Chi-square for sex
sex_table = pd.crosstab(itt["treatment"], itt["sex"])
chi2_sex, p_sex, _, _ = stats.chi2_contingency(sex_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_sex:.4f}")
# Race (categorical)
lines.append(f"{'Race, n (%)':<25} {'':20} {'':20} {'':15} {''}")
for race_cat in ["White", "Black", "Asian", "Other"]:
n_v = (vaccine["race"] == race_cat).sum()
n_p = (placebo["race"] == race_cat).sum()
n_t = (itt["race"] == race_cat).sum()
lines.append(
f" {race_cat:<23} {format_n_pct(n_v, n_vax):<20} {format_n_pct(n_p, n_pbo):<20} {format_n_pct(n_t, n_total):<15}"
)
race_table = pd.crosstab(itt["treatment"], itt["race"])
chi2_race, p_race, _, _ = stats.chi2_contingency(race_table)
lines.append(f"{'':25} {'':20} {'':20} {'':15} {p_race:.4f}")
# BMI (continuous)
bmi_vax = format_mean_sd(vaccine["bmi"])
bmi_pbo = format_mean_sd(placebo["bmi"])
bmi_tot = format_mean_sd(itt["bmi"])
_, bmi_p = stats.ttest_ind(vaccine["bmi"], placebo["bmi"])
lines.append(f"{'BMI (kg/m2), mean (SD)':<25} {bmi_vax:<20} {bmi_pbo:<20} {bmi_tot:<15} {bmi_p:.4f}")
lines.append("-" * 85)
lines.append("Note: p-values from independent t-test (continuous) or Chi-square test (categorical).")
lines.append(f"Source: generate_tlfs.py | Executed: {pd.Timestamp.now().strftime('%Y-%m-%d')}")
lines.append("=" * 85)
return "\n".join(lines)
# =============================================================================
# SECTION 4: TABLE 14.2 - SEROCONVERSION RATES
# =============================================================================
def generate_efficacy_table(df: pd.DataFrame) -> str:
"""
Generate Table 14.2: Seroconversion Rates at Day 28.
Uses ITT population. Fisher's Exact test for primary comparison.
"""
itt = df[df["itt_flag"] == 1].copy()
vaccine = itt[itt["treatment"] == "Vaccine"]
placebo = itt[itt["treatment"] == "Placebo"]
# Seroconversion counts
vax_resp = vaccine["seroconversion"].sum()
vax_n = len(vaccine)
pbo_resp = placebo["seroconversion"].sum()
pbo_n = len(placebo)
# Proportions
vax_rate = vax_resp / vax_n
pbo_rate = pbo_resp / pbo_n
diff = vax_rate - pbo_rate
# Clopper-Pearson CIs
vax_ci = clopper_pearson_ci(vax_resp, vax_n)
pbo_ci = clopper_pearson_ci(pbo_resp, pbo_n)
# Fisher's Exact Test
contingency = np.array(
[[vax_resp, vax_n - vax_resp], [pbo_resp, pbo_n - pbo_resp]]
)
fisher_result = stats.fisher_exact(contingency)
fisher_p = fisher_result.pvalue
# Chi-square test (supportive)
chi2_result = stats.chi2_contingency(contingency, correction=False)
chi2_p = chi2_result.pvalue
lines = []
lines.append("=" * 85)
lines.append("Table 14.2: Seroconversion Rates at Day 28")
lines.append(" (Intent-to-Treat Population)")
lines.append("=" * 85)
lines.append(f"{'':30} {'Vaccine':<25} {'Placebo':<25}")
lines.append(f"{'':30} {'(N=' + str(vax_n) + ')':<25} {'(N=' + str(pbo_n) + ')':<25}")
lines.append("-" * 85)
lines.append(
f"{'Responders, n/N':<30} {str(vax_resp) + '/' + str(vax_n):<25} {str(pbo_resp) + '/' + str(pbo_n):<25}"
)
lines.append(
f"{'Seroconversion Rate (%)':<30} {vax_rate*100:.1f}{'':20} {pbo_rate*100:.1f}"
)
lines.append(
f"{'95% CI (Clopper-Pearson)':<30} ({vax_ci[0]*100:.1f}, {vax_ci[1]*100:.1f}){'':13} ({pbo_ci[0]*100:.1f}, {pbo_ci[1]*100:.1f})"
)
lines.append("-" * 85)
lines.append(f"{'Treatment Difference (V-P)':<30} {diff*100:.1f}%")
lines.append(f"{'Fisher Exact Test p-value':<30} {'<0.0001' if fisher_p < 0.0001 else f'{fisher_p:.4f}'}")
lines.append(f"{'Chi-square Test p-value [a]':<30} {'<0.0001' if chi2_p < 0.0001 else f'{chi2_p:.4f}'}")
lines.append("-" * 85)
lines.append("Note: Seroconversion defined as >= 4-fold rise in antibody titre from baseline.")
lines.append(" 95% CIs computed using the Clopper-Pearson (exact) method.")
lines.append("[a] Pearson Chi-square without continuity correction (supportive analysis).")
lines.append(f"Source: generate_tlfs.py | Executed: {pd.Timestamp.now().strftime('%Y-%m-%d')}")
lines.append("=" * 85)
return "\n".join(lines)
# =============================================================================
# MAIN EXECUTION
# =============================================================================
if __name__ == "__main__":
print("=" * 60)
print("VX-SAP-001: Generating TLFs for Vaccine Immunogenicity Trial")
print("=" * 60)
# Step 1: Simulate data
print("\n[1/4] Simulating trial data (N=500)...")
trial_data = simulate_trial_data()
trial_data.to_csv(os.path.join(OUTPUT_DIR, "trial_data.csv"), index=False)
print(f" Dataset saved to {OUTPUT_DIR}/trial_data.csv")
# Print summary
print("\n --- Data Summary ---")
print(f" ITT Population: {trial_data['itt_flag'].sum()}")
print(f" PP Population: {trial_data['pp_flag'].sum()}")
vax_data = trial_data[trial_data["treatment"] == "Vaccine"]
pbo_data = trial_data[trial_data["treatment"] == "Placebo"]
print(f" Vaccine arm: {len(vax_data)} subjects")
print(f" Placebo arm: {len(pbo_data)} subjects")
print(
f" Seroconversion: Vaccine={vax_data['seroconversion'].mean()*100:.1f}%, "
f"Placebo={pbo_data['seroconversion'].mean()*100:.1f}%"
)
# Step 2: Generate Table 14.1
print("\n[2/4] Generating Table 14.1 (Demographics)...")
demo_table = generate_demographics_table(trial_data)
demo_path = os.path.join(OUTPUT_DIR, "table_14_1_demographics.txt")
with open(demo_path, "w") as f:
f.write(demo_table)
print(f" Saved to {demo_path}")
# Step 3: Generate Table 14.2
print("\n[3/4] Generating Table 14.2 (Seroconversion Rates)...")
efficacy_table = generate_efficacy_table(trial_data)
efficacy_path = os.path.join(OUTPUT_DIR, "table_14_2_seroconversion.txt")
with open(efficacy_path, "w") as f:
f.write(efficacy_table)
print(f" Saved to {efficacy_path}")
# Step 4: Print tables to console
print("\n[4/4] Displaying generated tables...\n")
print(demo_table)
print()
print(efficacy_table)
print("\n" + "=" * 60)
print("TLF generation complete. All outputs in ./output/")
print("=" * 60)
Secret mission
Add a Time-to-Seroconversion Kaplan-Meier Figure
Your SAP specifies Figure 14.1, but you haven't built it yet. In this secret mission, you'll generate a publication-quality Kaplan-Meier cumulative incidence figure showing time-to-seroconversion by treatment arm, complete with a log-rank test p-value and an at-risk table below the x-axis.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Local project folder: vaccine_sap_portfolio/ on your Desktop.
- Python packages: pandas, numpy, scipy, and matplotlib.
- Generated output files in output/ (CSV data, formatted tables, PDF figure).
Keep everything running
No action needed. Choose this if you plan to extend the pipeline further, add more tables, or refine your SAP document.
- Your vaccine_sap_portfolio/ folder stays on your Desktop with all generated outputs.
- The installed Python packages (pandas, numpy, scipy, matplotlib) remain available for other projects.
- You can re-run python generate_tlfs.py at any time to regenerate the tables and figure.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files will stay on disk until you return.
- No background services or servers are running for this project.
- Your files, packages, and outputs remain exactly as you left them.
- When you return, navigate back to vaccine_sap_portfolio/ and pick up where you stopped.
Delete - I don't want to use this again
Remove all project files and uninstall the Python packages to start fresh.
- Delete the project folder by running:
macOS
cd ~/Desktop
rm -rf vaccine_sap_portfolio
Windows
cd ~/Desktop
rmdir /s /q vaccine_sap_portfolio
- Uninstall the Python packages by running:
pip uninstall pandas numpy scipy matplotlib -y
- Confirm the folder is gone by running ls ~/Desktop and checking that vaccine_sap_portfolio no longer appears.
Nice Work!
Nice Work!
Congratulations! You have built a complete biostatistics portfolio artifact: a regulatory-grade Statistical Analysis Plan paired with an automated Python pipeline that transforms simulated patient data into clinical-ready Tables, Listings, and Figures.
You've learned how to:
- Author a structured Statistical Analysis Plan aligned with ICH E9 guidelines for a Phase III vaccine immunogenicity trial, including estimand specification per E9(R1).
- Simulate a 500-subject clinical trial dataset and derive ITT and Per-Protocol analysis populations, then programmatically populate clinical-ready tables with proper statistical tests.
- Apply Fisher's Exact test and Clopper-Pearson exact confidence intervals for seroconversion analysis using pandas and SciPy, producing reproducible outputs from raw data to final tables.
- Secret Mission: Create a publication-quality Kaplan-Meier time-to-seroconversion figure with an at-risk table and log-rank test annotation.
Ready to quiz yourself?