from lokigi.site import SiteProblem
problem_public_transport_extended = SiteProblem()
problem_public_transport_extended.add_demand(
"../../../sample_data/demand_MF_50_84.csv", demand_col="M50-84",
location_id_col="LSOA 2021 Name",
)
problem_public_transport_extended.add_sites(
"../../../sample_data/devon_mius.geojson",
candidate_id_col="Facility_Name"
)
problem_public_transport_extended.add_region_geometry_layer(
"https://github.com/hsma-programme/h6_3c_interactive_plots_travel/raw/main/h6_3c_interactive_plots_travel/example_code/LSOA_2011_Boundaries_Super_Generalised_Clipped_BSC_EW_V4.geojson",
common_col="LSOA11NM"
)
problem_public_transport_extended.add_travel_matrix(
travel_matrix_df="../../../sample_data/devon_miu_travel_matrix_public_transport_extended.csv",
source_col="from_id",
unit="minutes",
)
problem_public_transport_extended.add_equity_data(
"../../../sample_data/devon_imd_2025_2021_LSOAs.csv",
equity_col="Index of Multiple Deprivation (IMD) Decile (where 1 is most deprived 10% of LSOA",
common_col="LSOA name (2021)",
label="IMD decile",
disadvantaged_end="low",
)Site Closure Problems
Often we may find ourselves with a set of sites where we are required to close one or more of them due to budget or other constraints, but wish to do this in a way that will minimize the impact on users.
This is an easy thing to do with lokigi.
To start with, we will set up our problem as usual.
For our site file, we simply load in a file containing the positions of all of our existing sites, and provide a travel matrix of travel times from each region to those sites.
Here, we’ve also provided a region geometry layer to support plotting.
We also register IMD (Index of Multiple Deprivation) decile via add_equity_data(), with disadvantaged_end="low" since decile 1 is the most deprived 10% of LSOAs – the same source used in population impact vs baseline. This isn’t used to change which sites are chosen; it unlocks the equity-band breakdown of the closure’s impact later in this example.
Note that we are using a matrix of public transport travel times here. This particular matrix only looks at bus travel, not rail.
Let’s quickly remind ourselves of the sites in this case.
problem_public_transport_extended.show_sites()| canonical_site_index | Facility_Name | Latitude | Longitude | geometry | |
|---|---|---|---|---|---|
| 0 | 0 | North Devon District Hospital | 51.09217 | -4.05043 | POINT (256506.101 134540.134) |
| 1 | 1 | Honiton Hospital | 50.79492 | -3.18659 | POINT (316466.043 100155.33) |
| 2 | 2 | Tiverton & District Hospital | 50.90933 | -3.49308 | POINT (295122.857 113268.884) |
| 3 | 3 | Exmouth Minor Injury Unit | 50.62083 | -3.40198 | POINT (300919.29 81063.23) |
| 4 | 4 | Victoria Hospital (Sidmouth) | 50.68161 | -3.23966 | POINT (312514.661 87617.001) |
| 5 | 5 | Newton Abbot Community Hospital | 50.53926 | -3.61224 | POINT (285848.833 72295.946) |
| 6 | 6 | Totnes Community Hospital | 50.43283 | -3.68406 | POINT (280491.309 60575.271) |
| 7 | 7 | NHS Walk in Centre (Exeter) | 50.72658 | -3.52521 | POINT (292444.533 92994.08) |
| 8 | 8 | Tavistock Hospital | 50.54708 | -4.15376 | POINT (247503.629 74139.474) |
| 9 | 9 | South Hams Hospital (Kingsbridge) | 50.28929 | -3.78143 | POINT (273194.03 44777.085) |
| 10 | 10 | Cumberland Centre (Plymouth) | 50.37004 | -4.16873 | POINT (245868.211 54486.789) |
| 11 | 11 | Derriford Hospital (UTC) | 50.41802 | -4.11890 | POINT (249563.619 59719.094) |
| 12 | 12 | Ilfracombe & District Tyrrell Hospital | 51.20468 | -4.12454 | POINT (251678.187 147197.738) |
| 13 | 13 | South Molton Hospital | 51.01681 | -3.83823 | POINT (271156.007 125767.813) |
| 14 | 14 | Dawlish Community Hospital | 50.58057 | -3.47482 | POINT (295677.679 76686.654) |
problem_public_transport_extended.plot_sites()
First, let’s create a baseline evaluation of the existing setup of all 15 sites.
baseline = problem_public_transport_extended.evaluate_baseline(
site_names=problem_public_transport_extended.show_sites()["Facility_Name"].tolist(),
threshold_for_coverage=60, # OPTIONAL
)baseline.show_solutions()| site_names | site_indices | unselected_site_names | coverage_threshold | weighted_average | unweighted_average | 90th_percentile | max | weighted_average_for_ranking | unweighted_average_for_ranking | ... | gap_absolute_weighted | gap_relative_weighted | avg_lower_third_bins | avg_middle_third_bins | avg_upper_third_bins | inter_tertile_ratio | gap_absolute_description | gap_relative_description | inter_tertile_description | problem_df | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | [North Devon District Hospital, Honiton Hospit... | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,... | [] | 60 | 61.17 | 56.34 | 97.0 | 262.0 | 61.17 | 56.34 | ... | 45.04 | 2.16 | 57.6 | 63.18 | 46.81 | 1.23 | Spread of 45.0 minutes between best and worst ... | Significant Disparity (Worst group travels 116... | Slightly Inequitable (Most deprived travel 23%... | LSOA 2021 Name North Devon District Hos... |
1 rows × 36 columns
Our baseline has all of the standard methods available that we are used to in our solution objects.
plot_best_combination() can be used to visualise the travel times with the current site configuration.
fig = baseline.plot_best_combination(
unchosen_site_colour="magenta",
plot_site_allocation=False,
legend_loc="lower right",
show_all_locations=False,
)
fig;
And we can also use the plot_site_allocation parameter to see which sites are currently the closest sites for a range of people.
fig = baseline.plot_best_combination(
unchosen_site_colour="magenta",
plot_site_allocation=True,
legend_loc="lower right",
show_all_locations=False,
cmap="Set3",
)
fig;
Let’s imagine that we’ve been told we need to choose two to close of the fifteen existing sites.
We will simply pass in a p that is lower than the number of existing sites.
We’re going to use a ‘greedy’ search strategy here. This looks for the best option at each step - so it’s a bit short-sighted.
We could brute force it instead, which we’ll do later in this notebook!
sites_to_close = 2
n_existing_sites = len(problem_public_transport_extended.show_sites())
n_sites_remaining = n_existing_sites - sites_to_close
solution_greedy_public_transport_extended = problem_public_transport_extended.solve(
p=n_sites_remaining,
search_strategy="greedy",
threshold_for_coverage=60,
baseline=baseline # Optional - but adds extra useful metrics over not including
)Best combination for 1 sites: [7]
Best combination for 2 sites: [7, 11]
Best combination for 3 sites: [0, 7, 11]
Best combination for 4 sites: [0, 6, 7, 11]
Best combination for 5 sites: [0, 1, 6, 7, 11]
Best combination for 6 sites: [0, 1, 5, 6, 7, 11]
Best combination for 7 sites: [0, 1, 3, 5, 6, 7, 11]
Best combination for 8 sites: [0, 1, 3, 5, 6, 7, 8, 11]
Best combination for 9 sites: [0, 1, 3, 5, 6, 7, 8, 10, 11]
Best combination for 10 sites: [0, 1, 3, 4, 5, 6, 7, 8, 10, 11]
Best combination for 11 sites: [0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11]
Best combination for 12 sites: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
Best combination for 13 sites: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14]
Let’s plot what the greedy solver found to be the best solution for 13 sites.
fig = solution_greedy_public_transport_extended.plot_best_combination(
unchosen_site_colour="magenta",
plot_site_allocation=False,
legend_loc="lower right",
show_all_locations=True,
label_all_locations=True
)
fig;
And take a look at where each region was allocated to (in terms of what each region’s closest site was).
fig = solution_greedy_public_transport_extended.plot_best_combination(
unchosen_site_colour="magenta",
plot_site_allocation=True,
legend_loc="lower right",
show_all_locations=True,
label_all_locations=True,
cmap="Set3",
)
fig;
We can take a look at the full solution dataframe.
Because we’re using greedy, we will only get a single solution.
solution_greedy_public_transport_extended.show_solutions()| solution_rank | site_names | site_indices | unselected_site_names | coverage_threshold | weighted_average | unweighted_average | 90th_percentile | max | weighted_average_for_ranking | ... | regions_worsened | regions_unchanged | mean_reduction_among_improved | mean_increase_among_worsened | max_reduction | max_increase | demand_improved_by_equity_group | demand_worsened_by_equity_group | sites_closed_vs_baseline | sites_added_vs_baseline | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | [North Devon District Hospital, Honiton Hospit... | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14] | [Ilfracombe & District Tyrrell Hospital, South... | 60 | 63.26 | 58.23 | 99.0 | 262.0 | 63.26 | ... | 25 | 658 | NaN | 51.17 | NaN | 122.0 | {1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.... | {1: 326.0, 2: 542.0, 3: 235.0, 4: 4147.0, 5: 1... | [Ilfracombe & District Tyrrell Hospital, South... | [] |
1 rows × 53 columns
There seem to be a lot of columns - let’s remind ourselves what information gets calculated for each solution.
solution_greedy_public_transport_extended.describe_solution_columns()=== Which sites ===
Which candidate sites make up this solution.
solution_rank
site_names
site_indices
unselected_site_names
=== Travel cost ===
How far people have to travel under this solution (lower is better), and how many had no feasible journey at all.
weighted_average
unweighted_average
90th_percentile
max
weighted_average_for_ranking
unweighted_average_for_ranking
max_for_ranking
total_cost
regions_unreachable
demand_unreachable
proportion_demand_unreachable
=== Coverage ===
Share/headcount of people or places within threshold_for_coverage (higher is better).
coverage_threshold
proportion_within_coverage_threshold
proportion_regions_within_coverage_threshold
demand_within_coverage_threshold
regions_within_coverage_threshold
=== Equity ===
How unevenly travel cost is spread across the groups registered via add_equity_data().
weighted_by_equity_group
unweighted_by_equity_group
coverage_by_equity_group
coverage_regions_by_equity_group
max_cost_by_equity_group
regions_unreachable_by_equity_group
demand_unreachable_by_equity_group
gap_absolute_weighted
gap_relative_weighted
avg_lower_third_bins
avg_middle_third_bins
avg_upper_third_bins
inter_tertile_ratio
gap_absolute_description
gap_relative_description
inter_tertile_description
=== Change vs a baseline ===
Per-person/per-region comparison against the baseline passed to solve(baseline=...) -- present only if one was supplied.
demand_improved
demand_worsened
demand_unchanged
proportion_demand_improved
proportion_demand_worsened
regions_improved
regions_worsened
regions_unchanged
mean_reduction_among_improved
mean_increase_among_worsened
max_reduction
max_increase
demand_improved_by_equity_group
demand_worsened_by_equity_group
sites_closed_vs_baseline
sites_added_vs_baseline
=== Underlying per-region data ===
The full per-region breakdown behind every metric above.
problem_df
The raw table above has 41 columns and no units in sight – useful for further analysis, but not for reading straight off in a meeting. show_solutions_summary() gives the same solution as a handful of plain-English columns instead, only including a coverage/equity/impact-vs-baseline section if that data was actually registered.
Remember we are using a public transport matrix, which is why some of these journey times look so surprising!
import pandas as pd
pd.set_option("display.max_colwidth", None) # so long text columns aren't cut off with "..."
solution_greedy_public_transport_extended.show_solutions_summary()| Rank | Sites in this option | Sites not in this option | Sites added (vs rank 1) | Sites removed (vs rank 1) | Sites changed (vs rank 1) | Average travel time (mins) | Longest journey (mins) | People within 60 mins | % within 60 mins | ... | Sites added | People with a longer journey | % of cohort with a longer journey | Avg increase for them (mins) | People with a shorter journey | % of cohort with a shorter journey | Avg reduction for them (mins) | Equity gap (mins, best vs worst group) | Equity verdict (best vs worst group) | Equity verdict (most vs least deprived third) | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | North Devon District Hospital, Honiton Hospital, Tiverton & District Hospital, Exmouth Minor Injury Unit, Victoria Hospital (Sidmouth), Newton Abbot Community Hospital, Totnes Community Hospital, NHS Walk in Centre (Exeter), Tavistock Hospital, South Hams Hospital (Kingsbridge), Cumberland Centre (Plymouth), Derriford Hospital (UTC), Dawlish Community Hospital | Ilfracombe & District Tyrrell Hospital, South Molton Hospital | 0 | 63.3 | 262.0 | 128744 | 54.6 | ... | 9669 | 4.1 | 51.2 | 0 | 0.0 | 0.0 | 50.9 | Significant Disparity (Worst group travels 131% longer) | Highly Inequitable (Most deprived travel 29% longer) |
1 rows × 21 columns
Let’s see what sites have been suggested for closure.
solution_greedy_public_transport_extended.show_solutions_summary()["Sites not in this option"]0 Ilfracombe & District Tyrrell Hospital, South Molton Hospital
Name: Sites not in this option, dtype: str
Fun fact - here’s a little Python snippet you could use instead to achieve the same thing!
list(
set(baseline.show_solutions(n_best=1).site_names.iloc[0]) -
set(solution_greedy_public_transport_extended.show_solutions(n_best=1).site_names.iloc[0])
)What does this mean for patients?
The weighted_average shift above (61.17 -> 63.26 minutes) is a small regional shift – but like any average, it dilutes what’s actually happening locally. SolutionComparator.population_impact_summary() and population_impact_phrase() diff the baseline against this closure candidate per LSOA, the same pattern used in population impact vs baseline, to show how many people are actually affected by closing Ilfracombe & District Tyrrell Hospital and South Molton Hospital, and by how much. population_impact_phrase()’s “% of the cohort” figure is a share of the 235,768 people registered via add_demand() (males aged 50-84, the M50-84 column, within this matrix’s coverage area), not the whole population.
from lokigi.site_solutions import SolutionComparator
comparator = SolutionComparator(
baseline,
solution_greedy_public_transport_extended,
labels=("Current (15 sites)", "Proposed (13 sites)"),
)
print(comparator.population_impact_phrase())9,669 people (4.1% of the cohort) get a longer journey, averaging 51.2 minutes more. 0 of 683 regions improved; 25 worsened; 658 unchanged. In the most disadvantaged third of areas, 0.0% of people see a shorter journey, compared to 0.0% in the least disadvantaged third. 5,250 people (6.3%) in the most disadvantaged third get a longer journey.
The phrase above tells us 9,669 people are worse off overall, but not where they are. population_impact_worst_affected() names the specific LSOAs hit hardest by the closure, so a service lead can ask “what do we do about these particular places?” rather than only seeing a regional total.
comparator.population_impact_worst_affected()| Before (minutes) | After (minutes) | Change (minutes) | Previous site | New site | People affected | |
|---|---|---|---|---|---|---|
| LSOA 2021 Name | ||||||
| North Devon 013D | 7.0 | 129.0 | 122.0 | South Molton Hospital | North Devon District Hospital | 533.0 |
| North Devon 013C | 10.0 | 127.0 | 117.0 | South Molton Hospital | North Devon District Hospital | 329.0 |
| North Devon 013E | 10.0 | 126.0 | 116.0 | South Molton Hospital | North Devon District Hospital | 378.0 |
| North Devon 014A | 47.0 | 131.0 | 84.0 | South Molton Hospital | North Devon District Hospital | 591.0 |
| North Devon 001A | 8.0 | 86.0 | 78.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 258.0 |
| North Devon 003B | 4.0 | 82.0 | 78.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 346.0 |
| North Devon 003A | 9.0 | 81.0 | 72.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 284.0 |
| North Devon 001B | 14.0 | 85.0 | 71.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 326.0 |
| North Devon 001C | 17.0 | 88.0 | 71.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 263.0 |
| North Devon 003D | 14.0 | 84.0 | 70.0 | Ilfracombe & District Tyrrell Hospital | North Devon District Hospital | 235.0 |
A table of LSOA names is still a lot to scan. plot_population_impact_map() shows the same change spatially – every region shaded by how much longer (red) or shorter (blue) its journey became, on a scale centred on “no change”. This is the map equivalent of population_impact_worst_affected() above: passing the same n= restricts the colouring to just the n most affected regions in one direction.
comparator.plot_population_impact_map();
Or, to match the table above exactly, restrict the map to just the 10 worst-affected regions (direction="worsened", n=10) – everywhere else greyed out for geographic context.
comparator.plot_population_impact_map(direction="worsened", n=10);
show_sites overlays the sites themselves on top of the impact map – "closed" marks just the site(s) being closed (a black X); "all" would show every candidate site, with the closed one still picked out the same way.
comparator.plot_population_impact_map(direction="worsened", show_sites="closed");
We can also show all of the sites on the map, including those remaining open.
comparator.plot_population_impact_map(show_sites="all");
Who is most affected by deprivation?
The regional total hides how the closure’s impact is spread across deprivation bands. population_impact_by_equity_group() splits the same baseline-vs-candidate diff by IMD decile band, registered above via add_equity_data().
comparator.population_impact_by_equity_group()[["demand_improved", "demand_worsened", "band_total_demand", "proportion_of_band_improved", "proportion_of_band_worsened"]]| demand_improved | demand_worsened | band_total_demand | proportion_of_band_improved | proportion_of_band_worsened | |
|---|---|---|---|---|---|
| Index of Multiple Deprivation (IMD) Decile (where 1 is most deprived 10% of LSOA | |||||
| 1 | 0.0 | 326.0 | 8147.0 | 0.0 | 0.040015 |
| 2 | 0.0 | 542.0 | 15116.0 | 0.0 | 0.035856 |
| 3 | 0.0 | 235.0 | 21236.0 | 0.0 | 0.011066 |
| 4 | 0.0 | 4147.0 | 38726.0 | 0.0 | 0.107086 |
| 5 | 0.0 | 1028.0 | 37354.0 | 0.0 | 0.027520 |
| 6 | 0.0 | 1903.0 | 31770.0 | 0.0 | 0.059899 |
| 7 | 0.0 | 453.0 | 25120.0 | 0.0 | 0.018033 |
| 8 | 0.0 | 594.0 | 28605.0 | 0.0 | 0.020766 |
| 9 | 0.0 | 441.0 | 20758.0 | 0.0 | 0.021245 |
| 10 | 0.0 | 0.0 | 8936.0 | 0.0 | 0.000000 |
proportion_of_band_worsened is the rate-normalised figure that matters here: the share of that band’s own population who got a longer journey, not the share of the region-wide worsened-total that happened to live in that band – a more deprived band could look “less affected” on a raw headcount alone purely because it’s smaller. plot_population_impact_by_equity_group() shows this for every band at once, ordered most- to least-disadvantaged.
comparator.plot_population_impact_by_equity_group();
Everything above – which sites changed, how many people are affected and by how much, the worst-hit places, and the equity picture – can also be pulled together into one board-paper-style paragraph via decision_summary(), rather than assembling the pieces by hand.
print(comparator.decision_summary())2 sites would close: Ilfracombe & District Tyrrell Hospital, South Molton Hospital.
9,669 people (4.1% of the cohort) get a longer journey, averaging 51.2 minutes more. 0 of 683 regions improved; 25 worsened; 658 unchanged. In the most disadvantaged third of areas, 0.0% of people see a shorter journey, compared to 0.0% in the least disadvantaged third. 5,250 people (6.3%) in the most disadvantaged third get a longer journey.
Hit hardest: North Devon 013D (7.0 -> 129.0); North Devon 013C (10.0 -> 127.0); North Devon 013E (10.0 -> 126.0).
Equity in the proposed network: Significant Disparity (Worst group travels 131% longer) Highly Inequitable (Most deprived travel 29% longer)
Which sites pick up the extra patients?
Closing two sites means their nearby patients now have to travel to one of the remaining 13. site_allocation_summary() shows how much of the total demand each surviving site now serves, and the average travel time for the patients allocated to it – the capacity question that naturally follows a closure decision.
solution_greedy_public_transport_extended.site_allocation_summary()| n_regions | allocated_demand | proportion | average_travel_cost | |
|---|---|---|---|---|
| site | ||||
| North Devon District Hospital | 86 | 32895 | 0.139523 | 97.302022 |
| Honiton Hospital | 26 | 10119 | 0.042919 | 77.022433 |
| Tiverton & District Hospital | 20 | 7841 | 0.033257 | 65.419844 |
| Exmouth Minor Injury Unit | 28 | 10601 | 0.044964 | 32.788322 |
| Victoria Hospital (Sidmouth) | 21 | 9476 | 0.040192 | 50.559202 |
| Newton Abbot Community Hospital | 80 | 26031 | 0.110409 | 62.077485 |
| Totnes Community Hospital | 82 | 30002 | 0.127252 | 66.614559 |
| NHS Walk in Centre (Exeter) | 117 | 37891 | 0.160713 | 65.334037 |
| Tavistock Hospital | 22 | 9369 | 0.039738 | 64.719607 |
| South Hams Hospital (Kingsbridge) | 15 | 7276 | 0.030861 | 73.377130 |
| Cumberland Centre (Plymouth) | 96 | 27312 | 0.115843 | 50.082088 |
| Derriford Hospital (UTC) | 71 | 19771 | 0.083858 | 35.779981 |
| Dawlish Community Hospital | 19 | 7184 | 0.030471 | 40.411331 |
site_allocation_summary() shows the aggregate picture after the closures, but not specifically where the two closed sites’ own patients ended up. site_reallocation_matrix() answers that directly: rows are the previous network’s sites, columns are the new network’s sites, and each cell is how much demand moved from one to the other. Reading a closed site’s row across shows exactly which surviving sites picked up its patients.
comparator.site_reallocation_matrix()| Proposed (13 sites) | North Devon District Hospital | Honiton Hospital | Tiverton & District Hospital | Exmouth Minor Injury Unit | Victoria Hospital (Sidmouth) | Newton Abbot Community Hospital | Totnes Community Hospital | NHS Walk in Centre (Exeter) | Tavistock Hospital | South Hams Hospital (Kingsbridge) | Cumberland Centre (Plymouth) | Derriford Hospital (UTC) | Dawlish Community Hospital |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Current (15 sites) | |||||||||||||
| North Devon District Hospital | 23868.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Honiton Hospital | 0.0 | 10119.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Tiverton & District Hospital | 0.0 | 0.0 | 7199.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Exmouth Minor Injury Unit | 0.0 | 0.0 | 0.0 | 10601.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Victoria Hospital (Sidmouth) | 0.0 | 0.0 | 0.0 | 0.0 | 9476.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Newton Abbot Community Hospital | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 26031.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Totnes Community Hospital | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 30002.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| NHS Walk in Centre (Exeter) | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 37891.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| Tavistock Hospital | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 9369.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| South Hams Hospital (Kingsbridge) | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 7276.0 | 0.0 | 0.0 | 0.0 |
| Cumberland Centre (Plymouth) | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 27312.0 | 0.0 | 0.0 |
| Derriford Hospital (UTC) | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 19771.0 | 0.0 |
| Dawlish Community Hospital | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 7184.0 |
| Ilfracombe & District Tyrrell Hospital | 6016.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| South Molton Hospital | 3011.0 | 0.0 | 642.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
The same table as a heatmap, so it’s easier to scan at a glance – the diagonal is demand that stayed with the same site, and the two closed sites’ rows show where their patients were redistributed.
comparator.plot_site_reallocation_matrix();
With 15 sites, most of that heatmap is an uninteresting 100%-diagonal – a site that isn’t affected by the closures shows up as a single cell equal to its own total demand and nothing else. changed_only=True drops every row/column that saw no reallocation at all, leaving just the two closed sites and whichever surviving sites actually picked up their patients.
comparator.plot_site_reallocation_matrix(changed_only=True);
Brute force
In fact, when you only want to close one or two sites, brute force (evaluating every possible combination) is often quite doable.
solution_bf_public_transport_extended = problem_public_transport_extended.solve(
p=len(problem_public_transport_extended.show_sites())-2,
search_strategy="brute-force",
threshold_for_coverage=60,
baseline=baseline # Again, still optional, but opens up lots of useful additional metrics
)We can see that in this case it’s found the same solution as our greedy option.
list(
set(solution_bf_public_transport_extended .show_solutions(n_best=1).site_names.iloc[0]) -
set(solution_greedy_public_transport_extended.show_solutions(n_best=1).site_names.iloc[0])
)[]
solution_bf_public_transport_extended.show_solutions(n_best=5)[["solution_rank", "unselected_site_names", "weighted_average", "90th_percentile", "max", "proportion_demand_worsened", "mean_increase_among_worsened"]]| solution_rank | unselected_site_names | weighted_average | 90th_percentile | max | proportion_demand_worsened | mean_increase_among_worsened | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | [Ilfracombe & District Tyrrell Hospital, South Molton Hospital] | 63.26 | 99.0 | 262.0 | 0.04 | 51.17 |
| 1 | 2 | [Tiverton & District Hospital, Ilfracombe & District Tyrrell Hospital] | 63.33 | 98.8 | 262.0 | 0.05 | 39.75 |
| 2 | 3 | [Ilfracombe & District Tyrrell Hospital, Dawlish Community Hospital] | 63.36 | 98.0 | 262.0 | 0.06 | 39.26 |
| 3 | 4 | [South Molton Hospital, Dawlish Community Hospital] | 63.42 | 98.0 | 262.0 | 0.05 | 49.01 |
| 4 | 5 | [South Hams Hospital (Kingsbridge), Ilfracombe & District Tyrrell Hospital] | 63.42 | 99.0 | 262.0 | 0.06 | 40.03 |
The table above shows unselected_site_names, but with 13 sites it’s still hard to tell at a glance which specific one or two sites actually differ between these near-identical top-5 options – there’s no baseline here, so sites_closed_vs_baseline/sites_added_vs_baseline don’t apply. show_solutions_summary()’s Sites added/Sites removed/Sites changed columns answer that directly, diffing each row against a chosen reference – by default the top-ranked solution, since no required sites are configured here.
solution_bf_public_transport_extended.show_solutions_summary(n_best=5)[["Rank", "Sites added (vs rank 1)", "Sites removed (vs rank 1)", "Sites changed (vs rank 1)"]]| Rank | Sites added (vs rank 1) | Sites removed (vs rank 1) | Sites changed (vs rank 1) | |
|---|---|---|---|---|
| 0 | 1 | 0 | ||
| 1 | 2 | South Molton Hospital | Tiverton & District Hospital | 2 |
| 2 | 3 | South Molton Hospital | Dawlish Community Hospital | 2 |
| 3 | 4 | Ilfracombe & District Tyrrell Hospital | Dawlish Community Hospital | 2 |
| 4 | 5 | South Molton Hospital | South Hams Hospital (Kingsbridge) | 2 |
However, what if we scored it on one of our new metrics - “mean_increase_among_worsened”?
solution_reranked = (
solution_bf_public_transport_extended.show_solutions().sort_values("mean_increase_among_worsened")
[["solution_rank", "unselected_site_names", "weighted_average", "90th_percentile", "max", "proportion_demand_worsened", "mean_increase_among_worsened"]]
.reset_index(drop=True)
.reset_index(drop=False, names="rank_proportion_demand_worsened")
.rename(columns={"solution_rank":"rank_weighted_average"})
)
solution_reranked["rank_proportion_demand_worsened"] = solution_reranked["rank_proportion_demand_worsened"] + 1
solution_reranked| rank_proportion_demand_worsened | rank_weighted_average | unselected_site_names | weighted_average | 90th_percentile | max | proportion_demand_worsened | mean_increase_among_worsened | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 54 | [Newton Abbot Community Hospital, Cumberland Centre (Plymouth)] | 64.65 | 97.8 | 262.0 | 0.22 | 15.61 |
| 1 | 2 | 11 | [Cumberland Centre (Plymouth), Ilfracombe & District Tyrrell Hospital] | 63.57 | 98.0 | 262.0 | 0.14 | 17.35 |
| 2 | 3 | 16 | [Tiverton & District Hospital, Cumberland Centre (Plymouth)] | 63.69 | 98.0 | 262.0 | 0.14 | 17.79 |
| 3 | 4 | 19 | [Cumberland Centre (Plymouth), Dawlish Community Hospital] | 63.72 | 97.0 | 262.0 | 0.14 | 17.82 |
| 4 | 5 | 27 | [Victoria Hospital (Sidmouth), Cumberland Centre (Plymouth)] | 63.92 | 98.0 | 262.0 | 0.15 | 18.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 100 | 101 | 100 | [Cumberland Centre (Plymouth), Derriford Hospital (UTC)] | 71.54 | 107.8 | 262.0 | 0.20 | 51.94 |
| 101 | 102 | 87 | [North Devon District Hospital, Ilfracombe & District Tyrrell Hospital] | 68.07 | 128.0 | 314.0 | 0.13 | 54.45 |
| 102 | 103 | 103 | [Exmouth Minor Injury Unit, NHS Walk in Centre (Exeter)] | 72.65 | 110.0 | 262.0 | 0.21 | 55.82 |
| 103 | 104 | 65 | [Honiton Hospital, Victoria Hospital (Sidmouth)] | 65.99 | 116.0 | 294.0 | 0.08 | 58.03 |
| 104 | 105 | 101 | [Tiverton & District Hospital, NHS Walk in Centre (Exeter)] | 72.52 | 120.8 | 262.0 | 0.19 | 59.34 |
105 rows × 8 columns
Suddenly we are seeing a very different picture!
solution_reranked[solution_reranked["rank_weighted_average"]==1]| rank_proportion_demand_worsened | rank_weighted_average | unselected_site_names | weighted_average | 90th_percentile | max | proportion_demand_worsened | mean_increase_among_worsened | |
|---|---|---|---|---|---|---|---|---|
| 97 | 98 | 1 | [Ilfracombe & District Tyrrell Hospital, South Molton Hospital] | 63.26 | 99.0 | 262.0 | 0.04 | 51.17 |
Going on just weighted average, we’d choose a very unfavourable solution!
Let’s do some comparison plots.
solution_bf_public_transport_extended.plot_best_combination(
unchosen_site_colour="magenta",
unchosen_site_opacity=1,
plot_site_allocation=True,
legend_loc="lower right",
show_all_locations=True,
label_all_locations=True,
cmap="Set3",
title_fontsize=12
);
solution_bf_public_transport_extended.plot_best_combination(
unchosen_site_colour="magenta",
unchosen_site_opacity=1,
plot_site_allocation=True,
legend_loc="lower right",
show_all_locations=True,
label_all_locations=True,
cmap="Set3",
title_fontsize=12,
sort_by="mean_increase_among_worsened"
);
solution_bf_public_transport_extended.plot_best_combination(
unchosen_site_colour="yellow",
unchosen_site_opacity=0.8,
label_all_locations=True,
sort_by="mean_increase_among_worsened"
);
solution_bf_public_transport_extended.plot_best_combination(
plot_regions_not_meeting_threshold=True, chosen_site_colour="white",
unchosen_site_colour="yellow", unchosen_site_opacity=0.8,
label_all_locations=True,
sort_by="mean_increase_among_worsened"
);
Let’s have a look at the demand improvement columns.
Don’t forget to consider boundary effects! In our example, there’s a hospital just over the border in North Cornwall, but our travel matrix only considers MIUs situated within Devon.