Project 3 - Scoring Protein Structures
Drug Design with PyRosetta — Comparing Structure Quality Across PDB Models
What You Will Learn
- How to download multiple versions of the same protein from the PDB
- What makes one protein structure “better” than another
- How to use Rosetta energy scores to rank structural quality
- Why experimental resolution and scoring disagreements happen
- How to automate comparisons and visualize the results
| Time: ~20–30 minutes | Prerequisite: Project 1 completed | Difficulty: Beginner |
Background: Why Are There Multiple Structures of the Same Protein?
If you search the Protein Data Bank for a famous protein like Lysozyme, you do not find just one entry — you find dozens. Each entry represents a separate experiment, often performed by a different lab, under different conditions, using different crystals.
This raises a natural question: are all these structures equally good?
The answer is no. Some structures were determined at higher resolution (meaning they captured atomic positions more precisely). Some have better-behaved chemistry. Some were solved with older methods and contain small errors. Rosetta’s energy scoring function is sensitive to these differences — a higher-quality structure will generally have a lower (more negative) Rosetta score.
In drug design, this matters enormously. If you use a poor-quality structure to design a drug, your drug may not work in the real world because the binding site geometry was slightly wrong in your model.
The Protein We Will Study: Lysozyme
We already used Lysozyme in Project 1. Here we will download five different PDB structures of Lysozyme and rank them by Rosetta energy score.
| PDB ID | Notes |
|---|---|
| 1LYZ | Classic hen egg-white lysozyme, 2.0 Å resolution |
| 2LYZ | High-resolution structure, 1.65 Å resolution |
| 4LYZ | Another well-studied variant |
| 1LSE | Lysozyme at lower resolution |
| 2LZT | Triclinic crystal form |
Resolution: Measured in Ångströms (Å). Lower number = more precise atomic positions. A resolution of 1.0 Å is exceptional; 3.0 Å is considered low resolution.
Project Workflow
Here is the complete pipeline this project follows. Each step maps to one code cell below.
Cell 1 — Initialize PyRosetta
What this cell does: Starts PyRosetta and passes it one flag: -mute all. Without this flag, PyRosetta prints hundreds of internal log lines every time it loads a structure — enough to bury your actual results. This flag silences all of that output so only your print() statements appear.
import pyrosetta
pyrosetta.init("-mute all")
print("PyRosetta ready!")
Cell 2 — Download and Score One Structure
What this cell does: Downloads a single structure (1LYZ) and scores it. This is a sanity check — we deliberately test one case by hand before writing the loop in Cell 3, so that if something goes wrong it is easy to spot.
Key functions:
| Function | What it does |
|---|---|
pyrosetta.get_fa_scorefxn() | Creates the scoring function object. Think of it as a weighing machine — you pass a protein pose to it and it returns a single energy number. |
pose_from_rcsb("1LYZ") | Downloads the PDB file from the RCSB database and loads it into a Pose object. Requires an internet connection. |
scorefxn(pose) | Calling the scoring function like a function with a pose as input returns the Rosetta energy score in REU (Rosetta Energy Units). More negative = better. |
from pyrosetta.toolbox import pose_from_rcsb
# Download one structure and score it
scorefxn = pyrosetta.get_fa_scorefxn()
pdb_id = "1LYZ"
pose = pose_from_rcsb(pdb_id)
score = scorefxn(pose)
print(f"PDB ID: {pdb_id}")
print(f"Number of residues: {pose.total_residue()}")
print(f"Rosetta score: {score:.2f} REU")
Cell 3 — Score All Five Structures Automatically
What this cell does: Loops over all five PDB IDs, scores each one, and collects the results into a DataFrame — a table-like data structure from the pandas library. Understanding how the DataFrame is built and read is the central skill in this cell.
A DataFrame is a table with labelled rows and columns — like a spreadsheet in Python. Each column has a name (a string), and each row holds the data for one observation — in this project, one protein structure.
DataFrames come from the pandas library (import pandas as pd). They are the standard tool in Python for storing and analysing tabular data.
| (index) | PDB ID | Residues | Rosetta Score (REU) | Score per Residue |
|---|---|---|---|---|
| 0 | 1LYZ | 129 | −1823.45 | −14.13 |
| 1 | 2LYZ | 129 | −1798.21 | −13.94 |
| 2 | 4LYZ | 129 | −1755.88 | −13.61 |
| 3 | 1LSE | 129 | −1699.30 | −13.17 |
| 4 | 2LZT | 129 | −1712.04 | −13.27 |
We build the DataFrame in two steps. First we collect results into a plain Python list of dictionaries, then we convert it to a DataFrame with pd.DataFrame(results).
Step 1 — a list of dictionaries. Each dictionary holds one row's worth of data. The keys become column names; the values become the cell contents:
| Python code | → becomes this row | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| { "PDB ID": "1LYZ", "Residues": 129, "Rosetta Score (REU)": −1823.45, "Score per Residue": −14.13 } |
|
Step 2 — convert the list to a DataFrame. After the loop finishes, results is a list of 5 dictionaries. pd.DataFrame(results) stacks them into a table — each dictionary becomes one row, and matching keys line up into the same column automatically.
| (index) | PDB ID | Residues | Rosetta Score (REU) | Score per Residue |
|---|---|---|---|---|
| 0 | dict 0 | … | … | … |
| 1 | dict 1 | … | … | … |
| 2 | dict 2 | … | … | … |
| 3 | dict 3 | … | … | … |
| 4 | dict 4 | … | … | … |
results = [] — this will collect one dictionary per structure.pose_from_rcsb(pdb_id).score / n_res.results with four keys: PDB ID, Residues, Rosetta Score, Score per Residue.except block catches the error, prints a message, and appends a row with "N/A" values — so the loop continues rather than crashing.results to a DataFrame with pd.DataFrame(results) and print it.import pandas as pd
# The five Lysozyme structures we will compare
pdb_ids = ["1LYZ", "2LYZ", "4LYZ", "1LSE", "2LZT"]
results = []
for pdb_id in pdb_ids:
try:
print(f"Processing {pdb_id}...", end=" ")
pose = pose_from_rcsb(pdb_id)
score = scorefxn(pose)
n_res = pose.total_residue()
results.append({
"PDB ID": pdb_id,
"Residues": n_res,
"Rosetta Score (REU)": round(score, 2),
"Score per Residue": round(score / n_res, 2)
})
print(f"done — {score:.2f} REU")
except Exception as e:
print(f"failed — {e}")
results.append({
"PDB ID": pdb_id,
"Residues": "N/A",
"Rosetta Score (REU)": "N/A",
"Score per Residue": "N/A"
})
df = pd.DataFrame(results)
print("\n" + df.to_string(index=False))
Cell 4 — Rank the Structures
What this cell does: Cleans the DataFrame (removing any failed rows), converts the Score per Residue column to numbers, sorts it, and adds a Rank column. Three DataFrame operations are used here that are worth understanding carefully.
1. Filtering rows — df[df["Score per Residue"] != "N/A"]
This keeps only rows where the Score per Residue is not the string "N/A". Think of it as asking the table: "show me only the rows that pass this test." The condition inside the outer df[…] is evaluated for every row — rows where the condition is True are kept; rows where it is False are dropped.
| (index) | PDB ID | Score per Residue |
|---|---|---|
| 0 | 1LYZ | −14.13 |
| 1 | 2LYZ | −13.94 |
| 2 | 4LYZ | N/A ← this row will be removed |
| 3 | 1LSE | −13.17 |
| 4 | 2LZT | −13.27 |
| (index) | PDB ID | Score per Residue |
|---|---|---|
| 0 | 1LYZ | −14.13 |
| 1 | 2LYZ | −13.94 |
| 3 | 1LSE | −13.17 |
| 4 | 2LZT | −13.27 |
2. Converting column type — .astype(float)
When we stored "N/A" as a fallback in Cell 3, pandas treated the entire Score per Residue column as text (strings), even the rows with real numbers. We cannot sort text values numerically — "−14.13" sorted as text does not equal −14.13 sorted as a number. .astype(float) converts the column to proper decimal numbers so sorting works correctly.
3. Sorting — .sort_values("Score per Residue", ascending=True)
Reorders the rows from the most negative (best) score to the least negative (worst). ascending=True means smallest-first — since our scores are negative, the most negative value (e.g. −14.13) is the smallest and appears at the top.
| Rank | PDB ID | Score per Residue |
|---|---|---|
| 1 | 1LYZ | −14.13 ← best |
| 2 | 2LYZ | −13.94 |
| 3 | 2LZT | −13.27 |
| 4 | 1LSE | −13.17 |
Reading a single value — df.iloc[0]
.iloc[n] selects a row by its integer position (0 = first row, −1 = last row). After sorting, iloc[0] is the best structure. We then access individual fields from it like a dictionary: best["PDB ID"], best["Score per Residue"].
# Filter out any failed entries and sort
df_clean = df[df["Score per Residue"] != "N/A"].copy()
df_clean["Score per Residue"] = df_clean["Score per Residue"].astype(float)
df_clean = df_clean.sort_values("Score per Residue", ascending=True)
df_clean["Rank"] = range(1, len(df_clean) + 1)
print("=" * 60)
print("STRUCTURE RANKING (most negative per-residue score = best)")
print("=" * 60)
print(df_clean[["Rank", "PDB ID", "Residues", "Score per Residue"]].to_string(index=False))
print("=" * 60)
best = df_clean.iloc[0]
print(f"\nBest structure: {best['PDB ID']} ({best['Score per Residue']:.2f} REU/residue)")
Cell 5 — Visualize the Comparison
What this cell does: Takes the sorted DataFrame and draws a horizontal bar chart. Each bar represents one structure; bar length encodes its score; colour encodes relative quality (darker = better).
Key functions:
| Function | What it does |
|---|---|
cm.YlOrRd_r(np.linspace(0.2, 0.8, n)) | Picks n evenly spaced colours from the yellow-to-red gradient, reversed so the best (most negative) score gets the darkest colour. np.linspace generates evenly spaced values between 0.2 and 0.8 to sample from the gradient. |
ax.barh(labels, values) | Draws horizontal bars. Labels come from the PDB ID column; values come from Score per Residue. |
ax.axvline(x=0) | Draws a vertical dashed line at x = 0 as a visual reference point. |
ax.text(x, y, label) | Places a text annotation at a specific (x, y) position in the chart — used here to print each score value inside its bar. |
plt.savefig("file.png", dpi=150) | Saves the chart as a PNG image file. dpi=150 produces a high-resolution image. |
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import numpy as np
df_plot = df_clean.sort_values("Score per Residue", ascending=True)
# Colour gradient: darker = better score
colors = cm.YlOrRd_r(np.linspace(0.2, 0.8, len(df_plot)))
fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(df_plot["PDB ID"], df_plot["Score per Residue"],
color=colors, height=0.5)
ax.set_xlabel("Score per Residue (REU)", fontsize=12)
ax.set_title("Lysozyme Structure Quality Comparison\n(More negative = better Rosetta score)",
fontsize=13, fontweight="bold")
ax.axvline(x=0, color="black", linestyle="--", linewidth=0.8)
# Annotate each bar with the score value
for bar, val in zip(bars, df_plot["Score per Residue"]):
ax.text(val - 0.1, bar.get_y() + bar.get_height() / 2,
f"{val:.2f}", va="center", ha="right",
fontsize=10, fontweight="bold", color="white")
plt.tight_layout()
plt.savefig("structure_comparison.png", dpi=150, bbox_inches="tight")
plt.show()
print("Chart saved as structure_comparison.png")
Cell 6 — Interpret the Results
What this cell does: Reads specific values out of the sorted DataFrame to compute and print the spread between the best and worst structures, then gives a practical recommendation.
There are two main ways to access a specific cell after sorting:
df.iloc[0] — select the first row (by position). Returns a Series object that behaves like a dictionary. Access a specific column with df.iloc[0]["Column Name"].
df.iloc[-1] — select the last row (−1 always means "last"). After sorting best-to-worst, this is the worst structure.
| iloc position | PDB ID | Score per Residue | Meaning |
|---|---|---|---|
| iloc[0] | 1LYZ | −14.13 | ← best structure |
| iloc[1] | 2LYZ | −13.94 | |
| iloc[2] | 2LZT | −13.27 | |
| iloc[-1] | 1LSE | −13.17 | ← worst structure |
iloc[-1] and iloc[3] refer to the same row here — both access the last row. -1 is a convenient shorthand that always means "last", regardless of how many rows there are.df_plot = df_clean.sort_values("Score per Residue")
best = df_plot.iloc[0]
worst = df_plot.iloc[-1]
spread = abs(best["Score per Residue"]) - abs(worst["Score per Residue"])
print("INTERPRETATION")
print("=" * 55)
print(f"\nBest structure: {best['PDB ID']} ({best['Score per Residue']:.2f} REU/residue)")
print(f"Worst structure: {worst['PDB ID']} ({worst['Score per Residue']:.2f} REU/residue)")
print(f"Spread: {spread:.2f} REU/residue")
print()
print("What the spread tells us:")
print(" • A larger spread means some structures are significantly")
print(" better modelled than others.")
print(" • If the spread is small (<2 REU/residue), all structures")
print(" are roughly equivalent quality.")
print(" • If the spread is large (>5 REU/residue), you should")
print(" prefer the top-ranked structure for drug design work.")
print()
print(f"Recommendation: Use {best['PDB ID']} as your starting structure.")
Discussion Questions
1. Did the highest-resolution structure (lowest Å value) always have the best Rosetta score? If not, why might they disagree?
2. We normalized scores by number of residues. What other ways might you normalize protein scores to make fair comparisons?
3. If two structures have very similar per-residue scores but one has more residues, which would you prefer for drug docking? Why?
4. Rosetta scores measure energy in a computational model. What real-world factors might make a computationally “worse” structure more useful for a specific experiment?
5. If you were designing a drug to bind Lysozyme, which structure would you choose as your starting point? Justify your answer using the data from this project.
Key Vocabulary
| Term | Definition |
|---|---|
| Resolution (Å) | How precisely atomic positions were measured. Lower = better. |
| Normalization | Dividing a raw value by a size measure to allow fair comparison |
| Score per residue | Rosetta energy score divided by the number of amino acids |
| DataFrame | A table with labelled rows and columns from the pandas library; like a spreadsheet in Python |
| List of dictionaries | The pattern used to collect row data before converting to a DataFrame; each dict = one row |
.iloc[n] | Selects a row by integer position; 0 = first row, −1 = last row |
.astype(float) | Converts a column from text strings to decimal numbers so arithmetic and sorting work correctly |
| Filtering | Selecting only the rows of a DataFrame that satisfy a condition |
| Colormap | A mapping from numerical values to colours; used for visual encoding of data |
| Sanity check | Testing code on a single known case before running a full loop |
| Spread | The range between the best and worst scores in a comparison |