Montefiore Medical Center/ Albert Einstein College of Medicine
What is a tornado diagram?
A tornado diagram is a graphical summary of a one-way sensitivity analysis, which examines how sensitive the results of a cost-effectiveness model are to changes in individual input parameters.
In a one-way sensitivity analysis, one parameter is varied at a time across a plausible range (for example, its 95% confidence interval), while all other parameters are held constant at their base-case values. The effect of this variation on the outcome of interest—such as costs or QALYs, is then calculated.
Each horizontal bar in a tornado diagram represents a single model parameter. The length of the bar shows how much the outcome changes when that parameter is varied across its specified range.
Longer bars indicate parameters that have a greater influence on the model results
Shorter bars indicate parameters with relatively little impact
Parameters are ordered from top to bottom by their impact, creating the characteristic “tornado” shape
The center line represents the base-case estimate. The left and right ends of each bar correspond to the low and high values of the parameter, respectively.
Source code
The one-way sensitivity analysis shown on this page is implemented in:
This script defines parameter distributions and computes expected costs and QALYs while varying one parameter at a time.
Parameters of the model
The parameters are the same as for the probabilistic sensitivity analysis. We utilize the means, and vary the parameters according to the 95% intervals in the appropriate tables.
Tornado diagrams
Tornado diagram for costs
We see that costs are most sensitive to assumptions regarding the cost of an episode of cryptococcal disease post-transplant, cost_disease, and the probability that a donor has cryptococcus, p_donor_cryptococcus.
The expected cost savings from screening, as predicted, increase (become more negative) if those parameters lie at the higher end of the 95% interval.
Tornado diagram for QALYs
We see that QALYs are most sensitive to assumptions regarding the specificity of cryptococcal screening, p_specificity, due to the base case assuming that most donors with cryptococcus will lead to transplant cancellation.
As predicted, higher values of specificity will lead to less QALY loss from screening. Probability of donor cryptococcus, p_donor_cryptococcus has surprisingly little effect on QALYs.
---title: "One-way sensitivity analyses (tornado diagrams)"format: html---## What is a tornado diagram?A **tornado diagram** is a graphical summary of a [**one-way sensitivity analysis**](https://clas.ucdenver.edu/marcelo-perraillon/sites/default/files/attached-files/lecture_13_uncertainty.pdf), which examines how sensitive the results of a cost-effectiveness model are to changes in individual input parameters.In a one-way sensitivity analysis, **one parameter is varied at a time** across a plausible range (for example, its 95% confidence interval), while **all other parameters are held constant at their base-case values**. The effect of this variation on the outcome of interest—such as costs or QALYs, is then calculated.Each horizontal bar in a tornado diagram represents a **single model parameter**. The length of the bar shows how much the outcome changes when that parameter is varied across its specified range.- **Longer bars** indicate parameters that have a **greater influence** on the model results\- **Shorter bars** indicate parameters with relatively little impact\- Parameters are ordered from top to bottom by their impact, creating the characteristic “tornado” shapeThe center line represents the **base-case estimate**. The left and right ends of each bar correspond to the **low** and **high** values of the parameter, respectively.::: Rcode### Source codeThe one-way sensitivity analysis shown on this page is implemented in:- [`R/create_sensitivity_analysis_QC.R`](https://github.com/VagishHemmige/Cryptococcus-donor-screening-CEA/blob/master/R/create_sensitivity_analysis_QC.R)This script defines parameter distributions and computes expected costs and QALYs while varying one parameter at a time.:::## Parameters of the modelThe parameters are the same as for the [probabilistic sensitivity analysis](PSA.html#parameters-of-the-model). We utilize the means, and vary the parameters according to the 95% intervals in the appropriate tables.## Tornado diagrams### Tornado diagram for costsWe see that costs are most sensitive to assumptions regarding the cost of an episode of cryptococcal disease post-transplant, `cost_disease`, and the probability that a donor has cryptococcus, `p_donor_cryptococcus`.The expected cost savings from screening, as predicted, increase (become more negative) if those parameters lie at the higher end of the 95% interval.### Tornado diagram for QALYsWe see that QALYs are most sensitive to assumptions regarding the specificity of cryptococcal screening, `p_specificity`, due to the base case assuming that most donors with cryptococcus will lead to transplant cancellation.As predicted, higher values of specificity will lead to less QALY loss from screening. Probability of donor cryptococcus, `p_donor_cryptococcus` has surprisingly little effect on QALYs.## Source codeClick below to open up the source code from [`R/create_sensitivity_analysis_QC.R`](https://github.com/VagishHemmige/Cryptococcus-donor-screening-CEA/blob/master/R/create_sensitivity_analysis_QC.R) that is used to perform this analysis.```{r source, eval=FALSE}#We start with probabilities. we derive the mean and a shape parameter from the setup file #From the mean and alpha, we derive beta and then calculate confidence intervalsPSA_parameters<-list()PSA_parameters$probabilities<-tribble( ~parameter, ~mean, ~shape1, "p_usage", expected_value$p_usage, shape_parameter$p_usage, "p_donor_cryptococcus",expected_value$p_donor_cryptococcus, shape_parameter$p_donor_cryptococcus, "p_transmission",expected_value$p_transmission, shape_parameter$p_transmission, "p_spont_cryptococcus",expected_value$p_spont_cryptococcus, shape_parameter$p_spont_cryptococcus, "p_sensitivity",expected_value$p_sensitivity, shape_parameter$p_sensitivity, "p_specificity",expected_value$p_specificity, shape_parameter$p_specificity, "p_cancelled",expected_value$p_cancelled, shape_parameter$p_cancelled, "p_prophrate",expected_value$p_prophrate, shape_parameter$p_prophrate, "p_prophefficacy",expected_value$p_prophefficacy, shape_parameter$p_prophefficacy)%>% mutate(shape2=shape1 * (1 - mean) / mean)%>% rowwise() %>% mutate(mu=mean(rbeta(1e6, shape1, shape2)), lb_95=qbeta(0.025, shape1, shape2), ub_95=qbeta(0.975, shape1, shape2))#For costs and QALYS, we start from the mean and shape parameter, and we derive scale and then calculate confidence intervalsPSA_parameters$costs<-tribble( ~parameter, ~mean, ~shape, "cost_test",expected_value$cost_test,shape_parameter$cost_test, "cost_disease",expected_value$cost_disease,shape_parameter$cost_disease, "cost_fluconazole",expected_value$cost_fluconazole,shape_parameter$cost_fluconazole, "cost_cancellation",expected_value$cost_cancellation,shape_parameter$cost_cancellation, "cost_nocryptococcus",expected_value$cost_nocryptococcus,shape_parameter$cost_nocryptococcus, "cost_nonacceptance",expected_value$cost_nonacceptance,shape_parameter$cost_nonacceptance )%>% mutate(scale=mean/shape)%>% rowwise() %>% mutate(mu=mean(rgamma(1e6, shape=shape, scale=scale)), lb_95=qgamma(0.025, shape=shape, scale=scale), ub_95=qgamma(0.975, shape=shape, scale=scale))%>% mutate(lb_95=ifelse(is.na(lb_95), mean, lb_95), ub_95=ifelse(is.na(ub_95), mean, ub_95) )PSA_parameters$qalys<-tribble( ~parameter, ~mean, ~shape,"q_nocryptococcus",expected_value$q_nocryptococcus,shape_parameter$q_nocryptococcus,"q_noacceptance",expected_value$q_noacceptance,shape_parameter$q_noacceptance,"q_loss_cryptococcus",expected_value$q_loss_cryptococcus,shape_parameter$q_loss_cryptococcus)%>% mutate(scale=mean/shape)%>% rowwise() %>% mutate(mu=mean(rgamma(1e6, shape=shape, scale=scale)), lb_95=qgamma(0.025, shape=shape, scale=scale), ub_95=qgamma(0.975, shape=shape, scale=scale))%>% mutate(lb_95=ifelse(is.na(lb_95), mean, lb_95), ub_95=ifelse(is.na(ub_95), mean, ub_95) )#Loop to save plots parameter distributions for probabilitiesfor (i in 1:nrow(PSA_parameters$probabilities)){ temp_plot<-ggplot(data.frame(x = c(0, 1)), aes(x)) + stat_function(fun = dbeta, args = list(shape1 = PSA_parameters$probabilities[[i,3]], shape2 = PSA_parameters$probabilities[[i,4]]))+ labs( title = glue("Prior distribution for **{PSA_parameters$probabilities[[i,1]]}**"), x = "Probability", y = "Density", )+ theme_classic()+ theme(plot.title = element_markdown()) ggsave( glue("figures/PSA_prior_{PSA_parameters$probabilities[[i,1]]}.png"), plot = temp_plot, )}#Loop to save parameter distributions for costsfor (i in 1:nrow(PSA_parameters$costs)){ temp_plot<-ggplot(data.frame(x = c(PSA_parameters$costs[[i,6]]-1, 1+PSA_parameters$costs[[i,7]])), aes(x)) + stat_function(fun = dgamma, args = list(shape = PSA_parameters$costs[[i,3]], scale = PSA_parameters$costs[[i,4]]))+ labs( title = glue("Prior distribution for **{PSA_parameters$costs[[i,1]]}**"), x = "Cost", y = "Probability density", )+ theme_classic()+ theme(plot.title = element_markdown()) ggsave( glue("figures/PSA_prior_{PSA_parameters$costs[[i,1]]}.png"), plot = temp_plot, )}#Loop to save parameter distributions for QALYsfor (i in 1:nrow(PSA_parameters$qalys)){ temp_plot<-ggplot(data.frame(x = c(PSA_parameters$qalys[[i,6]]-1, 1+PSA_parameters$qalys[[i,7]])), aes(x)) + stat_function(fun = dgamma, args = list(shape = PSA_parameters$qalys[[i,3]], scale = PSA_parameters$qalys[[i,4]]))+ labs( title = glue("Prior distribution for **{PSA_parameters$qalys[[i,1]]}**"), x = "Cost", y = "Probability density", )+ theme_classic()+ theme(plot.title = element_markdown()) ggsave( glue("figures/PSA_prior_{PSA_parameters$qalys[[i,1]]}.png"), plot = temp_plot, )}#Create and save summary tables of the parametersPSA_tables<-list()PSA_tables$probabilities<-PSA_parameters$probabilities%>% gt()%>% cols_label( parameter = "Parameter", mean = "Mean", shape1 = "α", shape2 = "β", lb_95 = "Lower bound, 95% CI", ub_95 = "Upper bound, 95% CI" )PSA_tables$probabilitiesPSA_tables$probabilities%>% gtsave("figures/PSA_tables_probabilities.png")PSA_tables$probabilities%>% gtsave("tables/PSA_tables_probabilities.docx")PSA_tables$costs<-PSA_parameters$costs%>% gt()%>% cols_label( parameter = "Parameter", mean = "Mean", shape = "k", scale = "θ", lb_95 = "Lower bound, 95% CI", ub_95 = "Upper bound, 95% CI" )PSA_tables$costsPSA_tables$costs%>% gtsave("figures/PSA_tables_costs.png")PSA_tables$costs%>% gtsave("tables/PSA_tables_costs.docx")PSA_tables$qalys<-PSA_parameters$qalys%>% gt()%>% cols_label( parameter = "Parameter", mean = "Mean", shape = "k", scale = "θ", lb_95 = "Lower bound, 95% CI", ub_95 = "Upper bound, 95% CI" )PSA_tables$qalysPSA_tables$qalys%>% gtsave("figures/PSA_tables_qalys.png")PSA_tables$qalys%>% gtsave("tables/PSA_tables_qalys.docx")#One-way sensivity analysis (tornado diagrams)tornado_parameters<-list()#Add the results of low, mean, and high values of the CI to the dataframestornado_parameters$probabilities<-PSA_parameters$probabilities%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(lb_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_low=total_expected_cost_s-total_expected_cost_ns, qaly_difference_low=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_low=wtp*(qaly_difference_low)-cost_difference_low )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(mean), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_mean=total_expected_cost_s-total_expected_cost_ns, qaly_difference_mean=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_mean=wtp*(qaly_difference_mean)-cost_difference_mean )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(ub_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_high=total_expected_cost_s-total_expected_cost_ns, qaly_difference_high=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_high=wtp*(qaly_difference_high)-cost_difference_high )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% mutate(range_cost=abs(cost_difference_high-cost_difference_low), range_qaly=abs(qaly_difference_high-qaly_difference_low))#Add the results of low, mean, and high values of the CI to the dataframes for costtornado_parameters$costs<-PSA_parameters$costs%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(lb_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_low=total_expected_cost_s-total_expected_cost_ns, qaly_difference_low=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_low=wtp*(qaly_difference_low)-cost_difference_low )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(mean), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_mean=total_expected_cost_s-total_expected_cost_ns, qaly_difference_mean=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_low=wtp*(qaly_difference_low)-cost_difference_low )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(ub_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_high=total_expected_cost_s-total_expected_cost_ns, qaly_difference_high=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_high=wtp*(qaly_difference_high)-cost_difference_high )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% mutate(range_cost=abs(cost_difference_high-cost_difference_low), range_qaly=abs(qaly_difference_high-qaly_difference_low))#Add the results of low, mean, and high values of the CI to the dataframes for qalytornado_parameters$qalys<-PSA_parameters$qalys%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(lb_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_low=total_expected_cost_s-total_expected_cost_ns, qaly_difference_low=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_low=wtp*(qaly_difference_low)-cost_difference_low )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(mean), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_mean=total_expected_cost_s-total_expected_cost_ns, qaly_difference_mean=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_mean=wtp*(qaly_difference_mean)-cost_difference_mean )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% rowwise() %>% mutate( result = do.call( calculate_cost_QALY_QC, setNames(list(ub_95), parameter) ) ) %>% unnest(cols = c(result))%>% ungroup()%>% mutate( cost_difference_high=total_expected_cost_s-total_expected_cost_ns, qaly_difference_high=total_expected_qaly_s-total_expected_qaly_ns, nmb_difference_high=wtp*(qaly_difference_high)-cost_difference_high )%>% select(-total_expected_cost_ns, -total_expected_cost_s, -total_expected_qaly_ns,-total_expected_qaly_s)%>% mutate(range_cost=abs(cost_difference_high-cost_difference_low), range_qaly=abs(qaly_difference_high-qaly_difference_low), range_nmb=abs(nmb_difference_high-nmb_difference_low))tornado_parameters_final<-bind_rows(tornado_parameters)%>% select(parameter, contains("difference"), contains("range"))#Tornado plot of costs for probabilitiestornado_cost_df<-tornado_parameters_final%>% arrange(range_cost)%>% mutate(rownum=row_number())tornado_cost_df%>% ggplot()+ geom_rect(mapping=aes(xmin=cost_difference_low, xmax=cost_difference_mean, ymin=rownum-0.4, ymax=rownum+0.4, fill="Lower bound of CI"))+ geom_rect(mapping=aes(xmin=cost_difference_mean, xmax=cost_difference_high, ymin=rownum-0.4, ymax=rownum+0.4, fill="Upper bound of CI"))+ geom_vline(xintercept = first(tornado_cost_df$cost_difference_mean))+ scale_y_continuous( breaks = tornado_cost_df$rownum, labels = tornado_cost_df$parameter )+ labs( x="Cost difference", y="Parameter", title="Tornado plot of cost", )+scale_fill_manual( name = "Parameter value", values = c( "Lower bound of CI" = "steelblue", "Upper bound of CI" = "firebrick" ) )+ theme(legend.position = "bottom")ggsave("figures/tornado_cost.svg")#Tornado plot of QALYstornado_qaly_df<-tornado_parameters_final%>% arrange(range_qaly)%>% mutate(rownum=row_number())tornado_qaly_df%>% ggplot()+ geom_rect(mapping=aes(xmin=qaly_difference_low, xmax=qaly_difference_mean, ymin=rownum-0.4, ymax=rownum+0.4, fill="Lower bound of CI"))+ geom_rect(mapping=aes(xmin=qaly_difference_mean, xmax=qaly_difference_high, ymin=rownum-0.4, ymax=rownum+0.4, fill="Upper bound of CI"))+ geom_vline(xintercept = first(tornado_qaly_df$qaly_difference_mean))+ scale_y_continuous( breaks = tornado_qaly_df$rownum, labels = tornado_qaly_df$parameter )+ labs( x="QALY difference", y="Parameter", title="Tornado plot of QALYs", )+scale_fill_manual( name = "Parameter value", values = c( "Lower bound of CI" = "steelblue", "Upper bound of CI" = "firebrick" ) )+ theme(legend.position = "bottom")ggsave("figures/tornado_qaly.svg")```## Other results- [**Base case analysis**](base_case.qmd): primary results and interpretation\- [**Additional tree analyses**](additional_trees.qmd): alternative clinical assumptions\- [**Probabilistic sensitivity analyses**](psa.qmd): uncertainty and robustness\- [**About**](about.qmd): methods, assumptions, and disclosures