Calculate distance-based treatment effects for many cohorts#

In this notebook we create a generic geography of an acute stroke unit and a thrombectomy centre, calculate the travel times for any point on the map to either stroke unit, and then calculate outcomes using times to treatment based on those travel times. This shows the general effects of geography on the outcomes of patients.

This notebook expands on the simple example in the previous document by calculating outcomes for more cohorts of patients and combining the results. Have a look at that document for a more step-by-step walkthrough and some plots of the data as it goes along.

This notebook calculates the outcomes and the following notebook creates plots of the results.

Plain English summary#

This notebook adds to the content in the previous notebook.

The extra material here is to calculate the outcomes for different combinations of stroke types and treatments.

Aims#

Calculate drip-and-ship and mothership outcomes for the usual cohorts:

  • nLVO with IVT

  • LVO with IVT only

  • LVO with MT only

  • LVO with both IVT and MT

Method#

This example uses the same patient population and fixed times as the England and Wales examples.

We create a square grid containing an acute stroke unit and a thrombectomy centre. There is a fixed 60 minute travel time between the IVT and MT centres. Otherwise the travel times to the IVT and MT centres depend on the starting point on the grid.

These variable travel times are added to a selection of fixed times to build up total times from stroke onset to treatment for any point on the grid. The variation in time to treatment around a grid is due only to the travel time differences - all other fixed times are the same.

The treatment time grids are then used to calculate outcomes for any point on the grid. The outcomes calculated depend on the stroke type and the treatment given.

Here we calculate the difference in outcome measures for three cohorts:

  • nLVO treated with IVT.

  • a mix of LVO treated with IVT only, with MT only, and with both IVT and MT.

  • a mixed population (49.6% nLVO with IVT, 50.4% LVO with mixed treatments).

The concept of comparing grids of generic geography in this way is based on the figures from Holodinsky et al. 2017 - “Drip and Ship Versus Direct to Comprehensive Stroke Center”.

Notebook admin#

# Import packages
import numpy as np
import pandas as pd
import copy
# For keeping track of files:
import os
from dataclasses import dataclass

# Custom packages:
from stroke_outcome.continuous_outcome import Continuous_outcome
# Set up MatPlotLib
%matplotlib inline
# Keep notebook cleaner once finalised
import warnings
warnings.filterwarnings('ignore')
# Define file paths
@dataclass(frozen=True)
class Paths:
    '''Singleton object for storing paths to data and database.'''

    dir_output = 'output'
    file_grid_ivt = 'generic_geography_grid_time_travel_directly_to_ivt.csv'
    file_grid_diff = 'generic_geography_grid_time_travel_directly_diff.csv'
    file_grid_dict = 'generic_geography_dict_travel_grid.csv'
    file_outcomes_nlvo = 'generic_geography_df_nlvo_ivt_diff.csv'
    file_outcomes_lvo = 'generic_geography_df_lvo_diff.csv'
    file_outcomes_mix = 'generic_geography_df_mixed_diff.csv'

paths = Paths()

Import data#

Pathway times#

fixed_times = pd.read_csv(
    os.path.join('..', 'england_wales', 'output', 'pathway_times.csv'),
    index_col=0, header=None).squeeze()

Add an extra fixed time that is specific to this generic geography example. The time between the nearest IVT unit and the nearest MT unit is 60 minutes:

fixed_times['travel_ivt_to_mt'] = 60

View the time assumptions:

fixed_times
0
onset_to_ambulance_arrival                  60
arrival_to_ivt                              30
arrival_to_mt                               90
net_operational_delay_to_mt_for_transfer    60
travel_ivt_to_mt                            60
Name: 1, dtype: int64

Patient proportions#

patient_proportions = pd.read_csv(
    os.path.join('..', 'england_wales', 'output', 'patient_proportions.csv'),
    index_col=0, header=None).squeeze()
patient_proportions
0
haemorrhagic         0.13600
lvo_no_treatment     0.14648
lvo_ivt_only         0.00840
lvo_ivt_mt           0.08500
lvo_mt_only          0.01500
nlvo_no_treatment    0.50252
nlvo_ivt             0.10660
Name: 1, dtype: float64

