Project 4 - Visualizing Drug Binding Sites
Drug Design with PyRosetta — Mapping the COX-2 Active Site
What You Will Learn
- How to identify which amino acids are physically close to a drug molecule
- How to classify residues by their chemical properties (hydrophobic, polar, charged)
- How to compute a contact map of the binding site
- Why different chemical environments favour different drugs
- How to export a binding site report for use in downstream drug design
| Time: ~30–40 minutes | Prerequisite: Projects 1–2 completed | Difficulty: Beginner |
Background: What Is a Binding Site?
When a drug molecule enters a protein and settles into its pocket, it does not interact with the entire protein — only with a small number of amino acids that happen to be close enough to touch it. These amino acids form the binding site (also called the active site or binding pocket).
Understanding the binding site is foundational to drug design for two reasons:
1. Shape complementarity: The drug must physically fit into the pocket. If the pocket is small and narrow, a large drug molecule will not fit no matter how chemically compatible it is.
2. Chemical complementarity: The drug and the pocket must also have matching chemistry. Hydrophobic (water-fearing) regions of the drug should face hydrophobic regions of the pocket; charged regions should face oppositely charged residues.
In this project, we will use COX-2 with Ibuprofen (from Project 2) and systematically identify every amino acid within 5 Ångströms of the drug — the contact shell of the binding site.
Project Workflow
Before writing any code, here is a bird’s-eye view of everything this project does. Each step below corresponds to one or two code cells.
The Distance Cutoff: Why 5 Å?
An Ångström (Å) is one ten-billionth of a metre. At the atomic scale:
| Distance | Meaning |
|---|---|
| < 2.0 Å | Overlapping atoms — a clash (problem) |
| 2.0–3.5 Å | Direct chemical bond or strong hydrogen bond |
| 3.5–5.0 Å | Van der Waals contact — atoms “touching” |
| 5.0–8.0 Å | Close neighbourhood — indirect interactions |
| > 8.0 Å | Too far away to interact meaningfully |
We use 5.0 Å as the cutoff because residues within this distance are directly contacting the drug and contributing to binding. Residues farther away are bystanders.
Cell 1 — Initialize PyRosetta with Ligand Support
What this cell does: Loads PyRosetta and tells it two things — (1) do not crash when it sees an unfamiliar molecule like Ibuprofen, and (2) stay quiet so the output is readable.
The two important flags are -ignore_unrecognized_res false, which tells PyRosetta to keep molecules it has not seen before (without this, Ibuprofen would simply be deleted from the structure), and -mute all, which suppresses hundreds of internal log lines that would otherwise flood your screen.
import pyrosetta
from pyrosetta import *
from pyrosetta.toolbox import *
pyrosetta.init(extra_options="-ignore_unrecognized_res false -load_PDB_components true -mute all")
print("PyRosetta ready!")
Cell 2 — Load COX-2 + Ibuprofen and Isolate Chain A
Why cleaning is necessary: A raw PDB file from the database is like a messy lab bench — it contains the protein you want, but also a second copy of the protein (the other chain), water molecules, salt ions, and crystallography chemicals. If we do not remove these, our distance measurements will be cluttered with irrelevant contacts.
This cell keeps only two things: the protein residues from Chain A, and the Ibuprofen molecule. Everything else is deleted.
Key functions used in this cell:
| Function | What it does |
|---|---|
pose_from_pdb("file.pdb") | Reads a PDB file from disk and builds a PyRosetta Pose object — the main data structure holding all atom coordinates. |
pose.total_residue() | Returns the total number of residues (amino acids + drug + waters) in the pose as an integer. |
pose.residue(i) | Returns the residue object at position i. Positions are numbered 1 to N (not 0-based like Python lists). |
pose.pdb_info().chain(i) | Returns the chain letter (e.g. "A" or "B") for residue at position i — lets us filter by chain. |
res.name() | Returns the full residue name. Ibuprofen appears as "pdb_IBP" — this is how we find it. |
res.is_protein() | Returns True if the residue is an amino acid, False for drugs, waters, ions, etc. |
pose.clone() | Makes a deep copy of the pose so we can delete residues from the copy without affecting the original. |
clean_pose.delete_residue_slow(i) | Removes the residue at position i from the pose. Called "slow" because it renumbers all positions afterwards — that's why we delete in reverse order. |
total_residue(). For each residue, check its chain and name — collect positions we want to keep into a list.delete_positions.ligand_pos_clean.import urllib.request
from pyrosetta import pose_from_pdb
# Download COX-2 + Ibuprofen
url = "https://files.rcsb.org/download/4PH9.pdb"
urllib.request.urlretrieve(url, "4PH9.pdb")
pose = pose_from_pdb("4PH9.pdb")
print(f"Full structure: {pose.total_residue()} residues")
# Find all residues: identify IBP (Ibuprofen) and chain A protein
ligand_position = None
keep_positions = []
for i in range(1, pose.total_residue() + 1):
res = pose.residue(i)
chain = pose.pdb_info().chain(i)
if res.name() == "pdb_IBP":
ligand_position = i
keep_positions.append(i)
print(f"Found Ibuprofen at Rosetta position {i}")
elif chain == "A" and res.is_protein():
keep_positions.append(i)
# Build clean pose: chain A protein + Ibuprofen only
clean_pose = pose.clone()
delete_positions = [i for i in range(1, pose.total_residue() + 1)
if i not in keep_positions]
for i in reversed(delete_positions):
clean_pose.delete_residue_slow(i)
print(f"Clean structure: {clean_pose.total_residue()} residues")
# Find Ibuprofen in the clean pose
for i in range(1, clean_pose.total_residue() + 1):
if not clean_pose.residue(i).is_protein():
ligand_pos_clean = i
print(f"Ibuprofen in clean pose: position {ligand_pos_clean}")
break
Cell 3 — Find All Residues Contacting the Drug
This cell finds the minimum distance between any atom in a residue and any atom in Ibuprofen. That's the true measure of whether two molecules are touching.
Key functions used in this cell:
| Function / concept | What it does |
|---|---|
res.natoms() | Returns the total atom count for a residue. Side chains vary enormously — Glycine has 7 atoms, Tryptophan has 27. |
res.atom(j).xyz() | Returns a 3D coordinate object for atom j. We access the x, y, z values individually as .x, .y, .z. |
np.array([x, y, z]) | Packages the three coordinates into a NumPy array so we can do maths on them. |
np.linalg.norm(a - b) | Computes the straight-line (Euclidean) distance between two 3D points — the standard 3D distance formula √((x₂−x₁)² + (y₂−y₁)² + (z₂−z₁)²). |
pose.pdb_info().number(i) | Returns the original PDB residue number for position i — useful for cross-referencing with published literature. |
res.name1() / res.name3() | Single-letter and three-letter amino acid codes. Serine = "S" / "SER". Both are standard biochemistry notation. |
min_distance_to_ligand() min_dist = infinity as a placeholder that any real distance will beat.j in Ibuprofen. Get its (x, y, z) coordinates as a NumPy array.k in the protein residue. Get its (x, y, z) coordinates.np.linalg.norm(lig_atom - prot_atom) to get the Euclidean distance between these two atoms.min_dist, update min_dist. After all atom pairs, return the smallest value found.min_dist ≤ 5.0 Å, record this residue — its name, PDB number, and distance — in the contact_residues list.import numpy as np
def get_residue_centroid(pose, res_num):
"""Calculate the geometric centre of all atoms in a residue."""
res = pose.residue(res_num)
coords = [np.array([res.atom(j).xyz().x,
res.atom(j).xyz().y,
res.atom(j).xyz().z])
for j in range(1, res.natoms() + 1)]
return np.mean(coords, axis=0)
def min_distance_to_ligand(pose, protein_res, ligand_res):
"""
Calculate the minimum atom-atom distance between a protein residue
and the drug molecule (minimum is more accurate than centroid-centroid).
"""
lig = pose.residue(ligand_res)
prot = pose.residue(protein_res)
min_dist = float("inf")
for j in range(1, lig.natoms() + 1):
lig_atom = np.array([lig.atom(j).xyz().x,
lig.atom(j).xyz().y,
lig.atom(j).xyz().z])
for k in range(1, prot.natoms() + 1):
prot_atom = np.array([prot.atom(k).xyz().x,
prot.atom(k).xyz().y,
prot.atom(k).xyz().z])
dist = np.linalg.norm(lig_atom - prot_atom)
if dist < min_dist:
min_dist = dist
return min_dist
# Distance cutoff for "contact"
CUTOFF = 5.0 # Ångströms
contact_residues = []
for i in range(1, clean_pose.total_residue() + 1):
if i == ligand_pos_clean:
continue # skip the drug itself
if not clean_pose.residue(i).is_protein():
continue
dist = min_distance_to_ligand(clean_pose, i, ligand_pos_clean)
if dist <= CUTOFF:
res = clean_pose.residue(i)
pdb_num = clean_pose.pdb_info().number(i)
aa_name = res.name1() # single-letter code
aa_full = res.name3() # three-letter code
contact_residues.append({
"Position (Rosetta)": i,
"Position (PDB)": pdb_num,
"AA (1-letter)": aa_name,
"AA (3-letter)": aa_full,
"Min Distance (Å)": round(dist, 2)
})
print(f"Found {len(contact_residues)} residues within {CUTOFF} Å of Ibuprofen:\n")
import pandas as pd
df_contacts = pd.DataFrame(contact_residues).sort_values("Min Distance (Å)")
print(df_contacts.to_string(index=False))
Cell 4 — Classify Residues by Chemical Property
What this cell does: Adds a Chemical Class column to the contact table, labelling each residue as hydrophobic, polar, charged positive, charged negative, or special. This classification tells you what kind of drug chemistry would fit best into the pocket.
Key concepts:
Hydrophobic residues (A, V, I, L, M, F, Y, W) have non-polar side chains that prefer to avoid water. A binding site rich in these residues wants a drug with large non-polar surfaces — like the isobutyl group on Ibuprofen.
Polar residues (S, T, N, Q) can donate or accept hydrogen bonds. A polar-rich site prefers drugs with –OH, –NH, or –C=O groups.
Charged residues (R, K, H are positive; D, E are negative) form strong electrostatic interactions. A drug with the opposite charge will be pulled in strongly.
Key Python used in this cell:
| Function / concept | What it does |
|---|---|
set("AVILMFYW") | Creates a set from a string — each character becomes one element. Sets have O(1) membership lookup, making if aa1 in HYDROPHOBIC very fast. |
df["col"].apply(func) | Applies func to every value in column col and returns a new Series. Here it classifies each one-letter amino acid code into its chemical category. |
df["col"].value_counts() | Counts how many times each unique value appears — gives the chemical profile summary (e.g. "8 hydrophobic, 3 polar…"). |
# Standard amino acid chemical classifications
HYDROPHOBIC = set("AVILMFYW") # Ala, Val, Ile, Leu, Met, Phe, Tyr, Trp
POLAR = set("STNQ") # Ser, Thr, Asn, Gln
CHARGED_POS = set("RKH") # Arg, Lys, His (positive at physiological pH)
CHARGED_NEG = set("DE") # Asp, Glu (negative at physiological pH)
SPECIAL = set("CGP") # Cys, Gly, Pro (unique properties)
def classify_aa(aa1):
if aa1 in HYDROPHOBIC: return "Hydrophobic"
if aa1 in POLAR: return "Polar"
if aa1 in CHARGED_POS: return "Charged (+)"
if aa1 in CHARGED_NEG: return "Charged (−)"
if aa1 in SPECIAL: return "Special"
return "Unknown"
df_contacts["Chemical Class"] = df_contacts["AA (1-letter)"].apply(classify_aa)
# Summary by class
print("BINDING SITE CHEMICAL PROFILE")
print("=" * 45)
class_counts = df_contacts["Chemical Class"].value_counts()
total = len(df_contacts)
for cls, count in class_counts.items():
pct = 100 * count / total
print(f" {cls:<18} {count} residues ({pct:.0f}%)")
print()
print(df_contacts[["AA (3-letter)", "Position (PDB)", "Min Distance (Å)", "Chemical Class"]]
.sort_values("Min Distance (Å)").to_string(index=False))
The table below summarises what the chemical profile of a binding site tells you about optimal drug design:
| Binding Site Character | Preferred Drug Properties |
|---|---|
| Mostly hydrophobic | Non-polar drug with aromatic rings |
| Polar-rich | Drug with hydrogen bond donors/acceptors |
| Charged residues present | Complementary charges in drug (salt bridges) |
| Mix of types | Amphiphilic drug with both polar and non-polar regions |
Cell 5 — Build a Contact Distance Map
What this cell does: Draws a horizontal bar chart — one bar per contact residue — sorted by distance. Bars are coloured by chemical class so you can immediately see the chemical character of the binding pocket at a glance.
Key Matplotlib functions used:
| Function | What it does |
|---|---|
df["col"].map(dict) | Replaces each value in a column using a dictionary as a lookup table. Here it converts class names like "Hydrophobic" into hex colour codes like "#e07b39". |
ax.barh(labels, values, color=colors) | Draws a horizontal bar chart. Each bar's length equals its distance value; its colour comes from the colors list. |
ax.axvline(x=5.0) | Draws a vertical dashed line at x = 5 Å — a visual reminder of the cutoff. |
mpatches.Patch(color, label) | Creates a coloured rectangle for use as a custom legend entry. Without this, matplotlib would not know how to label the colour-coded bars. |
plt.savefig("file.png", dpi=150) | Saves the figure to disk. dpi=150 gives a high-resolution image suitable for reports. |
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# Colour by chemical class
CLASS_COLORS = {
"Hydrophobic": "#e07b39",
"Polar": "#4a90d9",
"Charged (+)": "#2ecc71",
"Charged (−)": "#e74c3c",
"Special": "#9b59b6",
"Unknown": "#95a5a6"
}
df_plot = df_contacts.sort_values("Min Distance (Å)")
colors = df_plot["Chemical Class"].map(CLASS_COLORS)
fig, ax = plt.subplots(figsize=(12, 6))
bars = ax.barh(
df_plot["AA (3-letter)"] + " " + df_plot["Position (PDB)"].astype(str),
df_plot["Min Distance (Å)"],
color=colors, height=0.6
)
# Reference line at 5 Å cutoff
ax.axvline(x=CUTOFF, color="black", linestyle="--", linewidth=1,
label=f"{CUTOFF} Å cutoff")
ax.set_xlabel("Minimum Distance to Ibuprofen (Å)", fontsize=12)
ax.set_title("COX-2 Binding Site Contact Map\n(Ibuprofen — 4PH9)",
fontsize=13, fontweight="bold")
# Legend for chemical classes
patches = [mpatches.Patch(color=v, label=k) for k, v in CLASS_COLORS.items()
if k in df_plot["Chemical Class"].values]
ax.legend(handles=patches, loc="lower right", fontsize=9)
plt.tight_layout()
plt.savefig("binding_site_contacts.png", dpi=150, bbox_inches="tight")
plt.show()
print("Chart saved as binding_site_contacts.png")
The contact map is a standard visualization in structural biology. Each bar represents one residue; the bar length shows how close that residue is to the drug. Residues with the shortest bars are the closest contacts and are likely the most important for binding.
Cell 6 — Export the Binding Site Report
What this cell does: Prints a clean human-readable summary and then saves the full contact table as a .csv file. This file will be used directly as input in Project 5 (Alanine Scanning) — this is how computational pipelines work: the output of one analysis feeds the next.
Key function: df.to_csv("filename.csv", index=False) saves the DataFrame as a comma-separated file that can be opened in Excel, Google Sheets, or loaded back into Python with pd.read_csv(). index=False prevents pandas from writing the row numbers as an extra column.
# Full summary table
print("BINDING SITE REPORT — COX-2 + Ibuprofen (4PH9)")
print("=" * 60)
print(f"Drug: Ibuprofen (IBP)")
print(f"Protein: COX-2 (PDB 4PH9, Chain A)")
print(f"Distance cutoff: {CUTOFF} Å\n")
print(f"Total contact residues: {len(df_contacts)}")
print(f"Closest residue: {df_contacts.sort_values('Min Distance (Å)').iloc[0]['AA (3-letter)']} "
f"at {df_contacts['Min Distance (Å)'].min():.2f} Å")
print(f"Chemical composition:")
for cls, count in df_contacts["Chemical Class"].value_counts().items():
print(f" {cls}: {count}")
# Save to CSV for use in Projects 5 and 6
df_contacts.to_csv("binding_site_report.csv", index=False)
print("\nFull report saved to binding_site_report.csv")
print("You will use this file in Project 5 (Alanine Scanning).")
binding_site_report.csv is not just a summary — it is a structured data file that Project 5 will load automatically. Keep it in the same folder as your Project 5 notebook. This mirrors how real research pipelines work: each analysis step produces an output that becomes the next step's input. Discussion Questions
1. Which residue was closest to Ibuprofen? What chemical class does it belong to, and why might that matter for binding?
2. The COX-2 binding site is described as primarily hydrophobic. Does your contact map support this? What percentage of residues were hydrophobic?
3. We used a 5 Å cutoff. What would happen to your contact residue count if you changed this to 4 Å? Or 8 Å? What is the trade-off?
4. Aspirin also inhibits COX-2 but works differently — it covalently bonds to a specific serine residue. If you found a serine in your contact map, which position is it? How would covalent binding change the drug design approach?
5. If you were designing a new COX-2 inhibitor, what chemical properties would you prioritize based on the binding site profile you generated?
Key Vocabulary
| Term | Definition |
|---|---|
| Binding site | The region of a protein where a drug or ligand physically docks |
| Contact residue | An amino acid within the distance cutoff of the drug |
| Hydrophobic | Water-fearing; amino acids or drug regions that prefer non-polar environments |
| Polar | Able to form hydrogen bonds; interacts well with water and charged groups |
| Hydrogen bond | A non-covalent attractive interaction between a hydrogen donor and acceptor |
| Cutoff distance | The maximum distance used to define “close enough to interact” |
| Contact map | A visualization of pairwise distances between atoms or residues |
| Ångström (Å) | Unit of length equal to 10⁻¹⁰ metres; used to measure atomic distances |
| Min atom-atom distance | The shortest distance between any atom in residue A and any atom in residue B |
| Chemical complementarity | The matching of chemical properties (charge, polarity) between drug and binding site |