Spaces:
Running
Running
# print(""" | |
# __ __ _ ___ _ _ _____ _____ _ _ _ _ _ ____ _____ | |
# | \/ | / \ |_ _| \ | |_ _| ____| \ | | / \ | \ | |/ ___| ____| | |
# | |\/| | / _ \ | || \| | | | | _| | \| | / _ \ | \| | | | _| | |
# | | | |/ ___ \ | || |\ | | | | |___| |\ |/ ___ \| |\ | |___| |___ | |
# |_| |_/_/ \_\___|_| \_| |_| |_____|_| \_/_/ \_\_| \_|\____|_____| | |
# ____ ____ _____ _ _ __ | |
# | __ )| _ \| ____| / \ | |/ / | |
# | _ \| |_) | _| / _ \ | ' / | |
# | |_) | _ <| |___ / ___ \| . \ | |
# |____/|_| \_\_____/_/ \_\_|\_\ | |
# """) | |
import os | |
# os.system("pip uninstall -y gradio") | |
# os.system("pip install gradio==3.50.2") | |
# os.system("pip uninstall -y spaces") | |
# os.system("pip install spaces==0.8") | |
os.system("pip uninstall -y torch") | |
os.system("pip install torch==2.0.1") | |
import sys | |
import copy | |
import random | |
import tempfile | |
import shutil | |
import logging | |
from pathlib import Path | |
from functools import partial | |
import spaces | |
import gradio as gr | |
import torch | |
import numpy as np | |
import pandas as pd | |
from Bio.PDB.Polypeptide import protein_letters_3to1 | |
from biopandas.pdb import PandasPdb | |
from colour import Color | |
from colour import RGB_TO_COLOR_NAMES | |
from mutils.proteins import AMINO_ACID_CODES_1 | |
from mutils.pdb import download_pdb | |
from mutils.mutations import Mutation | |
from ppiref.extraction import PPIExtractor | |
from ppiref.utils.ppi import PPIPath | |
from ppiref.utils.residue import Residue | |
from ppiformer.tasks.node import DDGPPIformer | |
from ppiformer.utils.api import download_from_zenodo | |
from ppiformer.utils.api import predict_ddg as predict_ddg_ | |
from ppiformer.utils.torch import fill_diagonal | |
from ppiformer.definitions import PPIFORMER_WEIGHTS_DIR | |
import pkg_resources | |
import sys | |
def print_package_versions(): | |
installed_packages = sorted([f"{pkg.key}=={pkg.version}" for pkg in pkg_resources.working_set]) | |
print("Installed packages and their versions:") | |
for package in installed_packages: | |
print(package) | |
print("\nPython version:") | |
print(sys.version) | |
print_package_versions() | |
logging.basicConfig( | |
level=logging.INFO, | |
format='%(asctime)s - %(levelname)s - %(message)s', | |
handlers=[logging.StreamHandler(sys.stdout)] | |
) | |
random.seed(0) | |
def predict_ddg(models, ppi, muts, return_attn): | |
device = 'cuda' if torch.cuda.is_available() else 'cpu' | |
print(f"[INFO] Device on prediction: {device}") | |
models = [model.to(device) for model in models] | |
if return_attn: | |
ddg_pred, attns = predict_ddg_(models, ppi, muts, return_attn=return_attn) | |
return ddg_pred.detach().cpu(), attns.detach().cpu() | |
else: | |
ddg_pred = predict_ddg_(models, ppi, muts, return_attn=return_attn) | |
return ddg_pred.detach().cpu() | |
def process_inputs(inputs, temp_dir): | |
pdb_code, pdb_path, partners, muts, muts_path = inputs | |
# Check inputs | |
if not pdb_code and not pdb_path: | |
raise gr.Error("PPI structure not specified.") | |
if pdb_code and pdb_path: | |
gr.Warning("Both PDB code and PDB file specified. Using PDB file.") | |
if not partners: | |
raise gr.Error("Partners not specified.") | |
if not muts and not muts_path: | |
raise gr.Error("Mutations not specified.") | |
if muts and muts_path: | |
gr.Warning("Both mutations and mutations file specified. Using mutations file.") | |
# Prepare PDB input | |
if pdb_path: | |
# convert file name to PPIRef format | |
new_pdb_path = temp_dir / f"pdb/{pdb_path.name.replace('_', '-')}" | |
new_pdb_path.parent.mkdir(parents=True, exist_ok=True) | |
shutil.copy(str(pdb_path), str(new_pdb_path)) | |
pdb_path = new_pdb_path | |
pdb_path = Path(pdb_path) | |
else: | |
try: | |
pdb_code = pdb_code.strip().lower() | |
pdb_path = temp_dir / f'pdb/{pdb_code}.pdb' | |
download_pdb(pdb_code, path=pdb_path) | |
except: | |
raise gr.Error("PDB download failed.") | |
# Parse partners | |
partners = list(map(lambda x: x.strip(), partners.split(','))) | |
# Add partners to file name | |
pdb_path = pdb_path.rename(pdb_path.with_stem(f"{pdb_path.stem}-{'-'.join(partners)}")) | |
# Extract PPI into temp dir | |
try: | |
ppi_dir = temp_dir / 'ppi' | |
extractor = PPIExtractor(out_dir=ppi_dir, nest_out_dir=True, join=True, radius=10.0) | |
extractor.extract(pdb_path, partners=partners) | |
ppi_path = PPIPath.construct(ppi_dir, pdb_path.stem, partners) | |
except: | |
raise gr.Error("PPI extraction failed.") | |
# Prepare mutations input | |
if muts_path: | |
muts_path = Path(muts_path) | |
muts = muts_path.read_text() | |
# Check mutations | |
# Basic format | |
try: | |
muts = [Mutation.from_str(m) for m in muts.strip().split(';') if m.strip()] | |
except Exception as e: | |
raise gr.Error(f'Mutations parsing failed: {e}') | |
# Partners | |
for mut in muts: | |
for pmut in mut.muts: | |
if pmut.chain not in partners: | |
raise gr.Error(f'Chain of point mutation {pmut} is not in the list of partners {partners}.') | |
# Consistency with provided .pdb | |
muts_on_interface = [] | |
for mut in muts: | |
if mut.wt_in_pdb(ppi_path): | |
val = True | |
elif mut.wt_in_pdb(pdb_path): | |
val = False | |
else: | |
raise gr.Error(f'Wild-type of mutation {mut} is not in the provided .pdb file.') | |
muts_on_interface.append(val) | |
muts = [str(m) for m in muts] | |
return pdb_path, ppi_path, muts, muts_on_interface | |
def plot_3dmol(pdb_path, ppi_path, mut, attn, attn_mut_id=0): | |
# NOTE 3DMol.js adapted from https://huggingface.co/spaces/huhlim/cg2all/blob/main/app.py | |
# Read PDB for 3Dmol.js | |
with open(pdb_path, "r") as fp: | |
lines = fp.readlines() | |
mol = "" | |
for l in lines: | |
mol += l | |
mol = mol.replace("OT1", "O ") | |
mol = mol.replace("OT2", "OXT") | |
# Read PPI to customize 3Dmol.js visualization | |
ppi_df = PandasPdb().read_pdb(ppi_path).df['ATOM'] | |
ppi_df = ppi_df.groupby(list(Residue._fields)).apply(lambda df: df[df['atom_name'] == 'CA'].iloc[0]).reset_index(drop=True) | |
ppi_df['id'] = ppi_df.apply(lambda row: ':'.join([row['residue_name'], row['chain_id'], str(row['residue_number']), row['insertion']]), axis=1) | |
ppi_df['id'] = ppi_df['id'].apply(lambda x: x[:-1] if x[-1] == ':' else x) | |
muts_id = Mutation.from_str(mut).wt_to_graphein() # flatten ids of all sp muts | |
ppi_df['mutated'] = ppi_df.apply(lambda row: row['id'] in muts_id, axis=1) | |
# Prepare attention coeffictients per residue (normalized sum of direct attention from mutated residues) | |
attn = torch.nan_to_num(attn, nan=1e-10) | |
attn_sub = attn[:, attn_mut_id, 0, :, 0, :, :, :] # models, layers, heads, tokens, tokens | |
idx_mutated = torch.from_numpy(ppi_df.index[ppi_df['mutated']].to_numpy()) | |
attn_sub = fill_diagonal(attn_sub, 1e-10) | |
attn_mutated = attn_sub[..., idx_mutated, :] | |
attn_mutated.shape | |
attns_per_token = torch.sum(attn_mutated, dim=(0, 1, 2, 3)) | |
attns_per_token = (attns_per_token - attns_per_token.min()) / (attns_per_token.max() - attns_per_token.min()) | |
attns_per_token += 1e-10 | |
ppi_df['attn'] = attns_per_token.numpy() | |
chains = ppi_df.sort_values('attn', ascending=False)['chain_id'].unique() | |
# Customize 3Dmol.js visualization https://3dmol.csb.pitt.edu/doc/ | |
styles = [] | |
zoom_atoms = [] | |
# Cartoon chains | |
preferred_colors = ['LimeGreen', 'HotPink', 'RoyalBlue'] | |
all_colors = [c[0] for c in RGB_TO_COLOR_NAMES.values()] | |
all_colors = [c for c in all_colors if c not in preferred_colors + ['Black', 'White']] | |
random.shuffle(all_colors) | |
all_colors = preferred_colors + all_colors | |
all_colors = [Color(c) for c in all_colors] | |
chain_to_color = dict(zip(chains, all_colors)) | |
for chain in chains: | |
styles.append([{"chain": chain}, {"cartoon": {"color": chain_to_color[chain].hex_l, "opacity": 0.6}}]) | |
# Stick PPI and atoms for zoom | |
# TODO Insertions | |
for _, row in ppi_df.iterrows(): | |
color = copy.deepcopy(chain_to_color[row['chain_id']]) | |
color.saturation = row['attn'] | |
color = color.hex_l | |
if row['mutated']: | |
styles.append([ | |
{'chain': row['chain_id'], 'resi': str(row['residue_number'])}, | |
{'stick': {'color': 'red', 'radius': 0.2, 'opacity': 1.0}} | |
]) | |
zoom_atoms.append(row['atom_number']) | |
else: | |
styles.append([ | |
{'chain': row['chain_id'], 'resi': str(row['residue_number'])}, | |
{'stick': {'color': color, 'radius': row['attn'] / 5, 'opacity': row['attn']}} | |
]) | |
# Convert style dicts to JS lines | |
styles = ''.join(['viewer.addStyle(' + ', '.join([str(s).replace("'", '"') for s in dcts]) + ');\n' for dcts in styles]) | |
# Convert zoom atoms to 3DMol.js selection and add labels for mutated residues | |
zoom_animation_duration = 500 | |
sel = '{\"or\": [' + ', '.join(["{\"serial\": " + str(a) + "}" for a in zoom_atoms]) + ']}' | |
zoom = 'viewer.zoomTo(' + sel + ',' + f'{zoom_animation_duration});' | |
for atom in zoom_atoms: | |
sel = '{\"serial\": ' + str(atom) + '}' | |
row = ppi_df[ppi_df['atom_number'] == atom].iloc[0] | |
label = protein_letters_3to1[row['residue_name']] + row['chain_id'] + str(row['residue_number']) + row['insertion'] | |
styles += 'viewer.addLabel(' + f"\"{label}\"," + "{fontSize:16, fontColor:\"red\", backgroundOpacity: 0.0}," + sel + ');\n' | |
# Construct 3Dmol.js visualization script embedded in HTML | |
html = ( | |
"""<!DOCTYPE html> | |
<html> | |
<head> | |
<meta http-equiv="content-type" content="text/html; charset=UTF-8" /> | |
<style> | |
body{ | |
font-family:sans-serif | |
} | |
.mol-container { | |
width: 100%; | |
height: 600px; | |
position: relative; | |
} | |
.mol-container select{ | |
background-image:None; | |
} | |
</style> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.3/jquery.min.js" integrity="sha512-STof4xm1wgkfm7heWqFJVn58Hm3EtS31XFaagaa8VMReCXAkQnJZ+jEy8PCC/iT18dFy95WcExNHFTqLyp72eQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> | |
<script src="https://3Dmol.csb.pitt.edu/build/3Dmol-min.js"></script> | |
</head> | |
<body> | |
<div id="container" class="mol-container"></div> | |
<script> | |
let pdb = `""" | |
+ mol | |
+ """` | |
$(document).ready(function () { | |
let element = $("#container"); | |
let config = { backgroundColor: "white" }; | |
let viewer = $3Dmol.createViewer(element, config); | |
viewer.addModel(pdb, "pdb"); | |
viewer.setStyle({"model": 0}, {"ray_opaque_background": "off"}, {"stick": {"color": "lightgrey", "opacity": 0.5}}); | |
""" | |
+ styles | |
+ zoom | |
+ """ | |
viewer.render(); | |
}) | |
</script> | |
</body></html>""" | |
) | |
return f"""<iframe style="width: 100%; height: 600px" name="result" allow="midi; geolocation; microphone; camera; | |
display-capture; encrypted-media;" sandbox="allow-modals allow-forms | |
allow-scripts allow-same-origin allow-popups | |
allow-top-navigation-by-user-activation allow-downloads" allowfullscreen="" | |
allowpaymentrequest="" frameborder="0" srcdoc='{html}'></iframe>""" | |
def predict(models, temp_dir, *inputs): | |
logging.info('Starting prediction') | |
# Process input | |
pdb_path, ppi_path, muts, muts_on_interface = process_inputs(inputs, temp_dir) | |
# Create dataframe | |
df = pd.DataFrame({ | |
'Mutation': muts, | |
'ddG [kcal/mol]': len(muts) * [np.nan], | |
'10A Interface': muts_on_interface, | |
'Attn Id': len(muts) * [np.nan], | |
}) | |
# Show warning if some mutations are not on the interface | |
muts_not_on_interface = df[~df['10A Interface']]['Mutation'].tolist() | |
n_muts_not_on_interface = len(muts_not_on_interface) | |
if n_muts_not_on_interface: | |
n_muts_warn = 5 | |
muts_not_on_interface = ';'.join(muts_not_on_interface[:n_muts_warn]) | |
if n_muts_not_on_interface > n_muts_warn: | |
muts_not_on_interface += f'... (and {n_muts_not_on_interface - n_muts_warn} more)' | |
gr.Warning(( | |
f"{muts_not_on_interface} {'is' if n_muts_not_on_interface == 1 else 'are'} not on the interface. " | |
f"The model will predict the effect{'s' if n_muts_not_on_interface > 1 else ''} of " | |
f"mutation{'s' if n_muts_not_on_interface > 1 else ''} on the whole complex. " | |
f"This may lead to less accurate predictions." | |
)) | |
logging.info('Inputs processed') | |
# Predict using interface for mutations on the interface and using the whole complex otherwise | |
attn_ppi, attn_pdb = None, None | |
for df_sub, path in [ | |
[df[df['10A Interface']], ppi_path], | |
[df[~df['10A Interface']], pdb_path] | |
]: | |
if not len(df_sub): | |
continue | |
# Predict | |
try: | |
ddg, attn = predict_ddg(models, path, df_sub['Mutation'].tolist(), return_attn=True) | |
except Exception as e: | |
print(f"Prediction failed. {str(e)}") | |
raise gr.Error(f"Prediction failed. {str(e)}") | |
ddg = ddg.detach().numpy().tolist() | |
logging.info(f'Predictions made for {path}') | |
# Update dataframe and attention tensor | |
idx = df_sub.index | |
df.loc[idx, 'ddG [kcal/mol]'] = ddg | |
df.loc[idx, 'Attn Id'] = np.arange(len(idx)) | |
if path == ppi_path: | |
attn_ppi = attn | |
else: | |
attn_pdb = attn | |
df['Attn Id'] = df['Attn Id'].astype(int) | |
# Round ddG values | |
df['ddG [kcal/mol]'] = df['ddG [kcal/mol]'].round(3) | |
# Create PPI-specific dropdown | |
dropdown = gr.Dropdown( | |
df['Mutation'].tolist(), value=df['Mutation'].iloc[0], | |
interactive=True, visible=True, label="Mutation to visualize", | |
) | |
# Predefine plot arguments for all dropdown choices | |
dropdown_choices_to_plot_args = { | |
mut: ( | |
pdb_path, | |
ppi_path if df[df['Mutation'] == mut]['10A Interface'].iloc[0] else pdb_path, | |
mut, | |
attn_ppi if df[df['Mutation'] == mut]['10A Interface'].iloc[0] else attn_pdb, | |
df[df['Mutation'] == mut]['Attn Id'].iloc[0] | |
) | |
for mut in df['Mutation'] | |
} | |
# Create dataframe file | |
path = 'ppiformer_ddg_predictions.csv' | |
if n_muts_not_on_interface: | |
df = df[['Mutation', 'ddG [kcal/mol]', '10A Interface']] | |
df.to_csv(path, index=False) | |
df = gr.Dataframe( | |
value=df, | |
headers=['Mutation', 'ddG [kcal/mol]', '10A Interface'], | |
datatype=['str', 'number', 'bool'], | |
col_count=(3, 'fixed'), | |
) | |
else: | |
df = df[['Mutation', 'ddG [kcal/mol]']] | |
df.to_csv(path, index=False) | |
df = gr.Dataframe( | |
value=df, | |
headers=['Mutation', 'ddG [kcal/mol]'], | |
datatype=['str', 'number'], | |
col_count=(2, 'fixed'), | |
) | |
logging.info('Prediction results prepared') | |
return df, path, dropdown, dropdown_choices_to_plot_args | |
def update_plot(dropdown, dropdown_choices_to_plot_args): | |
return plot_3dmol(*dropdown_choices_to_plot_args[dropdown]) | |
app = gr.Blocks(theme=gr.themes.Default(primary_hue="green", secondary_hue="pink")) | |
with app: | |
# Input GUI | |
gr.Markdown(value=""" | |
# PPIformer Web (CPU version) | |
### Computational Design of Protein-Protein Interactions | |
""") | |
gr.Image("assets/readme-dimer-close-up.png") | |
gr.Markdown(value=""" | |
[PPIformer](https://github.com/anton-bushuiev/PPIformer/tree/main) is a state-of-the-art predictor of the effects of mutations | |
on protein-protein interactions (PPIs), as quantified by the binding free energy changes (ddG). PPIformer was shown to successfully | |
identify known favourable mutations of the [staphylokinase thrombolytics](https://pubmed.ncbi.nlm.nih.gov/10942387/) | |
and a [human antibody](https://www.pnas.org/doi/10.1073/pnas.2122954119) against the SARS-CoV-2 spike protein. The model was pre-trained | |
on the [PPIRef](https://github.com/anton-bushuiev/PPIRef) | |
dataset via a coarse-grained structural masked modeling and fine-tuned on the [SKEMPI v2.0](https://life.bsc.es/pid/skempi2) dataset via log odds. | |
Please see more details in [our ICLR 2024 paper](https://arxiv.org/abs/2310.18515). | |
**Inputs.** To use PPIformer on your data, please specify the PPI structure (PDB code or .pdb file), interacting proteins of interest | |
(chain codes in the file) and mutations (semicolon-separated list or file with mutations in the | |
[standard format](https://foldxsuite.crg.eu/parameter/mutant-file): wild-type residue, chain, residue number, mutant residue). | |
For inspiration, you can use one of the examples below: click on one of the rows to pre-fill the inputs. After specifying the inputs, | |
press the button to predict the effects of mutations on the PPI. Currently the model runs on CPU, so the predictions may take a few minutes. | |
**Outputs.** After making a prediction with the model, you will see binding free energy changes for each mutation (ddG values in kcal/mol). | |
A more negative value indicates an improvement in affinity, whereas a more positive value means a reduction in affinity. | |
Below you will also see a 3D visualization of the PPI with wild types of mutated residues highlighted in red. The visualization additionally shows | |
the attention coefficients of the model for the nearest neighboring residues, which quantifies the contribution of the residues | |
to the predicted ddG value. The brighter and thicker a residue is, the more attention the model paid to it. | |
""") | |
with gr.Row(equal_height=True): | |
with gr.Column(): | |
gr.Markdown("## PPI structure") | |
with gr.Row(equal_height=True): | |
pdb_code = gr.Textbox(placeholder="1BUI", label="PDB code", info="Protein Data Bank identifier for the structure (https://www.rcsb.org/)") | |
partners = gr.Textbox(placeholder="A,B,C", label="Partners", info="Protein chain identifiers in the PDB file forming the PPI interface (two or more)") | |
pdb_path = gr.File(file_count="single", label="Or .pdb file instead of PDB code (your structure will only be used for this prediction and not stored anywhere)") | |
with gr.Column(): | |
gr.Markdown("## Mutations") | |
muts = gr.Textbox(placeholder="SC16A;FC47A;SC16A,FC47A", label="List of (multi-point) mutations", info="SC16A;FC47A;SC16A,FC47A for three mutations: serine to alanine at position 16 in chain C, phenylalanine to alanine at position 47 in chain C, and their double-point combination") | |
muts_path = gr.File(file_count="single", label="Or file with mutations") | |
examples = gr.Examples( | |
examples=[ | |
["1BUI", "A,B,C", "SC16A,FC47A;SC16A;FC47A"], | |
["3QIB", "A,B,P,C,D", "YP7F,TP12S;YP7F;TP12S"], | |
["1KNE", "A,P", ';'.join([f"TP6{a}" for a in AMINO_ACID_CODES_1])] | |
], | |
inputs=[pdb_code, partners, muts], | |
label="Examples (click on a line to pre-fill the inputs)", | |
cache_examples=False | |
) | |
# Predict GUI | |
predict_button = gr.Button(value="Predict effects of mutations on PPI", variant="primary") | |
# Output GUI | |
gr.Markdown("## Predictions") | |
df_file = gr.File(label="Download predictions as .csv", interactive=False, visible=True) | |
df = gr.Dataframe( | |
headers=["Mutation", "ddG [kcal/mol]"], | |
datatype=["str", "number"], | |
col_count=(2, "fixed"), | |
) | |
dropdown = gr.Dropdown(interactive=True, visible=False) | |
dropdown_choices_to_plot_args = gr.State([]) | |
plot = gr.HTML() | |
# Bottom info box | |
gr.Markdown(value=""" | |
<br/> | |
## About this web | |
**Use cases**. The predictor can be used in: (i) Drug Discovery for the development of novel drugs and vaccines for various diseases such as cancer, | |
neurodegenerative disorders, and infectious diseases, (ii) Biotechnological Applications to develop new biocatalysts for biofuels, | |
industrial chemicals, and pharmaceuticals (iii) Therapeutic Protein Design to develop therapeutic proteins with enhanced stability, | |
specificity, and efficacy, and (iv) Mechanistic Studies to gain insights into fundamental biological processes, such as signal transduction, | |
gene regulation, and immune response. | |
**Acknowledgement**. Please, use the following citation to acknowledge the use of our service. The web server is provided free of charge for non-commercial use. | |
> Bushuiev, Anton, Roman Bushuiev, Petr Kouba, Anatolii Filkin, Marketa Gabrielova, Michal Gabriel, Jiri Sedlar, Tomas Pluskal, Jiri Damborsky, Stanislav Mazurenko, Josef Sivic. | |
> "Learning to design protein-protein interactions with enhanced generalization". The Twelfth International Conference on Learning Representations (ICLR 2024). | |
> [https://arxiv.org/abs/2310.18515](https://arxiv.org/abs/2310.18515). | |
**Contact**. Please share your feedback or report any bugs through [GitHub Issues](https://github.com/anton-bushuiev/PPIformer/issues/new), or feel free to contact us directly at [anton.bushuiev@cvut.cz](mailto:anton.bushuiev@cvut.cz). | |
""") | |
gr.Image("assets/logos.png") | |
# Download weights from Zenodo | |
download_from_zenodo('weights.zip') | |
# Set device | |
device = 'cuda' if torch.cuda.is_available() else 'cpu' | |
print(f"[INFO] Device on start: {device}") | |
# Load models | |
models = [ | |
DDGPPIformer.load_from_checkpoint( | |
PPIFORMER_WEIGHTS_DIR / f'ddg_regression/{i}.ckpt', | |
map_location=torch.device('cpu') | |
).eval() | |
for i in range(3) | |
] | |
models = [model.to(device) for model in models] | |
# Create temporary directory for storing downloaded PDBs and extracted PPIs | |
temp_dir_obj = tempfile.TemporaryDirectory() | |
temp_dir = Path(temp_dir_obj.name) | |
# Main logic | |
inputs = [pdb_code, pdb_path, partners, muts, muts_path] | |
outputs = [df, df_file, dropdown, dropdown_choices_to_plot_args] | |
predict = partial(predict, models, temp_dir) | |
predict_button.click(predict, inputs=inputs, outputs=outputs) | |
# Update plot on dropdown change | |
dropdown.change(update_plot, inputs=[dropdown, dropdown_choices_to_plot_args], outputs=[plot]) | |
app.launch(allowed_paths=['./assets']) | |