Calculate some additional proportions:

# Proportion of treated LVO patients:
prop_lvo_treated = 0.0

for key, value in patient_proportions.items():
    if (('lvo' in key) & ('nlvo' not in key) & ('no_treat' not in key)):
        prop_lvo_treated += value

prop_lvo_treated
0.10840000000000001
# Proportion of treated ischaemic patients:
prop_ischaemic_treated = 0.0

for key, value in patient_proportions.items():
    if (('lvo' in key) & ('no_treat' not in key)):
        prop_ischaemic_treated += value

prop_ischaemic_treated
0.21500000000000002
# Proportion of ischaemic patients:
prop_ischaemic = 1.0 - patient_proportions['haemorrhagic']

prop_ischaemic
0.864
prop_nlvo_of_treated = patient_proportions['nlvo_ivt'] / prop_ischaemic_treated
prop_lvo_of_treated = 1.0 - prop_nlvo_of_treated

prop_nlvo_of_treated, prop_lvo_of_treated
(0.49581395348837204, 0.5041860465116279)

Define travel time grids#

For each point on a grid, find the travel time to a given coordinate (one of the treatment centres).

The treatment centres are located at the following coordinates:

Centre

x

y

IVT

0

0

IVT/MT

0

-60

Keep a copy of the useful parameters in a dictionary that can be saved and reloaded for future use.

dict_travel_grid = dict(
    ivt_x = 0,
    ivt_y = 0,
    mt_x = 0,
    mt_y = -fixed_times['travel_ivt_to_mt'],
    travel_ivt_to_mt = fixed_times['travel_ivt_to_mt'],
    # Only calculate travel times up to this x or y displacement:
    time_travel_max = 60,
    # Change how granular the grid is. 
    grid_step = 1, # minutes
)

# Make the grid a bit larger than the max travel time: 
dict_travel_grid['grid_xy_max'] = dict_travel_grid['time_travel_max'] + dict_travel_grid['grid_step'] * 2

Define a helper function to build the time grid:

def make_time_grid(
        xy_max,
        step,
        x_offset=0,
        y_offset=0
    ):
    # Times for each row....
    x_times = np.arange(-xy_max, xy_max + step, step) - x_offset
    # ... and each column.
    y_times = np.arange(-xy_max, xy_max + step, step) - y_offset
    # The offsets shift the position of (0,0) from the grid centre 
    # to (x_offset, y_offset). Distances will be calculated from the
    # latter point. 

    # Mesh to create new grids by stacking rows (xx) and columns (yy):
    xx, yy = np.meshgrid(x_times, y_times)

    # Then combine the two temporary grids to find distances: 
    radial_times = np.sqrt(xx**2.0 + yy**2.0)
    return radial_times

Build the grids:

grid_time_travel_directly_to_ivt = make_time_grid(
    dict_travel_grid['grid_xy_max'],
    dict_travel_grid['grid_step'],
    x_offset=dict_travel_grid['ivt_x'],
    y_offset=dict_travel_grid['ivt_y']
)
grid_time_travel_directly_to_mt = make_time_grid(
    dict_travel_grid['grid_xy_max'],
    dict_travel_grid['grid_step'],
    x_offset=dict_travel_grid['mt_x'],
    y_offset=dict_travel_grid['mt_y']
)
grid_time_travel_directly_diff = (
    grid_time_travel_directly_to_ivt - grid_time_travel_directly_to_mt)

Round the times to three decimal places (should be plenty):

grid_time_travel_directly_to_ivt = np.round(grid_time_travel_directly_to_ivt, 3)
grid_time_travel_directly_to_mt = np.round(grid_time_travel_directly_to_mt, 3)
grid_time_travel_directly_diff = np.round(grid_time_travel_directly_diff, 3)

Create outcome model inputs#

Calculate the fixed times to treatment excluding the travel from the patient location to the first stroke unit.

import pandas as pd
import os
treatment_time_dict = {
    'usual_care': {},
    'mothership': {}
}

