Primary Objectives
To replicate the power simulation results for the primary endpoint using the prespecified primary analysis methodology described in NCT04435626 [Protocol title: A Multicenter, Randomized, Double-blind, Parallel-group, Placebo-controlled Study to Evaluate the Efficacy and Safety of Finerenone on Morbidity and Mortality in Participants With Heart Failure (NYHA II-IV) and Left Ventricular Ejection Fraction ≥ 40% (LVEF ≥ 40%)].
Confirm expected event counts (with approximately 5500 randomized participants, it is expected to observe approximately 1310 first events and approximately 2375 total events)
Verify the observed 19% rate reduction (rate ratio = 0.81) using Andersen-Gill model.
Study Design Parameters
Total study duration: 42 months
Recruitment period: 24 months
Randomization ratio: 1:1 (finerenone:placebo)
Sample size: 5,500 participants (2,750 per arm)
Follow-up period: Up to 42 months
Event Rate Parameters (Placebo Arm)
CV death rate: 0.005125 events/month/participant (annualized: 6.15%)
HF event rate: 0.0182 events/month/participant (annualized: 21.84%)
Non-CV death rate: 0.0016 events/month/participant (annualized: 1.92%)
Expected first composite event rate: 12.5 per 100 patient-years
Expected CV death event rate: 4.6 per 100 patient-years
Expected CV death proportion: ~70% of all deaths (Non-CV deaths are simulated [by Bayer] as exponentially distributed censoring events with a rate of 0.0016 non-CV deaths/month per participant, leading to approximately 70% of all deaths being due to CV causes)
Recurrent Event Model
Distribution: Gamma frailty model (see sections below for mathematical formula)
Frailty variance: 5.0 (see sections below for random number generation)
Expected total-to-first event ratio: 1.8
- Total-to-first event ratio = Total number of events / Number of first events.
Treatment Effects (Finerenone Arm)
CV death hazard ratio: 0.80
HF event rate ratio: 0.75
Primary composite rate ratio: 0.81 (19% reduction)
Dropout/Discontinuation
Annual discontinuation rate: 5% (both arms)
Post-discontinuation event rates: Finerenone participants revert to placebo rates; placebo participants unchanged
Observation status: Participants remain in follow-up after discontinuation
Participant-Level Data Generation Plan
Step 1: Enrollment
- Generate enrollment times uniformly over 24 months
Step 2: Randomization
- Assign treatment (1:1) using simple randomization (Note: if we have the assumptions of prevalences of each stratification factor, we can simulate a dummy randomization list using block randomization methodology. At the stage of design, we can ignore such stratification variables).
Step 3: Generate Individual Frailty
Step 4: Generate Event Times
For Placebo Arm:
CV deaths: Exponential with individual rate = 0.005125 × frailty
HF events: Recurrent events from gamma frailty process
Rate: λ_HF = 0.0182 × frailty
Generate multiple events per participant until censoring
Use renewal process (or another more complex method as an option: non-homogeneous Poisson process) with frailty. Bayer used a non-homogeneous Poisson process in their work per the study protocol, which assumes a constant event rate across time. See Appendix 1 for further information.
Non-CV deaths: Exponential with rate = 0.0016 (independent of frailty)
For Finerenone Arm:
CV deaths: Exponential with individual rate = 0.005125 × 0.80 × frailty
HF events: Gamma frailty process with rate = 0.0182 × 0.75 × frailty
Non-CV deaths: Same as placebo
Overall composite rate ratio: Approximately 0.81 (19% reduction)
Step 5: Generate Discontinuation Times
Generate discontinuation time: Exponential with 5% annual rate
Post-discontinuation for finerenone: Revert to placebo rates for future events
Post-discontinuation for placebo: Rates unchanged
Continue follow-up and event recording after discontinuation
Step 6: Apply Censoring
Terminal events: CV death, non-CV death
Administrative censoring: 42 months - enrollment time
After terminal event, no further HF events recorded
Record exact timing of all events and censoring
More notes for the idea of “Until Censoring” in this simulation study regarding the types of censoring:
Death (terminal event)
Administrative censoring
Treatment discontinuation (not true censoring but maybe an intercurrent event)
Participant continues to be observed
But event rate changes (Per Bayer: with finerenone participants having the same risk of events as placebo participants after discontinuation and no change in event rate for discontinuing placebo participants)
E.g. censor_time = min(cv_death_time, noncv_death_time, max_followup);
Example:
- Participant enrolled at month 5
- Max follow-up = 42 - 5 = 37 months
- During simulation: CV death at month 25
- During simulation: Non-CV death at month 40 (doesn’t happen first)
Primary Analysis Methodology
Andersen-Gill Model Specification:
\[
h_{ij}(t)\ = \ h_{0}(t)\ \times \ exp(\beta\ \times \ Treatment_{i})
\]
Where:
\(h_{ij}(t)\) = hazard for j-th event in participant i
\(h_{0}(t)\) = baseline hazard (not consider stratification)
\(\beta\) = log(rate ratio) for treatment effect
\(Treatment_{i}\) = indicator for finerenone (1) vs placebo (0)
Robust standard errors (sandwich estimator) will be used to account for correlations of event times within a participant. As shown by Lin et al. 2000, the Andersen Gill model with robust standard errors can be interpreted as a proportional rates model. After the authors of the paper, the model is also referred to as Lin, Wei, Yang and Ying (LWYY) model.
Search “LWYY model” on the internet for more details (Lin et al., 2000).
Summary Table of Key Methodologies Used in This Simulation Study
| Survival Analysis |
Exponential distribution, Competing risks, Censoring |
| Recurrent Events |
Gamma frailty, Renewal process, Andersen-Gill model |
| Inference |
Robust sandwich estimator |
| Treatment Effects |
Hazard ratio, Rate ratio |
| Other Advanced Topics |
Counting processes, Martingales, Estimating equations |
Appendix 1. Generating Multiple HF Events Per Participant
Unlike CV death (which happens at most once), HF hospitalizations can recur multiple times for the same participant. We need a method to generate:
Event 1 at time \(t₁\)
Event 2 at time \(t₂\)
Event 3 at time \(t₃\)
… until censoring
With RENEWAL PROCESS:
Each inter-event time is independently drawn from the same distribution
The process “renews” after each event
Time between events doesn’t depend on when previous events occurred.
Example: Participant \(j\) with \(\lambda_{j} = \ 0.03\) events/month, censoring at 30 months (using exponential distribution to simulate the time-to-event):
Inter-event time 1: 5.2 months → Event 1 at time 5.2
Inter-event time 2: 8.7 months → Event 2 at time 13.9 (5.2 + 8.7)
Inter-event time 3: 12.1 months → Event 3 at time 26.0 (13.9 + 12.1)
Inter-event time 4: 18.3 months → Would be at time 44.3 → CENSORED (exceeds 30)
Result: 3 HF events at times 5.2, 13.9, and 26.0 months.
With NON-HOMOGENEOUS PROCESS:
A Poisson process where the event rate can change over time: \(\lambda(t)\), e.g., after treatment discontinuation.
For example, treatment discontinuation due to lack of efficacy may create a non-homogeneous process:
Before dropout (\(t\ < \ t_{dropout}\)): \(\lambda(t)\ = \ 0.0182\ \times \ 0.75\ \times \ Frailty\) (finerenone rate)
After dropout (\(t\ \geq \ t_{dropout}\)): \(\lambda(t)\ = \ 0.0182\ \times \ Frailty\) (placebo rate)
The rate changes at time \(t_{dropout}\), making it non-homogeneous.
However, homogeneous process will keep a constant rate: \(\lambda(t)\ = \ \lambda_{j}\) for all t.
Method 1 to Generate Events using non-homo process: Thinning Algorithm
Generate events from the maximum rate over the entire period
Accept/reject each event based on \(\lambda(t)\) at that time
More complex but handles arbitrary \(\lambda(t)\)
Method 2 to Generate Events using non-homo process: Piecewise Constant Rates (Simpler)
Divide time into intervals with constant rates:
Interval 1: \([0, t_{drop-out})\) → Rate = \(\lambda ₁\)
Interval 2: \([t_{drop-out}, T_{max}]\) → Rate =\(\ \lambda ₂\)
Generate events separately in each interval using renewal process.
SAS Program (Core Parts) for Reference
/* Generate events before dropout */
current_time = 0;
do while (current_time < min(time_to_dropout, censor_time));
inter_event_time = rand('exponential') / lambda_hf_on_treatment;
current_time = current_time + inter_event_time;
if current_time < min(time_to_dropout, censor_time) then do;
hf_event_time = current_time;
output;
end;
end;
/* Generate events after dropout (if applicable) */
if time_to_dropout < censor_time then do;
current_time = time_to_dropout;
lambda_hf_post_dropout = &hf_event_rate * frailty; /* Revert to placebo rate */
do while (current_time < censor_time);
inter_event_time = rand('exponential') / lambda_hf_post_dropout;
current_time = current_time + inter_event_time;
if current_time < censor_time then do;
hf_event_time = current_time;
output;
end;
end;
end;
Appendix 2. Reference SAS Program for Power Simulation
/**************************************************************************
* purpose: generate simulation datasets for phase 3 heart failure trial
* primary endpoint: total (first and recurrent) cv death and hf events.
* tried to replicate the power simulation by NCT04435626.
* analysis: andersen-gill model with robust standard errors.
* note: stratification factors (region, lvef) not included (simplify the simulation)
* author: yupng.l (2026)
* This post presents an independent replication of the primary endpoint power simulation
* using the primary analysis methodology described in NCT04435626. The implementation
* relies exclusively on publicly available information and reflects my own interpretation.
* This work is not affiliated with or endorsed by the study sponsor or investigators.
* The views expressed here are my own.
**************************************************************************/
dm log "clear";
dm output "clear";
proc datasets lib=work memtype=data kill;
run;
quit;
title1;
footnote1;
%let n_sim = 1000; /* number of simulations */
%let n_participants = 5500; /* total sample size */
%let recruitment_months = 24; /* recruitment period */
%let total_months = 42; /* total study duration */
/* parameters */
%let cv_death_rate = 0.005125; /* cv death rate per month */
%let hf_event_rate = 0.0182; /* hf event rate per month */
%let noncv_death_rate = 0.0016; /* non-cv death rate per month */
%let frailty_variance = 5.0; /* gamma frailty variance */
%let hr_cv_death = 0.80; /* hazard ratio for cv death */
%let rr_hf_event = 0.75; /* rate ratio for hf events */
%let annual_dropout = 0.05; /* annual discontinuation rate */
/**************************************************************************
* macro: generate one simulation dataset
**************************************************************************/
%macro generate_one_sim(sim_id=);
/*** step 1: generate baseline participant data ***/
data participants_&sim_id;
call streaminit(123+&sim_id); /* set seed for reproducibility */
do participant_id = 1 to &n_participants;
/* enrollment time (uniform over recruitment period) */
enroll_time = rand('uniform') * &recruitment_months;
/* randomization 1:1 */
treatment = rand('bernoulli', 0.5); /* 0=placebo, 1=finerenone */
/* generate individual frailty from gamma() distribution */
/* alpha used as a temporary variable */
/* where theta = frailty_variance */
alpha = 1 / &frailty_variance;
frailty = rand('gamma', alpha) / alpha;
/* maximum follow-up time for this participant */
max_followup = &total_months - enroll_time;
/* treatment-specific parameters */
if treatment = 0 then
do;
/* placebo */
lambda_cv = &cv_death_rate * frailty;
lambda_hf = &hf_event_rate * frailty;
end;
else
do;
/* finerenone */
lambda_cv = &cv_death_rate * &hr_cv_death * frailty;
lambda_hf = &hf_event_rate * &rr_hf_event * frailty;
end;
lambda_noncv = &noncv_death_rate; /* independent of frailty */
/* generate discontinuation time */
dropout_rate = &annual_dropout / 12; /* monthly rate */
time_to_dropout = rand('exponential') / dropout_rate;
output;
end;
drop alpha;
run;
/*** step 2: generate cv deaths ***/
data cv_deaths_&sim_id;
set participants_&sim_id;
/* generate time to cv death */
time_to_cv_death = rand('exponential') / lambda_cv;
/* adjust if occurs after dropout (finerenone reverts to placebo) */
if treatment = 1 and time_to_dropout < time_to_cv_death then
do;
/* generate new cv death time from dropout using placebo rate */
lambda_cv_placebo = &cv_death_rate * frailty;
time_to_cv_death = time_to_dropout +
rand('exponential') / lambda_cv_placebo;
end;
/* apply administrative censoring */
if time_to_cv_death > max_followup then
do;
cv_death = 0;
cv_death_time = max_followup;
end;
else
do;
cv_death = 1;
cv_death_time = time_to_cv_death;
end;
keep participant_id treatment frailty enroll_time max_followup
time_to_dropout lambda_hf cv_death cv_death_time;
run;
/*** step 3: generate non-cv deaths ***/
data noncv_deaths_&sim_id;
set cv_deaths_&sim_id;
/* generate time to non-cv death */
time_to_noncv_death = rand('exponential') / &noncv_death_rate;
/* apply administrative censoring */
if time_to_noncv_death > max_followup then
do;
noncv_death = 0;
noncv_death_time = max_followup;
end;
else
do;
noncv_death = 1;
noncv_death_time = time_to_noncv_death;
end;
keep participant_id treatment frailty enroll_time max_followup
time_to_dropout lambda_hf
cv_death cv_death_time noncv_death noncv_death_time;
run;
/*** step 4: generate recurrent hf events ***/
data hf_events_&sim_id;
set noncv_deaths_&sim_id;
/* determine censoring time (earliest of death or admin censoring) */
censor_time = min(cv_death_time, noncv_death_time, max_followup);
/* generate recurrent hf events using renewal process */
current_time = 0;
event_num = 0;
current_lambda_hf = lambda_hf;
do while (current_time < censor_time);
/* generate next hf event time */
inter_event_time = rand('exponential') / current_lambda_hf;
current_time = current_time + inter_event_time;
/* check if event occurs before censoring */
if current_time < censor_time then
do;
event_num + 1;
hf_event_time = current_time;
/* check if dropout occurred and adjust rate if needed */
if treatment = 1 and time_to_dropout < current_time
and time_to_dropout < hf_event_time then
do;
/* revert to placebo rate after dropout */
current_lambda_hf = &hf_event_rate * frailty;
end;
output;
end;
else leave;
end;
/* keep variables needed for analysis */
keep participant_id treatment event_num hf_event_time censor_time;
run;
/*** step 5: create counting process dataset for andersen-gill ***/
/* first, add cv death as an event */
data cv_death_events_&sim_id;
set noncv_deaths_&sim_id;
where cv_death = 1;
event_num = 0; /* assign cv death as event 0 for sorting */
event_time = cv_death_time;
event_type = 'cv_death';
keep participant_id treatment event_num event_time event_type;
run;
/* add hf events */
data hf_event_list_&sim_id;
set hf_events_&sim_id;
event_time = hf_event_time;
event_type = 'hf_event';
keep participant_id treatment event_num event_time event_type;
run;
/* combine all events and sort */
data all_events_&sim_id;
set cv_death_events_&sim_id hf_event_list_&sim_id;
run;
proc sort data=all_events_&sim_id;
by participant_id event_time;
run;
/* create counting process format */
data counting_process_&sim_id;
set all_events_&sim_id;
by participant_id;
/* start time is previous event time (or 0 for first event) */
retain prev_event_time;
if first.participant_id then
do;
start_time = 0;
prev_event_time = 0;
end;
else start_time = prev_event_time;
stop_time = event_time;
event = 1; /* all rows represent events */
prev_event_time = event_time;
/* add censoring information for last observation */
if last.participant_id then
last_event = 1;
else last_event = 0;
sim_id = &sim_id;
keep sim_id participant_id treatment start_time stop_time event
event_type last_event;
run;
/* add censored observations (no events) */
data participants_no_events_&sim_id;
merge noncv_deaths_&sim_id(in=a)
all_events_&sim_id(in=b keep=participant_id);
by participant_id;
if a and not b; /* participants with no events */
sim_id = &sim_id;
start_time = 0;
stop_time = min(cv_death_time, noncv_death_time, max_followup);
event = 0;
event_type = 'censored';
last_event = 1;
keep sim_id participant_id treatment start_time stop_time event
event_type last_event;
run;
/* combine events and censored observations */
data final_dataset_&sim_id;
set counting_process_&sim_id participants_no_events_&sim_id;
run;
proc sort data=final_dataset_&sim_id;
by sim_id participant_id start_time;
run;
/*** step 6: create summary statistics dataset ***/
proc sql;
create table sim_summary_&sim_id as
select
&sim_id as sim_id,
count(distinct participant_id) as n_participants,
sum
(case
when treatment=0 then 1
else 0
end)
as n_placebo,
sum
(case
when treatment=1 then 1
else 0
end)
as n_finerenone,
sum
(case
when event=1 then 1
else 0
end)
as total_events,
sum
(case
when event=1 and treatment=0 then 1
else 0
end)
as events_placebo,
sum
(case
when event=1 and treatment=1 then 1
else 0
end)
as events_finerenone,
sum
(case
when event=1 and event_type='cv_death' then 1
else 0
end)
as cv_deaths,
sum
(case
when event=1 and event_type='hf_event' then 1
else 0
end)
as hf_events,
sum(stop_time - start_time) as total_person_months,
sum
(case
when treatment=0 then (stop_time - start_time)
else 0
end)
as person_months_placebo,
sum
(case
when treatment=1 then (stop_time - start_time)
else 0
end)
as person_months_finerenone
from final_dataset_&sim_id;
quit;
/* count first events per participant */
proc sql;
create table first_events_&sim_id as
select
participant_id,
treatment,
min(event_time) as time_to_first_event
from (
/* combine cv deaths and hf events */
select participant_id, treatment, cv_death_time as event_time
from noncv_deaths_&sim_id
where cv_death = 1
union
select participant_id, treatment, hf_event_time as event_time
from hf_events_&sim_id
)
group by participant_id, treatment;
quit;
/* count first events by treatment arm */
proc sql;
create table first_event_summary_&sim_id as
select
&sim_id as sim_id,
count(*) as n_first_events_total,
sum
(case
when treatment=0 then 1
else 0
end)
as n_first_events_placebo,
sum
(case
when treatment=1 then 1
else 0
end)
as n_first_events_finerenone
from first_events_&sim_id;
quit;
/* count total events (already in counting_process dataset) */
proc sql;
create table total_event_summary_&sim_id as
select
&sim_id as sim_id,
sum(event) as n_total_events,
sum
(case
when treatment=0 and event=1 then 1
else 0
end)
as n_total_events_placebo,
sum
(case
when treatment=1 and event=1 then 1
else 0
end)
as n_total_events_finerenone
from final_dataset_&sim_id;
quit;
/* calculate the ratio */
proc sql;
create table event_ratio_&sim_id as
select
a.sim_id,
a.n_first_events_total,
a.n_first_events_placebo,
a.n_first_events_finerenone,
b.n_total_events,
b.n_total_events_placebo,
b.n_total_events_finerenone,
b.n_total_events / a.n_first_events_total as total_to_first_ratio,
b.n_total_events_placebo / a.n_first_events_placebo as ratio_placebo,
b.n_total_events_finerenone / a.n_first_events_finerenone as ratio_finerenone
from first_event_summary_&sim_id as a,
total_event_summary_&sim_id as b
where a.sim_id = b.sim_id;
quit;
/* calculate rates per 100 person-years */
data sim_summary_&sim_id;
set sim_summary_&sim_id;
person_years_total = total_person_months / 12;
person_years_placebo = person_months_placebo / 12;
person_years_finerenone = person_months_finerenone / 12;
rate_total = (total_events / person_years_total) * 100;
rate_placebo = (events_placebo / person_years_placebo) * 100;
rate_finerenone = (events_finerenone / person_years_finerenone) * 100;
rate_ratio = rate_finerenone / rate_placebo;
drop total_person_months person_months_placebo person_months_finerenone;
run;
%mend generate_one_sim;
/**************************************************************************
* main execution: generate multiple simulations
**************************************************************************/
%macro run_all_simulations;
%do sim = 1 %to &n_sim;
%if %sysfunc(mod(&sim, 100)) = 0 %then
%do;
%put note: completed &sim of &n_sim simulations;
%end;
%generate_one_sim(sim_id=&sim);
%end;
/* combine all simulation datasets */
data all_simulations;
set final_dataset_1 - final_dataset_&n_sim;
run;
/* combine all summary statistics */
data all_summaries;
set sim_summary_1 - sim_summary_&n_sim;
run;
data all_event_ratios;
set event_ratio_1 - event_ratio_&n_sim;
run;
proc means data=all_event_ratios mean std min max p25 p50 p75;
var n_first_events_total
n_total_events
total_to_first_ratio;
title "summary of event counts across all simulations";
run;
/* clean up temp simulation datasets */
proc datasets library=work nolist;
delete participants_: cv_deaths_: noncv_deaths_: hf_events_:
cv_death_events_: hf_event_list_: all_events_:
counting_process_: participants_no_events_: final_dataset_:
sim_summary_:;
quit;
%mend run_all_simulations;
/**************************************************************************
* execute simulation
**************************************************************************/
%run_all_simulations;
/**************************************************************************
* after running simulations, analyze with andersen-gill model
**************************************************************************/
%macro analyze_sim;
/* fit andersen-gill model with robust standard errors */
proc sort data=all_simulations;
by sim_id participant_id start_time;
run;
/* template sas program from bayer sap (published) */
proc phreg data=all_simulations covs(aggregate);
by sim_id;
class participant_id;
model (start_time, stop_time)*event(0) = treatment / ties=efron rl;
id participant_id; /* cluster variable for robust se */
ods output parameterestimates=parms;
run;
/* extract results */
data results;
set parms;
where parameter = 'treatment';
rate_ratio = exp(estimate);
p_value = probchisq;
significant = (p_value < 0.05);
keep sim_id rate_ratio p_value significant;
run;
%mend analyze_sim;
/**************************************************************************
* calculate power across all simulations
**************************************************************************/
%macro calculate_power;
/* analyze all simulations */
%analyze_sim();
/* combine results */
data all_results;
set results;
run;
/* calculate power */
proc means data=all_results mean;
var rate_ratio significant;
output out=power_results
mean=(rate_ratio) = mean_rr
mean(significant)=power
n(significant)=n_sims;
run;
data power_results;
set power_results;
power_pct = power * 100;
run;
proc print data=power_results;
title "power analysis results";
var power_pct mean_rr n_sims;
run;
/* clean up */
proc datasets library=work nolist;
delete parms_: results_:;
quit;
%mend calculate_power;
/* execute power calculation */
%calculate_power;