Eachan Johnson commited on
Commit
63c21fa
·
1 Parent(s): 55f0908

Initial commit

Browse files
Files changed (4) hide show
  1. README.md +25 -8
  2. app.py +343 -0
  3. requirements.txt +6 -0
  4. stennett2022-table1.xlsx +0 -0
README.md CHANGED
@@ -1,14 +1,31 @@
1
  ---
2
- title: Abx Discovery Resistance Dynamics
3
- emoji: âš¡
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 4.44.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
- short_description: Demo of Figure 1 from https://doi.org/10.1042/BCJ20220062
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Dynamics of antibiotic discovery and resistance
3
+ emoji: 💊
4
+ colorFrom: yellow
5
+ colorTo: orange
6
  sdk: gradio
 
7
  app_file: app.py
8
  pinned: false
 
 
9
  ---
10
 
11
+ # Dynamics of antibiotic discovery and resistance
12
+
13
+ [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/scbirlab/2024-Parkhill-BiochemJ/main?labpath=modelling-abx-discovery.ipynb)
14
+
15
+ Interface to demonstrate Figure 1 from [Parkhill SL, Johnson EO, Integrating bacterial molecular genetics with chemical biology for renewed antibacterial drug discovery, _Biochemical Journal_ (2024) 481 (13): 839–864](https://doi.org/10.1042/BCJ20220062).
16
+
17
+ Access the notebook explaining the models [here](https://github.com/scbirlab/2024-Parkhill-BiochemJ/blob/main/modelling-abx-discovery.ipynb). Run it interactively in Binder [here](https://mybinder.org/v2/gh/scbirlab/2024-Parkhill-BiochemJ/main?labpath=modelling-abx-discovery.ipynb).
18
+
19
+ **Figure 1.** The rate of antibacterial drug discovery has reached a plateau, while the rate of emergence of new resistance is constant.
20
+
21
+ <img src="nb-outputs/year-vs-ttr.png" alt="" width="300">
22
+
23
+ **(A)** The time between discovery and resistance detection compared to the discovery year for classes of antibacterial drugs. Colour indicates the time between discovery and clinical use. Outliers in time to resistance (labelled with a canonical example in parentheses) are characterised by a longer gap between discovery and clinical use.
24
+
25
+ <img src="nb-outputs/ttr-hist.png" alt="" width="300">
26
+
27
+ **(B)** The distribution of time between discovery and resistance for classes of antibacterial drugs follows an exponential distribution (Poisson maximum likelihood fit shown by blue line), with a half-life of ~15 years (95% confidence interval: 10–20 years).
28
+
29
+ <img src="nb-outputs/resistance-curves.png" alt="" width="300">
30
+
31
+ **(C)** Poisson maximum likelihood best fits for the dynamics of antibacterial drug discovery. The number of classes with resistance not-yet-detected (light blue line) as a function of time was modelled as the difference between the number of discovered classes, $D$ (orange line), and the number of classes with detected resistance, $R$ (dark blue line). $R$ was modelled as exponential growth with rate $t_{1/2}$. $D$ was modelled as the coupon collector problem when sampling from $k$ antibacterial drug classes at a constant rate $n$ after a ~28 year lag phase, $t_{\text{lag}}$. Data were obtained from [Stennett, Back, and Race](https://doi.org/10.3390/antibiotics11091237).
app.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from typing import Callable, Iterable, List, Optional, Tuple
3
+ from functools import partial
4
+ import os
5
+
6
+ import altair as alt
7
+ from carabiner import print_err, colorblind_palette
8
+ from carabiner.mpl import grid
9
+ import gradio as gr
10
+ import matplotlib.pyplot as plt
11
+ import matplotlib as mpl
12
+ import numpy as np
13
+ from numpy.typing import ArrayLike
14
+ import pandas as pd
15
+ from scipy.integrate import odeint
16
+ from scipy.optimize import approx_fprime, minimize
17
+ from scipy.stats import expon, gamma, poisson, probplot
18
+
19
+ DATA_PATH = f'{os.getcwd()}/stennett2022-table1.xlsx'
20
+ CLASS_COL = 'class'
21
+ YEAR_COL = 'year'
22
+ FIG_PANEL_SIZE = 3.5
23
+
24
+ NUMBER_DISCOVERED = "Discovered"
25
+ NUMBER_WO_R = "Without resistance"
26
+ NUMBER_W_R = "With resistance"
27
+
28
+ def load_data(
29
+ path: str = DATA_PATH,
30
+ class_col: str = CLASS_COL,
31
+ year_col_prefix: str = YEAR_COL
32
+ ) -> pd.DataFrame:
33
+ df = pd.read_excel(path)
34
+ summaries = [df.groupby(col)[[class_col]]
35
+ .agg('count')
36
+ .rename(columns={class_col: f'{col}_count'})
37
+ for col in df if col.startswith(year_col_prefix)]
38
+
39
+ df = (
40
+ pd.concat(summaries, axis=1)
41
+ .sort_index()
42
+ .fillna(0.)
43
+ .cumsum()
44
+ .reset_index()
45
+ .rename(columns={'index': year_col_prefix})
46
+ .assign(**{
47
+ NUMBER_WO_R: lambda x: x[f'{year_col_prefix}_discovered_count'] - x[f'{year_col_prefix}_resistance_count'],
48
+ "time": lambda x: x[year_col_prefix] - x[year_col_prefix].min(),
49
+ })
50
+ .rename(columns={
51
+ YEAR_COL: "Year",
52
+ f'{year_col_prefix}_discovered_count': NUMBER_DISCOVERED,
53
+ f'{year_col_prefix}_resistance_count': NUMBER_W_R,
54
+ })
55
+ )
56
+ return df
57
+
58
+
59
+ def dg_dt(params: ArrayLike) -> Callable[[ArrayLike, ArrayLike], List[np.ndarray]]:
60
+ k, n, tlag, half_life = params
61
+ slope = 1.
62
+
63
+ def f(y: ArrayLike, t: ArrayLike):
64
+ m, D, R, g = y
65
+ dm = (n / 2.) * (1. + np.tanh(((t - tlag) ** slope)))
66
+ dD = ((k - D + 1.) / k) * dm
67
+ dR = (D - R) / (half_life / np.log(2.)) # Scale to half-life
68
+ dg = dD - dR
69
+ return [dm, dD, dR, dg]
70
+
71
+ return f
72
+
73
+
74
+ def dynamic_model(
75
+ t: float,
76
+ params: ArrayLike,
77
+ y0: Optional[ArrayLike] = None
78
+ ) -> np.ndarray:
79
+ if y0 is None:
80
+ y0 = np.ones((4,))
81
+
82
+ o = odeint(
83
+ dg_dt(params),
84
+ y0=y0,
85
+ t=[0., t],
86
+ )
87
+ return o[-1,:] # -1 to take the endpoint only
88
+
89
+
90
+ def nloglik_poisson(y_pred: ArrayLike, y_true: ArrayLike) -> float:
91
+ return -np.sum(poisson.logpmf(y_true, mu=y_pred))
92
+
93
+
94
+ def dobj_fun(
95
+ model: Callable[[float, ArrayLike, Optional[ArrayLike]], np.ndarray],
96
+ df: pd.DataFrame
97
+ ) -> Callable[[ArrayLike], float]:
98
+
99
+ def _dobj_fun(params: ArrayLike) -> float:
100
+ return nloglik_poisson(
101
+ [model(t, params)[1:-1] for t in df['time']],
102
+ y_true=df[[NUMBER_DISCOVERED, NUMBER_W_R]].values,
103
+ )
104
+
105
+ return _dobj_fun
106
+
107
+ def fit_to_data(
108
+ df: pd.DataFrame,
109
+ init_params: ArrayLike
110
+ ) -> Tuple[float]:
111
+ init_params = np.asarray([float(p) for p in init_params])
112
+ print_err(f"Fitting with init params = {init_params}")
113
+ function_to_minimize = dobj_fun(dynamic_model, df)
114
+ jacobian = partial(approx_fprime, f=function_to_minimize)
115
+ print_err(f"Initial objective: {function_to_minimize(init_params)}, initial gradients:\n{jacobian(init_params)}")
116
+ do = minimize(
117
+ function_to_minimize,
118
+ x0=init_params,
119
+ jac=jacobian,
120
+ method='bfgs',
121
+ )
122
+ print(do)
123
+ return tuple(do.x.flatten())
124
+
125
+
126
+ def plot_prediction(
127
+ predicted_values: ArrayLike,
128
+ columns: ArrayLike,
129
+ index: ArrayLike,
130
+ year_col: str = "Year",
131
+ y_col: str = "Number of classes",
132
+ **kwargs
133
+ ) -> alt.Chart:
134
+ df_pred = (
135
+ pd.DataFrame(
136
+ predicted_values,
137
+ columns=columns,
138
+ index=index,
139
+ )
140
+ .reset_index(
141
+ names=year_col
142
+ )
143
+ .melt(
144
+ id_vars=year_col,
145
+ value_vars=columns,
146
+ var_name='count_type',
147
+ value_name=y_col,
148
+ )
149
+ )
150
+ return alt.Chart(df_pred).mark_line().encode(**kwargs)
151
+
152
+
153
+ def plot_data_altair(
154
+ df: pd.DataFrame,
155
+ year_col: str = "Year",
156
+ y_col: str = "Number of classes",
157
+ params: Optional[ArrayLike] = None,
158
+ add_config: bool = True,
159
+ ):
160
+
161
+ cols_to_plot = [NUMBER_DISCOVERED, NUMBER_W_R, NUMBER_WO_R]
162
+
163
+ df_m = df.melt(
164
+ id_vars=year_col,
165
+ value_vars=cols_to_plot,
166
+ var_name='count_type',
167
+ value_name=y_col,
168
+ )
169
+ print(df_m)
170
+ encoding = dict(
171
+ x=alt.X(year_col).scale(zero=False),
172
+ y=alt.Y(y_col),
173
+ color=alt.Color('count_type').title(""),
174
+ )
175
+ figure = alt.Chart(df_m).mark_circle().encode(**encoding, tooltip=[year_col, y_col])
176
+ if params is not None:
177
+ params = np.asarray([float(p) for p in params])
178
+ print_err(f"Plotting with params = {params}")
179
+ predicted_values = np.asarray([dynamic_model(t, params)[1:] for t in df['time']])
180
+ figure += plot_prediction(
181
+ predicted_values=predicted_values,
182
+ columns=cols_to_plot,
183
+ index=df[year_col],
184
+ **encoding
185
+ )
186
+
187
+ if add_config:
188
+ return figure.configure_axis(
189
+ grid=False
190
+ ).interactive()
191
+ else:
192
+ return figure
193
+
194
+
195
+ def plot_data_forecast_altair(
196
+ df: pd.DataFrame,
197
+ year_col: str = "Year",
198
+ y_col: str = "Number of classes",
199
+ params: Optional[ArrayLike] = None
200
+ ):
201
+ params = np.asarray([float(p) for p in params])
202
+ old_params, fold_changes, forecast_time = params[:4], params[4:-1], params[-1]
203
+ new_params = [old_params[0] * fold_changes[0], old_params[1] * fold_changes[1], 0., old_params[-1] * fold_changes[-1]]
204
+ print_err(f"Plotting with params = {params}, forcasting for {forecast_time} years")
205
+ cols_to_plot = [NUMBER_DISCOVERED, NUMBER_W_R, NUMBER_WO_R]
206
+
207
+ figure = plot_data_altair(df, year_col, y_col, old_params, add_config=False)
208
+ encoding = dict(
209
+ x=alt.X(year_col).scale(zero=False),
210
+ y=alt.Y(y_col),
211
+ color=alt.Color('count_type').title(""),
212
+ )
213
+ figure += alt.Chart(pd.DataFrame(dict(
214
+ Year=[df["Year"].max()],
215
+ color=["lightgrey"]
216
+ ))).mark_rule().encode(
217
+ x=alt.X('Year'),
218
+ color=alt.Color('Year:N', scale=None)
219
+ )
220
+ future_times = np.linspace(0., forecast_time, num=20)
221
+ y0 = dynamic_model(df['time'].values[-1], old_params)
222
+ new_values = np.array([dynamic_model(t, new_params, y0=y0)[1:] for t in future_times])
223
+ figure += plot_prediction(
224
+ predicted_values=new_values,
225
+ columns=cols_to_plot,
226
+ index=df["Year"].max() + future_times,
227
+ **encoding
228
+ )
229
+ return figure.configure_axis(
230
+ grid=False
231
+ ).interactive()
232
+
233
+
234
+ pool_size_title = "**Effective pool size** | _effective number of antibiotic classes being sampled by drug discovery, from the [Coupon Collector problem](https://en.wikipedia.org/wiki/Coupon_collector%27s_problem)_"
235
+ discovery_rate_title = " **Maximal rate of new discoveries** | _effective number of samples from the pool of antibiotic classes per year_"
236
+ half_life_title = "**Resistance-free half-life** | _exponential decay from start of clinical use_"
237
+
238
+
239
+ def parameter_msg(*params) -> str:
240
+
241
+ params = np.asarray([float(p) for p in params])
242
+ pool_size, discovery_rate, discovery_lag, half_life = params
243
+ return f"""
244
+ {pool_size_title} | {pool_size:.1f} classes
245
+
246
+ {discovery_rate_title} | {discovery_rate:.1f} / year
247
+
248
+ **Discovery lag** | _time to maximal discovery rate_ | {discovery_lag:.1f} years
249
+
250
+ {half_life_title} | {half_life:.1f} years
251
+
252
+ """
253
+
254
+
255
+ def forecast_msg(*params) -> str:
256
+
257
+ params = np.asarray([float(p) for p in params])
258
+ pool_size, discovery_rate, discovery_lag, half_life = params[:4]
259
+ x_pool_size, x_discovery_rate, x_half_life, _ = params[4:]
260
+
261
+ return f"""
262
+ {pool_size_title} | {pool_size:.1f} classes ⨉ {x_pool_size} = **{pool_size * x_pool_size:.1f} classes**
263
+
264
+ {discovery_rate_title} | {discovery_rate:.1f} / year ⨉ {x_discovery_rate} = **{x_discovery_rate * discovery_rate:.1f} / year**
265
+
266
+ {half_life_title} | {half_life:.1f} years ⨉ {x_half_life} = **{x_half_life * half_life:.1f} years**
267
+
268
+ """
269
+
270
+
271
+ with gr.Blocks() as demo:
272
+
273
+ data = load_data()
274
+
275
+ gr.Markdown(
276
+ """
277
+ # Dynamics of antibiotic discovery and resistance
278
+ [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/scbirlab/2024-Parkhill-BiochemJ/main?labpath=modelling-abx-discovery.ipynb)
279
+
280
+ Interface to demonstrate Figure 1 from [Parkhill SL, Johnson EO, Integrating bacterial molecular genetics with chemical biology for renewed antibacterial
281
+ drug discovery, _Biochemical Journal_ (2024) 481 (13): 839–864](https://doi.org/10.1042/BCJ20220062).
282
+
283
+ Access the notebook explaining the models [here](https://github.com/scbirlab/2024-Parkhill-BiochemJ/blob/main/modelling-abx-discovery.ipynb). Run
284
+ the notebook interactively in Binder [here](https://mybinder.org/v2/gh/scbirlab/2024-Parkhill-BiochemJ/main?labpath=modelling-abx-discovery.ipynb).
285
+
286
+ """
287
+ )
288
+
289
+ with gr.Tab("Fitting parameters"):
290
+ gr.Markdown(
291
+ """
292
+ # Finding the dynamic parameters
293
+
294
+ **Adjust the sliders** to see the dynamics change.
295
+
296
+ **Click "Fit parameters!"** to automatically find the best fitting parameters.
297
+
298
+ """
299
+ )
300
+ with gr.Row():
301
+ param_sliders = [
302
+ gr.Slider(label="Pool size", info="Effective number of antibiotic classes being sampled by drug discovery",
303
+ value=20., minimum=0., maximum=100., scale=10),
304
+ gr.Slider(label="Maximal discovery rate", info="Effective number of samples from the pool per year",
305
+ value=1., minimum=0., maximum=10., scale=10),
306
+ gr.Slider(label="Discovery lag", info="Time to maximum discovery rate",
307
+ value=25., minimum=0., maximum=100., scale=10),
308
+ gr.Slider(label="Resistance-free half-life", info="Relative to start of clinical use",
309
+ value=30., minimum=0., maximum=50., scale=10),
310
+ ]
311
+ fit_button = gr.Button("Fit parameters!", scale=6)
312
+
313
+ # with gr.Row():
314
+ fit_message = gr.Markdown(parameter_msg, inputs=param_sliders)
315
+ plot = gr.Plot(lambda *x: plot_data_altair(df=data, params=x), inputs=param_sliders, format="png", label="Model fit", scale=4)
316
+
317
+ fit_button.click(lambda *x: fit_to_data(data, init_params=x), inputs=param_sliders, outputs=param_sliders)
318
+
319
+ with gr.Tab("Forecasting the future!"):
320
+ gr.Markdown(
321
+ """
322
+ # Forecasting future discovery and resistance!
323
+
324
+ **Adjust the sliders** to see how changes these parameters would change the future.
325
+
326
+ **Click "Fit parameters!"** on the previous tab to set the parameters to fit historical data,
327
+ then come back to this tab to check the forecast.
328
+
329
+ """
330
+ )
331
+
332
+ with gr.Row():
333
+ forecast_sliders = [
334
+ gr.Slider(label="⨉ pool size", info="Increase in accessible antibiotic classes", value=1., minimum=0., maximum=10., step=.2),
335
+ gr.Slider(label="⨉ discovery rate", info="Increase in rate of discovery", value=1., minimum=0., maximum=10., step=.2),
336
+ gr.Slider(label="⨉ half-life", info="Increase in resistance-free half-life", value=1., minimum=0., maximum=10., step=.2),
337
+ gr.Slider(label="🔮", info="In years", value=100., minimum=0., maximum=200., step=.5),
338
+ ]
339
+
340
+ fit_message = gr.Markdown(forecast_msg, inputs=param_sliders + forecast_sliders)
341
+ forecast = gr.Plot(lambda *x: plot_data_forecast_altair(df=data, params=x), inputs=param_sliders + forecast_sliders, format="png", label="Forecast", scale=4)
342
+
343
+ demo.launch(share=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ altair[all]
2
+ gradio
3
+ numpy
4
+ openpyxl
5
+ pandas
6
+ scipy
stennett2022-table1.xlsx ADDED
Binary file (10.2 kB). View file