Times for usual care (the patient goes to their nearest stroke unit and is later transferred to the MT unit if necessary):

treatment_time_dict['usual_care']['ivt'] = (
    fixed_times['onset_to_ambulance_arrival'] + 
    fixed_times['arrival_to_ivt']
    )
treatment_time_dict['usual_care']['mt'] = (
    fixed_times['onset_to_ambulance_arrival'] + 
    fixed_times['arrival_to_ivt'] + 
    fixed_times['net_operational_delay_to_mt_for_transfer'] + 
    fixed_times['travel_ivt_to_mt'] + 
    fixed_times['arrival_to_mt']
    )

Times for mothership (the patient goes directly to the MT unit):

treatment_time_dict['mothership']['ivt'] = (
    fixed_times['onset_to_ambulance_arrival'] + 
    fixed_times['arrival_to_ivt']
    )
treatment_time_dict['mothership']['mt'] = (
    fixed_times['onset_to_ambulance_arrival'] + 
    fixed_times['arrival_to_mt']
    )

Display these times:

pd.DataFrame(treatment_time_dict)
usual_care mothership
ivt 90 90
mt 300 150

Store the times in the main fixed times dictionary for later reference.

fixed_times['usual_care_ivt'] = treatment_time_dict['usual_care']['ivt']
fixed_times['usual_care_mt'] = treatment_time_dict['usual_care']['mt']
fixed_times['mothership_ivt'] = treatment_time_dict['mothership']['ivt']
fixed_times['mothership_mt'] = treatment_time_dict['mothership']['mt']

The time to IVT for usual care and mothership should be the same because the only difference in these cases is the travel time to the stroke unit, which isn’t included in these fixed times.

These times can be added to the existing travel time grids to find the time for treatment at any point in the grid.

For the outcome model, we have to turn the grid of times into a single column of times using flatten(). We can easily undo this later when plotting the grids.

Treatment times for usual care:

df_usual_care = pd.DataFrame()
df_usual_care['onset_to_needle_mins'] = (
    treatment_time_dict['usual_care']['ivt'] +
    grid_time_travel_directly_to_ivt.flatten()
)
df_usual_care['onset_to_puncture_mins'] = (
    treatment_time_dict['usual_care']['mt'] +
    grid_time_travel_directly_to_ivt.flatten()
)
# n.b. travel time between hospitals is included in the treatment_time_dict.

Treatment times for mothership:

df_mothership = pd.DataFrame()
df_mothership['onset_to_needle_mins'] = (
    treatment_time_dict['mothership']['ivt'] + 
    grid_time_travel_directly_to_mt.flatten()
)
df_mothership['onset_to_puncture_mins'] = (
    treatment_time_dict['mothership']['mt'] +
    grid_time_travel_directly_to_mt.flatten()
)

Assign three cohorts to these treatment times.

nLVO with IVT:

df_usual_care_nlvo_ivt = df_usual_care.copy()
df_usual_care_nlvo_ivt['stroke_type_code'] = 1
df_usual_care_nlvo_ivt['ivt_chosen_bool'] = 1
df_usual_care_nlvo_ivt['mt_chosen_bool'] = 0
df_mothership_nlvo_ivt = df_mothership.copy()
df_mothership_nlvo_ivt['stroke_type_code'] = 1
df_mothership_nlvo_ivt['ivt_chosen_bool'] = 1
df_mothership_nlvo_ivt['mt_chosen_bool'] = 0

LVO with IVT only:

df_usual_care_lvo_ivt = df_usual_care.copy()
df_usual_care_lvo_ivt['stroke_type_code'] = 2
df_usual_care_lvo_ivt['ivt_chosen_bool'] = 1
df_usual_care_lvo_ivt['mt_chosen_bool'] = 0
df_mothership_lvo_ivt = df_mothership.copy()
df_mothership_lvo_ivt['stroke_type_code'] = 2
df_mothership_lvo_ivt['ivt_chosen_bool'] = 1
df_mothership_lvo_ivt['mt_chosen_bool'] = 0

LVO with both IVT and MT:

df_usual_care_lvo_ivt_mt = df_usual_care.copy()
df_usual_care_lvo_ivt_mt['stroke_type_code'] = 2
df_usual_care_lvo_ivt_mt['ivt_chosen_bool'] = 1
df_usual_care_lvo_ivt_mt['mt_chosen_bool'] = 1
df_mothership_lvo_ivt_mt = df_mothership.copy()
df_mothership_lvo_ivt_mt['stroke_type_code'] = 2
df_mothership_lvo_ivt_mt['ivt_chosen_bool'] = 1
df_mothership_lvo_ivt_mt['mt_chosen_bool'] = 1

LVO with MT only:

df_usual_care_lvo_mt = df_usual_care.copy()
df_usual_care_lvo_mt['stroke_type_code'] = 2
df_usual_care_lvo_mt['ivt_chosen_bool'] = 0
df_usual_care_lvo_mt['mt_chosen_bool'] = 1
df_mothership_lvo_mt = df_mothership.copy()
df_mothership_lvo_mt['stroke_type_code'] = 2
df_mothership_lvo_mt['ivt_chosen_bool'] = 0
df_mothership_lvo_mt['mt_chosen_bool'] = 1

Calculate outcomes#

# Set up outcome model
outcome_model = Continuous_outcome()
dfs = [
    df_usual_care_nlvo_ivt,
    df_mothership_nlvo_ivt,
    df_usual_care_lvo_ivt,
    df_mothership_lvo_ivt,
    df_usual_care_lvo_ivt_mt,
    df_mothership_lvo_ivt_mt,
    df_usual_care_lvo_mt,
    df_mothership_lvo_mt,
]

for df in dfs:
    outcome_model.assign_patients_to_trial(df)

    # Calculate outcomes:
    patient_data_dict, outcomes_by_stroke_type, full_cohort_outcomes = (
        outcome_model.calculate_outcomes())

    # Make a copy of the results:
    outcomes_by_stroke_type = copy.copy(outcomes_by_stroke_type)
    full_cohort_outcomes = copy.copy(full_cohort_outcomes)

    # Place the relevant results into the starting dataframe:
    df['added_utility'] = full_cohort_outcomes['each_patient_utility_shift']
    df['mean_mrs'] = full_cohort_outcomes['each_patient_mrs_post_stroke']
    df['mrs_less_equal_2'] = full_cohort_outcomes['each_patient_mrs_dist_post_stroke'][:, 2]
    df['mrs_shift'] = full_cohort_outcomes['each_patient_mrs_shift']

Combine outcomes#

Combine the data in these columns:

outcome_cols = ['added_utility', 'mean_mrs', 'mrs_less_equal_2', 'mrs_shift']

Combine outcomes for treated ischaemic population#

The following function combines the data from multiple dataframes, one for each cohort, in the proportions requested:

def combine_outcomes_treated_ischaemic(
        df_nlvo_ivt,
        df_lvo_ivt,
        df_lvo_ivt_mt,
        df_lvo_mt,
        patient_proportions,
        outcome_cols,
        prop_ischaemic_treated
    ):
    # Combine the outcomes:
    df_mixed = pd.DataFrame(
        np.sum((
            patient_proportions['nlvo_ivt'] * df_nlvo_ivt[outcome_cols],
            patient_proportions['lvo_ivt_only'] * df_lvo_ivt[outcome_cols],
            patient_proportions['lvo_ivt_mt'] * df_lvo_ivt_mt[outcome_cols],
            patient_proportions['lvo_mt_only'] * df_lvo_mt[outcome_cols],
        ), axis=0),
        columns=outcome_cols
    )
    
    # Adjust outcomes for just the treated population:
    df_mixed = df_mixed / prop_ischaemic_treated
    
    # Copy over the treatment times.
    # They're the same times in all three dataframes so just pick the nLVO IVT df:
    df_mixed['onset_to_needle_mins'] = df_nlvo_ivt['onset_to_needle_mins']
    df_mixed['onset_to_puncture_mins'] = df_nlvo_ivt['onset_to_puncture_mins']
    return df_mixed

Usual care:

df_usual_care_mixed = combine_outcomes_treated_ischaemic(
    df_usual_care_nlvo_ivt,
    df_usual_care_lvo_ivt,
    df_usual_care_lvo_ivt_mt,
    df_usual_care_lvo_mt,
    patient_proportions,
    outcome_cols,
    prop_ischaemic_treated
)

Mothership:

df_mothership_mixed = combine_outcomes_treated_ischaemic(
    df_mothership_nlvo_ivt,
    df_mothership_lvo_ivt,
    df_mothership_lvo_ivt_mt,
    df_mothership_lvo_mt,
    patient_proportions,
    outcome_cols,
    prop_ischaemic_treated
)

Combine outcomes for LVO with various treatments#

The following function combines the data from multiple dataframes, one for each cohort, in the proportions requested:

def combine_outcomes_lvo(
        df_lvo_ivt,
        df_lvo_ivt_mt,
        df_lvo_mt,
        patient_proportions,
        outcome_cols,
        prop_lvo_treated
    ):
    # Combine the outcomes:
    df_lvo = pd.DataFrame(
        np.sum((
            patient_proportions['lvo_ivt_only'] * df_lvo_ivt[outcome_cols],
            patient_proportions['lvo_ivt_mt'] * df_lvo_ivt_mt[outcome_cols],
            patient_proportions['lvo_mt_only'] * df_lvo_mt[outcome_cols],
        ), axis=0),
        columns=outcome_cols
    )
    
    # Adjust outcomes for just the treated population:
    df_lvo = df_lvo / prop_lvo_treated
    
    # Copy over the treatment times.
    # They're the same times in all three dataframes so just pick the LVO IVT df:
    df_lvo['onset_to_needle_mins'] = df_lvo_ivt['onset_to_needle_mins']
    df_lvo['onset_to_puncture_mins'] = df_lvo_ivt['onset_to_puncture_mins']
    return df_lvo

Usual care:

df_usual_care_lvo = combine_outcomes_lvo(
    df_usual_care_lvo_ivt,
    df_usual_care_lvo_ivt_mt,
    df_usual_care_lvo_mt,
    patient_proportions,
    outcome_cols,
    prop_lvo_treated
)

Mothership:

df_mothership_lvo = combine_outcomes_lvo(
    df_mothership_lvo_ivt,
    df_mothership_lvo_ivt_mt,
    df_mothership_lvo_mt,
    patient_proportions,
    outcome_cols,
    prop_lvo_treated
)

Difference in outcomes between usual care and mothership#

Calculate the differences in outcomes between usual care and mothership.

Only do this for the groups that will be plotted later:

  • nLVO with IVT

  • LVO with mixed treatments

  • treated ischaemic population

df_nlvo_ivt_diff = df_mothership_nlvo_ivt - df_usual_care_nlvo_ivt
df_lvo_diff = df_mothership_lvo - df_usual_care_lvo
df_mixed_diff = df_mothership_mixed - df_usual_care_mixed

Save results to file#

Travel time grids:

pd.DataFrame(grid_time_travel_directly_to_ivt).to_csv(
    os.path.join(paths.dir_output, paths.file_grid_ivt),
    index=False, header=None
)
pd.DataFrame(grid_time_travel_directly_diff).to_csv(
    os.path.join(paths.dir_output, paths.file_grid_diff),
    index=False, header=None
)

Useful data for travel time grids:

pd.Series(dict_travel_grid).to_csv(
    os.path.join(paths.dir_output, paths.file_grid_dict),
    header=False
)

Outcomes:

df_nlvo_ivt_diff.to_csv(
    os.path.join(paths.dir_output, paths.file_outcomes_nlvo),
    index=False
)
df_lvo_diff.to_csv(
    os.path.join(paths.dir_output, paths.file_outcomes_lvo),
    index=False
)
df_mixed_diff.to_csv(
    os.path.join(paths.dir_output, paths.file_outcomes_mix),
    index=False
)

Conclusion#

The outcome grids have been calculated for the separate cohorts and the results have been saved to file.

The results are plotted in the next notebook.