MSCBIO2025: The Right Way
Twelve stages take you from an empty laptop to publication-style figures. You will install the tools, learn pandas on a tiny table, build a project folder, then audit and plot a real dataset of more than four thousand protein variants. No biology is needed: every column is explained when you meet it.
- Run it yourself.Each stage has hands-on practice, where you write functions and small analyses, and ends with a puzzle. Run everything on your own machine and type the answer you get.
- Earn two stars a stage.The first star unlocks a harder second one. Twenty-four in all. Allow about fifteen hours with the practice exercises.
- Nothing leaves your browser.Progress is saved in this browser only. Nothing is uploaded and there is no account.
| stage | star1 | star2 | |
|---|---|---|---|
| 0 | Basecamp | NaN | NaN |
| 1 | Notebook Ridge | NaN | NaN |
| 2 | Traceback Gorge | NaN | NaN |
| 3 | Table Flats | NaN | NaN |
| 4 | Score Falls | NaN | NaN |
| 5 | Function Forge | NaN | NaN |
| 6 | Project Lodge | NaN | NaN |
| 7 | Audit Pass | NaN | NaN |
| 8 | Region Rapids | NaN | NaN |
| 9 | Histogram Meadow | NaN | NaN |
| 10 | Heatmap Heights | NaN | NaN |
| 11 | The Summit | NaN | NaN |
Basecamp: set up your tools
By the end you will have an isolated Python environment, an editor that understands it, and proof that both talk to each other. About 40 minutes.
Most beginner pain is not about Python at all. It is about which Python is running, and where. So we fix that first, once, properly.
1. Install VS Code
Download it from code.visualstudio.com and run the installer. VS Code is a text editor that grows into an IDE when you add extensions. It will be where you write scripts, run notebooks, browse files, and use a terminal, all in one window.
2. Install Miniconda from the command line
conda is a program that creates environments: separate folders, each with its own Python and its own set of libraries. Project A can use pandas 2 while project B uses pandas 3, and neither can break the other. Miniconda is the small installer: just conda and Python. We install it entirely from the terminal. Open a terminal first: Terminal on macOS or Linux, PowerShell on Windows (Start menu, type PowerShell).
Pick your system. On a Mac, check which chip you have with uname -m: arm64 means Apple silicon, x86_64 means Intel.
uname -m
# macOS, Apple silicon (arm64):
URL=https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh
# macOS, Intel (x86_64): use this line instead
# URL=https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh
# Linux (x86_64): use this line instead
# URL=https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
mkdir -p ~/miniconda3
curl -L $URL -o ~/miniconda3/miniconda.sh
bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3
rm ~/miniconda3/miniconda.sh
~/miniconda3/bin/conda init bash zshInvoke-WebRequest -Uri "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe" -OutFile ".\miniconda.exe"
Start-Process -FilePath ".\miniconda.exe" -ArgumentList "/InstallationType=JustMe","/RegisterPython=0","/S","/D=$env:USERPROFILE\miniconda3" -Wait
Remove-Item ".\miniconda.exe"
& "$env:USERPROFILE\miniconda3\Scripts\conda.exe" init powershellThe last line connects conda to your shell. Close the terminal completely and open a new one. The prompt should now start with (base). Then check:
conda --version
which python # Windows: Get-Command pythonOptional but pleasant: stop conda from activating base in every new terminal.
conda config --set auto_activate_base false3. Create the environment
One command makes a fresh environment called pandas-quest with everything this guide needs:
conda create -n pandas-quest -c conda-forge python=3.12 pandas numpy matplotlib seaborn scipy ipykernel -yThen step inside it. You will do this every time you open a new terminal:
conda activate pandas-questYour prompt now starts with (pandas-quest). That prefix is your seatbelt light: if it is missing, you are not in the environment.
4. Make a project folder and open it
mkdir pandas-quest
cd pandas-quest
code .mkdir pandas-quest
cd pandas-quest
code .VS Code opens with that folder. Always open the folder, not single files. It is what lets VS Code find your data, scripts and environment.
5. Add extensions
An extension is an add-on that teaches VS Code new skills. You install them from inside VS Code, and you will use the same few clicks every time you want a new one.
- Click the Extensions icon in the vertical bar on the far left of the window. It looks like four squares with one flying off. No icon bar? Use the menu: View, then Extensions. Keyboard shortcut: Ctrl+Shift+X (Windows, Linux) or Cmd+Shift+X (Mac).
- Click the search box at the top of the panel. It says Search Extensions in Marketplace. The Marketplace is VS Code's app store, and everything in it is free to install.
- Type the name from the table below, for example
Python. Results appear underneath, most popular first. - Pick the right result: check the publisher name under the title, and the download count (the good ones have millions or hundreds of thousands). Click the result to read its description on the right.
- Click the blue Install button. Wait a few seconds until it changes to a gear icon. That means it worked. If a Reload or Restart Extensions button appears, click it.
- Repeat for each extension in the table.
| Search for | Publisher | What it does for you |
|---|---|---|
| Python | Microsoft | Runs and debugs .py files, finds your conda environments, adds autocomplete. |
| Jupyter | Microsoft | Opens .ipynb notebooks inside VS Code and runs cells. |
| Rainbow CSV | mechatroner | Colours each column of a CSV differently so you can read raw data files. |
| Data Wrangler | Microsoft | Opens a DataFrame as a scrollable grid with summary stats. |
| Error Lens | Alexander (usernamehw) | Prints errors and warnings at the end of the offending line. Great for making errors feel normal. |
To confirm everything is installed, type @installed in the search box. You should see all five in the list. The same box shows other useful filters: @recommended for suggestions, and the clock icon for recently updated extensions.
6. Tell VS Code which Python to use
Press Ctrl/Cmd+Shift+P, type Python: Select Interpreter, and choose the one named pandas-quest. Its path contains envs/pandas-quest. A notebook has its own picker, which you will use in the next stage.
7. Run the check script
In VS Code create a new file named check_setup.py (File, New File), paste this in, save, and run it from the terminal with python check_setup.py.
import sys
import platform
print("python ", platform.python_version())
for name in ["pandas", "numpy", "matplotlib", "seaborn", "scipy"]:
module = __import__(name)
print(f"{name:<10}", module.__version__)
print("env ", sys.prefix.replace("\\", "/").split("/")[-1])You should see something shaped like this (your version numbers will differ):
python 3.12.8
pandas 3.0.1
numpy 2.3.5
matplotlib 3.10.7
seaborn 0.13.2
scipy 1.16.3
env pandas-questFirst star
Paste everything check_setup.py printed. The checker looks for Python, pandas, matplotlib and seaborn versions, and an env line that says pandas-quest.
Stuck? Nudge and solution
If a library line is missing, the install command in step 3 did not finish. Run it again and read the last lines it prints.
Second star
Now prove VS Code itself uses that environment. In the next stage you will create a notebook; for now, any notebook will do (Ctrl/Cmd+Shift+P, then Create: New Jupyter Notebook). Click Select Kernel at the top right, pick pandas-quest, run this in a cell, and paste the path it prints.
Stuck? Nudge and solution
Not seeing pandas-quest in the kernel list? Click the refresh icon in the kernel picker, or restart VS Code once. The environment needs ipykernel installed, which step 3 did.
import sys
print(sys.executable)Notebook Ridge: cells, kernels and Python basics
You will learn the one rule that explains most notebook weirdness, then use strings, loops and conditions to take a variant name apart. About 50 minutes.
A Jupyter notebook (.ipynb) mixes code, results and notes in one document. It is made of cells. Code cells run Python and show what they produce underneath. Markdown cells hold text, headings and reminders to future you.
Create notebooks/01_python_basics.ipynb in your project (make the notebooks folder if it does not exist), select the pandas-quest kernel, and follow along cell by cell.
The kernel is a running program with a memory
Behind the notebook is a kernel: a Python process that executes your cells and remembers every variable you have created. The notebook shows the code; the kernel holds the state. They can disagree. Try it:
x = 10print(x * 2)Run both: you get 20. Now edit cell 1 to x = 100 but do not run it, then re-run cell 2. Still 20. The page says 100; the kernel still thinks 10.
Printing, and what shows up on its own
The last expression in a cell is displayed automatically. print() is for everything else: checking an assumption, logging progress inside a loop, leaving yourself a trail of what happened. F-strings make labels painless:
n_reads = 1250
print(f"reads counted: {n_reads}")
print(f"as thousands: {n_reads / 1000:.2f}k")Data types, in five lines
count = 24 # int
score = 3.49 # float
name = "p.Trp18Ter" # str
is_low = score < 2 # bool
variants = ["a", "b"] # list
print(type(count), type(score), type(name), type(is_low), type(variants))type(x) is the first thing to check when something behaves strangely. It is the notebook equivalent of looking at the label on the jar.
Strings, and slicing them
A slice text[start:stop] takes characters from start up to, but not including, stop. Counting starts at 0, and negative numbers count from the end.
v = "p.Trp18Ter"
# index: 0123456789...
print(v[2:5]) # Trp
print(v[5:-3]) # 18
print(v[-3:]) # Ter
print(int(v[5:-3]) + 1) # 19
print(v.startswith("p."), v.endswith("Ter"), v.upper(), v.replace("p.", ""))Math, conditions, loops
print(7 + 2, 7 - 2, 7 * 2, 7 / 2, 7 // 2, 7 % 2, 7 ** 2) # 9 5 14 3.5 3 1 49
print(round(3.4902, 2))score = 1.4
if score < 0.5:
label = "low"
elif score < 1.0:
label = "in between"
else:
label = "wild-type-like"
print(label)names = ["WT", "p.Trp18Ter", "p.Leu25Pro"]
for i, name in enumerate(names):
print(i, name, len(name))
# the same idea in one line, called a list comprehension
lengths = [len(name) for name in names]Python reads close to English: for each name in names, print it. Indentation (four spaces) is how Python knows what is inside the loop or the if, so it is part of the grammar, not decoration.
Here is the list you will use for the puzzles. Every entry follows the pattern p. + three-letter reference + position + three-letter alternate. (Ter means the sequence stops there; the same three letters twice means nothing changed.)
variants = ["p.Gly12Gly", "p.Trp18Ter", "p.Leu25Pro", "p.Val30Asp",
"p.Gln36Ter", "p.Ile40Asn", "p.Ser45Thr", "p.Gly46Arg"]Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: count a letter
seq is a short protein sequence. Without using seq.count, use a loop to count how many times the letter "I" appears and store it in n_I.seq = "MTAIIKEIVSRNKRR"
n_I = 0
# TODO: loop over seq and add 1 to n_I for every "I"assert n_I == 3
print("all checks passed")Show a solution (try for ten minutes first)
seq = "MTAIIKEIVSRNKRR"
n_I = 0
for letter in seq:
if letter == "I":
n_I += 1Your turn: letter frequencies
counts that maps every letter of seq to how many times it occurs, for example {"M": 1, "I": 3, ...}. Hint: counts.get(letter, 0) returns 0 for a letter you have not seen yet.seq = "MTAIIKEIVSRNKRR"
counts = {}
# TODO: loop over seq and update countsassert counts["R"] == 3 and counts["I"] == 3 and counts["K"] == 2
assert sum(counts.values()) == len(seq)
print("all checks passed")Show a solution (try for ten minutes first)
seq = "MTAIIKEIVSRNKRR"
counts = {}
for letter in seq:
counts[letter] = counts.get(letter, 0) + 1Your turn: filter with a comprehension
variants, make a list late holding the positions (as integers) that are greater than 30, using a single list comprehension.variants = ["p.Gly12Gly", "p.Trp18Ter", "p.Leu25Pro", "p.Val30Asp",
"p.Gln36Ter", "p.Ile40Asn", "p.Ser45Thr", "p.Gly46Arg"]
late = [] # TODO: replace with a list comprehensionassert late == [36, 40, 45, 46]
print("all checks passed")Show a solution (try for ten minutes first)
variants = ["p.Gly12Gly", "p.Trp18Ter", "p.Leu25Pro", "p.Val30Asp",
"p.Gln36Ter", "p.Ile40Asn", "p.Ser45Thr", "p.Gly46Arg"]
late = [int(v[5:-3]) for v in variants if int(v[5:-3]) > 30]First star
Using slicing, pull the position number out of every variant, convert it to an integer, and add them all up. What is the sum?
Stuck? Nudge and solution
Every variant has the same shape, so v[5:-3] works for all of them. Loop over the list and add int(...) to a running total starting at 0.
total = 0
for v in variants:
total += int(v[5:-3])
print(total)Second star
Classify each variant with a loop and if/elif/else: synonymous if the reference and alternate letters are identical, nonsense if the alternate is Ter, otherwise missense. Report the counts in the order synonymous, nonsense, missense, separated by commas.
Stuck? Nudge and solution
Reference is v[2:5] and alternate is v[-3:]. Check synonymous first, then nonsense. Keep three counters and print them with an f-string.
syn = non = mis = 0
for v in variants:
ref, alt = v[2:5], v[-3:]
if ref == alt:
syn += 1
elif alt == "Ter":
non += 1
else:
mis += 1
print(f"{syn},{non},{mis}")Traceback Gorge: errors are directions, not insults
Every programmer sees red text all day. The skill is not avoiding it, it is reading it fast. You will learn the anatomy of an error and a repeatable way to chase one down. About 35 minutes.
An error message is the computer telling you, as precisely as it can, where it got stuck and why. It is more helpful than most feedback you will ever get. Here is a real one from a notebook:
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
Cell In[4], line 2
1 df = pd.read_csv("data/raw/toy.csv")
----> 2 print(df["Score"].mean())
File ~/miniconda3/envs/pandas-quest/lib/python3.12/site-packages/pandas/core/frame.py:4102, in DataFrame.__getitem__(self, key)
... (about thirty lines of pandas internals) ...
KeyError: 'Score'- Start at the bottom.
KeyError: 'Score'says: you asked for something by name and it is not there. The type tells you the category; the message tells you the specifics. - Find your line. The arrow
----> 2points at your code. Ignore the middle section about library internals; those are the pandas authors' lines, and the bug is almost never theirs. - Ask what you would expect. Which column names really exist?
print(df.columns). You will seescore, lowercase. Keys are case-sensitive.
The usual suspects
| Error | Plain-English meaning | First thing to try |
|---|---|---|
SyntaxError | Python could not even read the code. A missing colon, quote or bracket. Nothing ran. | Look at the line it marks and the line above. |
NameError | You used a name Python has never seen. | Typo? Or you did not run the cell that creates it (kernel memory!). |
TypeError | Right operation, wrong kind of thing, such as adding a string to a number. | print(type(x)) for each thing involved. |
ValueError | Right kind of thing, unacceptable value, such as int("abc"). | Print the value that was passed in. |
KeyError | A dictionary key or DataFrame column is not there. | df.columns. Check case and stray spaces. |
IndexError | You asked for item 10 of a list with 5 items. | len(...) before indexing. |
AttributeError | That object has no such method or attribute. | Typo, or it is a different type than you think. |
FileNotFoundError | The path points to nothing. | Print the current folder with from pathlib import Path; Path.cwd(). |
ModuleNotFoundError | The library is not installed in this environment. | Wrong kernel or interpreter. Stage 0, step 6. |
A routine that works
- Read the last line of the traceback out loud, slowly.
- Go to the line the arrow points at. Is it what you expected to run?
- Check what you assumed:
print(type(x)),df.shape,df.columns,df.head(). - Shrink the problem. Reproduce it on three rows, or in a fresh cell with two lines.
- Suspect the kernel. Restart and Run All. A surprising number of bugs vanish.
- Search the message with your own names removed:
KeyError pandas columnbeatsKeyError 'Score'. Read the answer for why it happens, not just a line to copy. - Still stuck? Write a question someone can answer: what you wanted, what you ran, the full error, what you already tried. That is also the best prompt for an AI assistant, which is a fine tool as long as you understand the fix before pasting it in.
Warm-up: four quick questions about reading errors. Then a broken script to fix on your own machine.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: fix three small bugs
names = ["WT", "p.Trp18Ter", "p.Leu25Pro"]
last = names[3] # want the last name
scores = {"WT": 3.49, "p.Trp18Ter": 1.5}
wt_score = scores["wt"] # want the WT score
reads = ["120", "80", "45"]
total = sum(reads) # want 245assert last == "p.Leu25Pro"
assert wt_score == 3.49
assert total == 245
print("all checks passed")Show a solution (try for ten minutes first)
names = ["WT", "p.Trp18Ter", "p.Leu25Pro"]
last = names[-1]
scores = {"WT": 3.49, "p.Trp18Ter": 1.5}
wt_score = scores["WT"]
reads = ["120", "80", "45"]
total = sum(int(r) for r in reads)Your turn: catch errors on purpose
try/except Exception as e, and append the name of the error type (type(e).__name__) to error_names. You will see all five names from the cheat sheet, and learn how programs survive errors.snippets = [
lambda: undefined_name,
lambda: "a" + 1,
lambda: {"a": 1}["b"],
lambda: [1, 2][5],
lambda: int("x"),
]
error_names = []
for run in snippets:
pass # TODO: try run(), except Exception as e, record type(e).__name__assert error_names == ["NameError", "TypeError", "KeyError", "IndexError", "ValueError"]
print("all checks passed")Show a solution (try for ten minutes first)
snippets = [
lambda: undefined_name,
lambda: "a" + 1,
lambda: {"a": 1}["b"],
lambda: [1, 2][5],
lambda: int("x"),
]
error_names = []
for run in snippets:
try:
run()
except Exception as e:
error_names.append(type(e).__name__)First star
Pick the best answer for each.
Stuck? Nudge and solution
Question 2: the error names the key it could not find. Compare it, character by character, with the real column name.
Second star
This script has three separate bugs. Copy it into a new file broken.py, run it with python broken.py, and fix what the errors tell you, one at a time. When it finally runs, it prints a mean score. What is that mean, to 3 decimal places?
scores = [3.45, 1.55, 2.18, 2.59, 3.82]
total = 0
for s in scores
total = total + s
mean = total / len(score)
print("mean score: " + mean)Stuck? Nudge and solution
Expect three different errors, in this order: SyntaxError (a missing character at the end of a line), then NameError (one name is misspelled), then TypeError (you cannot add a string and a float; try an f-string).
scores = [3.45, 1.55, 2.18, 2.59, 3.82]
total = 0
for s in scores:
total = total + s
mean = total / len(scores)
print(f"mean score: {mean:.3f}")Table Flats: your first DataFrame
Libraries, read a CSV, look around, pick columns and filter rows. The four moves behind most data analysis. About 45 minutes.
A library is code someone else wrote that you borrow with import. The one that matters most here is pandas, which gives Python a table type called a DataFrame: rows are observations, columns are named measurements, and every column has a type.
Our first table is a tiny made-up experiment. No biology needed: each row is one named item measured once, and four columns (bin1_count to bin4_count) say how many reads fell into each of four buckets, from the lowest signal (bin 1) to the highest (bin 4).
Make a new notebook, notebooks/02_pandas_one.ipynb, and read the CSV straight from its web address (the file is hosted on this site):
import pandas as pd
URL = "https://atharva-bhagwat.github.io/assets/data/toy_vampseq_bin_counts.csv"
df = pd.read_csv(URL)
dfLook before you touch
Before any analysis, find out what you actually have. Run each of these in its own cell:
df.head() # first 5 rows (df.tail() for the last 5)
df.shape # (rows, columns)
df.columns # the column names
df.dtypes # type of each column
df.info() # types + missing values in one view
df.describe() # count, mean, min, max... of numeric columns
df["variant_type"].value_counts() # how many rows per category
df["variant"].nunique() # how many different variantsPick columns
df["variant"] # one column -> a Series
df[["variant", "replicate"]] # several columns -> a DataFrame (note the double brackets)Pick rows by condition
A comparison on a column produces a column of True/False. Using that to index the DataFrame keeps only the True rows. This is called a boolean mask, and it is the pandas move you will use most.
is_nonsense = df["variant_type"] == "nonsense"
df[is_nonsense]
# combine conditions with & (and), | (or), ~ (not) and wrap each one in parentheses
df[(df["variant_type"] == "missense") & (df["replicate"] == 1)]
# membership and text tests
df[df["variant_type"].isin(["nonsense", "synonymous"])]
df[df["variant"].str.endswith("Arg")]
# the same filter, written as a readable string
df.query("variant_type == 'missense' and replicate == 1")Select by position or label
df.loc[0:2, ["variant", "bin1_count"]] # by label: rows 0 to 2 (inclusive!), named columns
df.iloc[0:2, 0:2] # by position: rows 0 and 1 (stop excluded), first two columnsPractice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: name the nonsense
df, make a sorted list of unique variant names whose variant_type is nonsense, called nonsense_names.nonsense_names = [] # TODO: filter df, take the variant column, make it unique and sortedassert nonsense_names == ["p.Gln36Ter", "p.Trp18Ter", "p.Tyr52Ter"]
print("all checks passed")Show a solution (try for ten minutes first)
nonsense_names = sorted(df.loc[df["variant_type"] == "nonsense", "variant"].unique())Your turn: which rows are bright?
bin4_count is greater than their bin1_count (more reads in the brightest bin than the dimmest). Store the names as a sorted list called bright. Compare two columns, then combine with the replicate filter.bright = [] # TODOassert "p.Leu25Pro" not in bright and "p.Trp18Ter" not in bright
assert "p.Lys50Arg" in bright and len(bright) == 7
print("all checks passed")Show a solution (try for ten minutes first)
rep1 = df[df["replicate"] == 1]
bright = sorted(rep1.loc[rep1["bin4_count"] > rep1["bin1_count"], "variant"])Your turn: top three
missense rows of replicate 2, sort by bin1_count from largest to smallest and keep the top 3 variant names in a list called top3.top3 = [] # TODO: filter, sort_values(..., ascending=False), head(3), then tolist()assert top3 == ["p.Leu25Pro", "p.Gly46Arg", "p.Val30Asp"]
print("all checks passed")Show a solution (try for ten minutes first)
sub = df[(df["variant_type"] == "missense") & (df["replicate"] == 2)]
top3 = sub.sort_values("bin1_count", ascending=False).head(3)["variant"].tolist()First star
How many rows are missense and from replicate 2?
Stuck? Nudge and solution
Two conditions joined with &, each in parentheses. Then count rows with len(...) or .shape[0].
sub = df[(df["variant_type"] == "missense") & (df["replicate"] == 2)]
print(len(sub))Second star
Add up every count in the four bin*_count columns across the whole table. What is the grand total of reads?
Stuck? Nudge and solution
Select the four columns as a list, then .sum() sums each column. A second .sum() adds those four numbers together.
bins = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
print(df[bins].sum().sum())Score Falls: new columns, groups and pivots
You will turn raw counts into one number per row, summarise it per variant, and line up replicates side by side. About 55 minutes.
Raw counts are hard to compare: one row has 20 reads, another has 700. A common fix is a weighted average of the bins. Give bin 1 weight 1, bin 2 weight 2, and so on. A row with all its reads in bin 1 scores 1, all in bin 4 scores 4, and anything between lands in between.
score = (1*bin1 + 2*bin2 + 3*bin3 + 4*bin4) / total_readsMake new columns without loops
pandas works on whole columns at once. That is called vectorised code. It is shorter, easier to read, and much faster than looping over rows.
bins = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
df["total_reads"] = df[bins].sum(axis=1) # axis=1: add across the columns of each row
df["score"] = (df[bins] * [1, 2, 3, 4]).sum(axis=1) / df["total_reads"]
df[["variant", "replicate", "total_reads", "score"]].round(3)Sort and rename
df.sort_values("score") # smallest first
df.sort_values("score", ascending=False)
df.rename(columns={"score": "bin_score"})Summarise by group
groupby splits the table by a column, applies a summary to each piece, and glues the answers back together: split, apply, combine.
df.groupby("variant_type")["score"].mean()
# several summaries at once, with names you choose
per_variant = df.groupby("variant").agg(
mean_score=("score", "mean"),
reads=("total_reads", "sum"),
n_rows=("score", "size"),
).reset_index()
per_variant.sort_values("mean_score")Pivot: one row per variant, one column per replicate
wide = df.pivot(index="variant", columns="replicate", values="score")
wide # columns are named 1 and 2
wide[1].corr(wide[2]) # how well do the replicates agree?wide["diff"] = (wide[1] - wide[2]).abs()
wide.sort_values("diff", ascending=False)Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: a fraction column
df already has total_reads. Add a column frac_bin4: the share of each row's reads that fall in bin 4. Then find the replicate 1 variant with the highest frac_bin4 and store its name in top_variant.top_variant = "" # TODOassert "frac_bin4" in df.columns
assert df["frac_bin4"].between(0, 1).all()
assert top_variant == "p.Lys50Arg"
print("all checks passed")Show a solution (try for ten minutes first)
df["frac_bin4"] = df["bin4_count"] / df["total_reads"]
rep1 = df[df["replicate"] == 1]
top_variant = rep1.loc[rep1["frac_bin4"].idxmax(), "variant"]Your turn: scores by type
type_means with the mean score for each variant_type. Two of its facts will be checked: which type scores lowest, and which highest.type_means = None # TODO: groupby + meanassert type_means.idxmin() == "nonsense"
assert type_means.idxmax() == "wild_type"
assert len(type_means) == 4
print("all checks passed")Show a solution (try for ten minutes first)
type_means = df.groupby("variant_type")["score"].mean()Your turn: a summary table
summary with one row per variant (variant name as the index) and two columns: mean_score (mean over replicates) and rep_diff (absolute difference between replicate 1 and 2). Combine groupby, pivot and a join, or use agg cleverly. Several routes work.summary = None # TODOassert summary.shape == (12, 2)
assert list(summary.columns) == ["mean_score", "rep_diff"]
assert abs(summary.loc["p.Ala58Val", "rep_diff"] - 1.15) < 1e-9
print("all checks passed")Show a solution (try for ten minutes first)
wide = df.pivot(index="variant", columns="replicate", values="score")
summary = pd.DataFrame({
"mean_score": wide.mean(axis=1),
"rep_diff": (wide[1] - wide[2]).abs(),
})First star
What is the mean score of the WT rows (both replicates), to 2 decimal places?
Stuck? Nudge and solution
Filter to rows where variant == "WT" and take .mean() of the score column.
print(df[df["variant"] == "WT"]["score"].mean().round(2))Second star
Pivot the scores so replicates sit side by side, and take the absolute difference between replicate 1 and 2 for each variant. Which variant has the largest disagreement? Write its name exactly as it appears.
Stuck? Nudge and solution
Use the pivot and diff code above, then idxmax() on the diff column, or sort descending and read the top row. Then look at that variant's total_reads and ask whether you trust it.
wide = df.pivot(index="variant", columns="replicate", values="score")
wide["diff"] = (wide[1] - wide[2]).abs()
print(wide["diff"].idxmax())Function Forge: write it once, reuse it forever
You will turn working notebook code into a documented function, learn the habits that make it safe to reuse, and test it. About 50 minutes.
In Stage 4 you computed scores for the whole table. Now imagine a collaborator wants a different weighting, or you want to ignore rows with too few reads, or you need the same calculation for next month's dataset. Copy and paste works exactly once. The second time you paste, the two copies start to differ, and you forget which one you trust.
Anatomy of a function
BINS = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
def add_scores(df, weights=(1, 2, 3, 4), min_reads=0):
"""Add total_reads and a weighted-average bin score.
score = sum(weight_i * count_i) / total_reads, so 1 means every read
is in bin 1 (lowest signal) and 4 means every read is in bin 4.
Rows with fewer than `min_reads` total reads get score = NaN, because
a number built from 20 reads is not one we should trust.
Returns a new DataFrame; the input is left unchanged.
"""
out = df.copy()
out["total_reads"] = out[BINS].sum(axis=1)
out["score"] = (out[BINS] * list(weights)).sum(axis=1) / out["total_reads"]
out.loc[out["total_reads"] < min_reads, "score"] = float("nan")
return outdef name(parameters):starts the recipe. Parameters with=have defaults, so callers only mention what they want to change.- The triple-quoted text right under
defis the docstring. It is whathelp(add_scores)and Shift+Tab show. returnhands the result back. A function with noreturngives backNone, a classic cause of "why is my variable empty?".df.copy()means the function works on its own copy and never changes the caller's table behind their back.
Use it
scored = add_scores(df) # defaults
strict = add_scores(df, min_reads=50) # same recipe, one dial changed
flat = add_scores(df, weights=(1, 1, 1, 1))
scored[["variant", "replicate", "total_reads", "score"]].head()Check it, so you can stop worrying about it
An assert silently passes when true and shouts when false. A few of them under a function are tiny, permanent tests:
assert len(scored) == len(df) # no rows lost
assert scored["score"].between(1, 4).all() # scores live in 1-4
assert strict["score"].isna().sum() == 2 # exactly the 2 low-read rows were blanked
before = df.columns.tolist()
add_scores(df)
assert df.columns.tolist() == before # the input was not modifiedDocumentation is a kindness to future you
- Name things for what they are.
add_scoresbeatsfunc2. Verbs for actions, nouns for data. - Docstrings say what and why; comments explain the why of a non-obvious line, not what the code already says.
- Markdown cells are your lab notebook. Before each section write one sentence: what question this answers and what you decided.
- Keep functions small and single-purpose. If the docstring needs the word "and" three times, split it.
- Show an example in the docstring or the next cell. Examples are the documentation people actually read.
The next stage moves this function out of the notebook into its own file so every notebook can import it.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: fraction_in_bin
fraction_in_bin(df, k) that returns a Series: the fraction of each row's reads that fall in bin k (1 to 4). Include a docstring, and raise ValueError("k must be 1, 2, 3 or 4") if k is anything else.def fraction_in_bin(df, k):
# TODO: docstring, validation, calculation
passf = fraction_in_bin(df, 4)
assert abs(f.iloc[0] - 376 / 694) < 1e-9
assert f.between(0, 1).all() and len(f) == len(df)
try:
fraction_in_bin(df, 5)
raise AssertionError("k=5 should raise ValueError")
except ValueError:
pass
assert fraction_in_bin.__doc__
print("all checks passed")Show a solution (try for ten minutes first)
def fraction_in_bin(df, k):
"""Fraction of each row's reads in bin k (1 to 4)."""
if k not in (1, 2, 3, 4):
raise ValueError("k must be 1, 2, 3 or 4")
bins = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
return df[f"bin{k}_count"] / df[bins].sum(axis=1)Your turn: flag_low_coverage
flag_low_coverage(df, min_reads=50) returning a boolean Series that is True for rows whose total reads (sum of the four bins) are below min_reads.def flag_low_coverage(df, min_reads=50):
pass # TODOassert flag_low_coverage(df).sum() == 2
assert flag_low_coverage(df, min_reads=10_000).all()
assert not flag_low_coverage(df, min_reads=1).any()
print("all checks passed")Show a solution (try for ten minutes first)
def flag_low_coverage(df, min_reads=50):
"""True for rows with fewer than min_reads total reads."""
bins = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
return df[bins].sum(axis=1) < min_readsYour turn: summarise_variants
add_scores from this stage. Write summarise_variants(df, min_reads=50) returning a DataFrame indexed by variant with two columns: mean_score (mean over replicates, ignoring masked rows) and n_used (how many rows contributed). Calling a function from inside another is how real code grows.def summarise_variants(df, min_reads=50):
pass # TODO: scored = add_scores(...), then groupby("variant")s = summarise_variants(df)
assert s.shape == (12, 2)
assert s.loc["p.Ala58Val", "n_used"] == 0
assert s.loc["WT", "n_used"] == 2
assert abs(s.loc["WT", "mean_score"] - 3.49) < 0.01
print("all checks passed")Show a solution (try for ten minutes first)
def summarise_variants(df, min_reads=50):
"""Mean score and number of usable rows per variant."""
scored = add_scores(df, min_reads=min_reads)
return scored.groupby("variant")["score"].agg(mean_score="mean", n_used="count")First star
Define add_scores in your notebook and call it on the toy table. What is the score of p.Ser45Thr in replicate 1, to 3 decimal places?
Stuck? Nudge and solution
Call add_scores(df), then filter on both variant and replicate, and pick the score column.
scored = add_scores(df)
row = scored[(scored["variant"] == "p.Ser45Thr") & (scored["replicate"] == 1)]
print(round(row["score"].iloc[0], 3))Second star
Reuse the same function with min_reads=50. Among rows with variant_type equal to missense, what is the mean score now (3 decimal places)? Rows with too few reads should not count. pandas skips NaN when it averages.
Stuck? Nudge and solution
One call to add_scores(df, min_reads=50), filter to missense, .mean(). If your answer feels too high or too low, check how many rows went into it with .count().
strict = add_scores(df, min_reads=50)
mis = strict[strict["variant_type"] == "missense"]
print(round(mis["score"].mean(), 3), mis["score"].count())Project Lodge: a tidy home and a trustworthy download
You will lay out a project folder, download real data with a script, prove the file is intact, and move your function into a module any notebook can import. About 50 minutes.
Analyses that survive are organised. Not fancy, just predictable. When someone (including you in six months) opens the folder, they should know where the raw data is, what is code, and what is output.
pandas-quest/
├── README.md what this is and how to run it
├── environment.yml the exact recipe for the conda environment
├── data/
│ ├── raw/ downloads exactly as received. Never edited.
│ └── derived/ tables your code produces
├── notebooks/ exploration, in numbered order
├── src/ functions you reuse (plain .py files)
└── results/
└── figures/ plots saved by code, not screenshots1. Build the skeleton
From your project folder, in the terminal where (pandas-quest) is active (this one-liner works on every operating system):
python -c "from pathlib import Path; [Path(p).mkdir(parents=True, exist_ok=True) for p in ['data/raw', 'data/derived', 'notebooks', 'src', 'results/figures']]"2. Get the data
Three files power the rest of the guide. You can click to download them by hand, but the script below is better because it is a record of where each file came from.
| File | What it is |
|---|---|
| toy_vampseq_bin_counts.csv | Invented table of read counts, used in Stages 3 to 5. |
| pten-variant-abundance.csv | The real table: one row per protein variant, used from Stage 7. |
| pten-region-annotations.csv | Three named stretches of the sequence, used in Stages 8 to 10. |
Save this as download_data.py in the project root and run python download_data.py.
"""Download the data files into data/raw/. Safe to re-run."""
from pathlib import Path
from urllib.request import urlretrieve
BASE = "https://atharva-bhagwat.github.io/assets/data/"
FILES = {
"toy_vampseq_bin_counts.csv": BASE + "toy_vampseq_bin_counts.csv",
"pten-variant-abundance.csv": BASE + "pten-variant-abundance.csv",
"pten-region-annotations.csv": BASE + "pten-region-annotations.csv",
}
out_dir = Path("data/raw")
out_dir.mkdir(parents=True, exist_ok=True)
for name, url in FILES.items():
target = out_dir / name
if target.exists():
print(f"skip {name} (already there)")
continue
urlretrieve(url, target)
print(f"saved {name} ({target.stat().st_size:,} bytes)")3. Check the file is the file
A truncated or edited download looks fine until it quietly ruins your results. A SHA-256 hash is a fingerprint of a file's exact bytes; change one character and it changes completely. The guide knows the fingerprint of the original file. Compute yours:
import hashlib
from pathlib import Path
path = Path("data/raw/pten-variant-abundance.csv")
print(hashlib.sha256(path.read_bytes()).hexdigest())4. Move your function into src/
Create src/vampseq_tools.py and paste in the function from Stage 5, with the BINS list it needs:
"""Reusable helpers for the variant-abundance analysis."""
BINS = ["bin1_count", "bin2_count", "bin3_count", "bin4_count"]
def add_scores(df, weights=(1, 2, 3, 4), min_reads=0):
"""Add total_reads and a weighted-average bin score.
score = sum(weight_i * count_i) / total_reads. Rows with fewer than
`min_reads` total reads get score = NaN. Returns a new DataFrame.
"""
out = df.copy()
out["total_reads"] = out[BINS].sum(axis=1)
out["score"] = (out[BINS] * list(weights)).sum(axis=1) / out["total_reads"]
out.loc[out["total_reads"] < min_reads, "score"] = float("nan")
return outNow any notebook can import it. Put this in the first cell of each notebook:
import sys
from pathlib import Path
# Make the project's src/ folder importable, wherever the notebook runs from
ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
sys.path.insert(0, str(ROOT / "src"))
RAW = ROOT / "data" / "raw"
%load_ext autoreload
%autoreload 2
import pandas as pd
from vampseq_tools import add_scores- Why the
sys.pathline? Python only imports from certain folders. This adds yours. - Why
%autoreload 2? The kernel remembers imported code (Stage 1!). Without autoreload, editingvampseq_tools.pychanges nothing until you restart the kernel. With it, edits are picked up on the next run.
5. Write it down: README and environment
# PTEN variant abundance, a pandas practice project
Explores a published dataset of protein variant abundance scores.
## Run it
conda env create -f environment.yml
conda activate pandas-quest
python download_data.py
python src/make_figures.py
## Layout
data/raw (never edited), data/derived, notebooks/, src/, results/figures/
## Decisions
- Score = weighted average of the four bins (weights 1-4).
- Variants with fewer than 50 reads are excluded from scores.Freeze the environment so someone else (or a new laptop) can rebuild it:
conda env export --from-history > environment.yml--from-history records only the packages you asked for, not every dependency, so the file stays portable between operating systems. To rebuild: conda env create -f environment.yml.
6. Optional but wise: version control
git init
printf "data/raw/\n__pycache__/\n.ipynb_checkpoints/\n.DS_Store\n" > .gitignore
git add .
git commit -m "Project skeleton"Commit whenever something works. A commit is a save point you can always return to, and the message is a one-line diary entry. (On Windows, create .gitignore in VS Code and paste those four lines instead.)
7. Check your skeleton
Save this as check_project.py in the project root, and run it with python check_project.py:
from pathlib import Path
expected = [
"data/raw", "data/derived", "notebooks", "src",
"results/figures", "README.md", "environment.yml",
]
found = 0
for item in expected:
ok = Path(item).exists()
found += ok
print(("[x] " if ok else "[ ] ") + item)
print(f"project check: {found}/{len(expected)}")Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: file_fingerprint
file_fingerprint(path) that returns the SHA-256 of a file as a hex string. Then add it to src/vampseq_tools.py so any notebook can import it. (Test it here first; the check writes a tiny temporary file.)import hashlib
from pathlib import Path
def file_fingerprint(path):
pass # TODOimport tempfile
with tempfile.TemporaryDirectory() as folder:
p = Path(folder) / "abc.txt"
p.write_bytes(b"abc")
assert file_fingerprint(p) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
assert file_fingerprint(str(p)) == file_fingerprint(p) # accepts a str as well
print("all checks passed")Show a solution (try for ten minutes first)
import hashlib
from pathlib import Path
def file_fingerprint(path):
"""SHA-256 of a file, as a hex string."""
return hashlib.sha256(Path(path).read_bytes()).hexdigest()Your turn: ensure_dirs
ensure_dirs(root, names) that creates each folder in names under root (including nested ones such as "results/figures") and returns the list of names it newly created. Calling it twice must not fail, and the second call returns an empty list. This is the skeleton command from earlier, turned into a reusable tool.from pathlib import Path
def ensure_dirs(root, names):
pass # TODOimport tempfile
with tempfile.TemporaryDirectory() as folder:
wanted = ["data/raw", "notebooks", "results/figures"]
assert ensure_dirs(folder, wanted) == wanted
assert all((Path(folder) / n).is_dir() for n in wanted)
assert ensure_dirs(folder, wanted) == []
print("all checks passed")Show a solution (try for ten minutes first)
from pathlib import Path
def ensure_dirs(root, names):
"""Create folders under root. Return the names that did not exist yet."""
created = []
for name in names:
path = Path(root) / name
if not path.exists():
path.mkdir(parents=True)
created.append(name)
return createdFirst star
Compute the SHA-256 of data/raw/pten-variant-abundance.csv and paste the 64 characters. The checker compares it with the fingerprint of the original file. If it does not match, the file is not the one published, so download again before going further.
Stuck? Nudge and solution
A mismatch usually means a partial download (check the file size: it should be roughly a megabyte), or the file was opened and re-saved in a spreadsheet program. Delete it and run download_data.py again.
import hashlib
from pathlib import Path
print(hashlib.sha256(Path("data/raw/pten-variant-abundance.csv").read_bytes()).hexdigest())Second star
Paste the output of check_project.py. It should end with project check: 7/7.
Stuck? Nudge and solution
Any line starting with [ ] is missing. Create it in the VS Code Explorer (right-click, New Folder or New File), or re-run the skeleton command.
Audit Pass: trust, but verify
You have been handed a table with thousands of rows. Before you plot anything, you will find out what is in it, what is missing, and whether the labels mean what they say. About 55 minutes.
Real data arrives with quirks. The habit that separates careful analysts from unlucky ones is a data audit: a short, boring, systematic look at the table before any conclusions. Make notebooks/03_data_audit.ipynb, paste in the first-cell block from Stage 6, then load the real table:
df = pd.read_csv(RAW / "pten-variant-abundance.csv")
df.head()What the columns hold, in plain terms (score is the one that matters most):
| Column | Meaning |
|---|---|
hgvs_pro | Variant name such as p.Asn12Ile: reference letters, position, new letters. This is the identity of a row. |
score | The measured abundance, already normalised: about 1 means "like the unchanged reference", about 0 means "like a broken one". |
sd, se, lower_ci, upper_ci | Spread and uncertainty of that measurement. |
expts | How many experiments contributed to the score. |
record_type | missense (one letter swapped), synonymous (no real change), nonsense (stops early), wild_type (the reference itself). |
abundance_class, abundance_class_label | A binned version of the score, as a number and as a text label. |
accession, snv | Database identifier, and a flag for whether one DNA change can produce it. |
The audit checklist
df.shape # rows, columns
df.dtypes # anything numeric stored as text?
df.isna().sum() # missing values per column
df["hgvs_pro"].is_unique # one row per variant?
df.duplicated(subset="hgvs_pro").sum() # how many repeats
df["record_type"].value_counts() # counts per type
df["abundance_class_label"].value_counts(dropna=False)
pd.crosstab(df["abundance_class"], df["abundance_class_label"]) # do number and label agree?
df[["score", "sd", "se", "expts"]].describe()Turn text into columns
The variant name packs three facts into one string. To summarise by position later, you need them as separate columns. A regular expression (regex) describes a text pattern; .str.extract pulls out the parts:
pattern = r"^p\.(?P<ref>[A-Za-z]{3})(?P<position>\d+)(?P<alt>[A-Za-z]{3}|=)$"
parts = df["hgvs_pro"].str.extract(pattern)
parts.head()| Piece | Means |
|---|---|
^ and $ | start and end of the text, so partial matches do not sneak through |
p\. | a literal p and a literal dot (the backslash cancels the dot's special meaning) |
[A-Za-z]{3} | exactly three letters |
\d+ | one or more digits |
(?P<name>...) | capture this piece into a column called name |
...|= | or an equals sign (the synonymous notation) |
Build your pattern interactively at regex101.com: paste a few variant names and watch the pieces highlight.
df["ref"] = parts["ref"]
df["position"] = pd.to_numeric(parts["position"]).astype("Int64") # nullable integer
df["alt"] = parts["alt"]
df[df["position"].isna()] # which rows did not match, and why?The Int64 (capital I) type is a nullable integer, so rows without a position can hold a proper missing value instead of forcing every number to a decimal. Rows that did not match the pattern (the wild-type reference row, for instance) simply get blanks. That is your cue to look at them, not to ignore them.
Last, test that the labels agree with the contents. If they do not, you have a problem worth knowing about now:
assert (df.loc[df["record_type"] == "nonsense", "alt"] == "Ter").all()
assert (df.loc[df["record_type"] == "synonymous", "alt"] == "=").all()
assert df.loc[df["record_type"] == "missense", "alt"].notna().all()
print("labels and names agree")Put the parser in your module
You will need this in every notebook from now on, so add it to src/vampseq_tools.py (it needs import pandas as pd at the top of that file):
def parse_hgvs(df, column="hgvs_pro"):
"""Split names like 'p.Asn12Ile' into ref, position and alt columns.
ref = 'Asn', position = 12 (nullable integer), alt = 'Ile'.
Synonymous names like 'p.Asn12=' get alt = '='. Anything that does not
match (for example the wild-type row '_wt') gets missing values.
"""
pattern = r"^p\.(?P<ref>[A-Za-z]{3})(?P<position>\d+)(?P<alt>[A-Za-z]{3}|=)$"
parts = df[column].str.extract(pattern)
out = df.copy()
out["ref"] = parts["ref"]
out["position"] = pd.to_numeric(parts["position"]).astype("Int64")
out["alt"] = parts["alt"]
return outThen in notebooks: from vampseq_tools import parse_hgvs and df = parse_hgvs(df).
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: an audit function
audit(df) returning a dictionary with keys rows, columns, missing_cells (total missing values in the whole table), duplicate_names (rows whose hgvs_pro repeats an earlier one) and by_type (a dictionary of record_type counts). Run it on every new dataset from now on.def audit(df):
pass # TODOtiny = pd.DataFrame({"hgvs_pro": ["a", "b", "b"], "score": [1.0, None, 2.0], "record_type": ["x", "x", "y"]})
r = audit(tiny)
assert r["rows"] == 3 and r["columns"] == 3
assert r["missing_cells"] == 1 and r["duplicate_names"] == 1
assert r["by_type"] == {"x": 2, "y": 1}
print(audit(df))
print("all checks passed")Show a solution (try for ten minutes first)
def audit(df):
"""Quick facts about a variant table."""
return {
"rows": len(df),
"columns": df.shape[1],
"missing_cells": int(df.isna().sum().sum()),
"duplicate_names": int(df.duplicated(subset="hgvs_pro").sum()),
"by_type": df["record_type"].value_counts().to_dict(),
}Your turn: split one variant name
split_variant(name) using re.match and the pattern from this stage. It returns a tuple (ref, position, alt) with the position as an int, or None if the name does not match.import re
def split_variant(name):
pass # TODOassert split_variant("p.Trp18Ter") == ("Trp", 18, "Ter")
assert split_variant("p.Asn12=") == ("Asn", 12, "=")
assert split_variant("_wt") is None
assert split_variant("p.Gly12_Asp13del") is None
print("all checks passed")Show a solution (try for ten minutes first)
import re
def split_variant(name):
"""Return (ref, position, alt) or None."""
m = re.match(r"^p\.([A-Za-z]{3})(\d+)([A-Za-z]{3}|=)$", name)
if m is None:
return None
return m.group(1), int(m.group(2)), m.group(3)First star
How many rows does the table have (not counting the header)?
Stuck? Nudge and solution
df.shape gives (rows, columns). len(df) gives the rows directly.
print(len(df))Second star
After parsing hgvs_pro, how many distinct positions appear in the table? Ignore rows where no position could be read.
Stuck? Nudge and solution
df["position"].nunique() already skips missing values. Without a parsed position column, try str.extract(r"(\d+)") on hgvs_pro.
df = parse_hgvs(df)
print(df["position"].nunique())Region Rapids: group, merge and summarise
You will attach a second table to the first, summarise by position and by region, and learn why every summary needs a count next to it. About 50 minutes.
Stage 7 gave you one row per variant. Questions usually live at a coarser level: per position, per region, per category. The second table lists named stretches of the sequence with start and end positions:
regions = pd.read_csv(RAW / "pten-region-annotations.csv")
regionsLabel each row with its region
Write a small function that answers "which region contains this position?" for one position, then apply it to the whole column with .map:
def find_region(position, regions):
if pd.isna(position):
return "no_position"
for r in regions.itertuples():
if r.start <= position <= r.end:
return r.region
return "other"
df["region"] = df["position"].map(lambda pos: find_region(pos, regions))
df["region"].value_counts()That logic is reusable, so it joins your module, next to parse_hgvs:
def label_regions(df, regions, position_column="position"):
"""Add a 'region' column from a table with region, start and end columns.
Positions outside every region are labelled 'other'; rows without a
position are labelled 'no_position'.
"""
def find(position):
if pd.isna(position):
return "no_position"
for r in regions.itertuples():
if r.start <= position <= r.end:
return r.region
return "other"
out = df.copy()
out["region"] = out[position_column].map(find)
return outFocus the question
Different record types answer different questions. For "how do single-letter changes affect abundance?" keep only missense rows and give that table a name:
miss = df[df["record_type"] == "missense"]
len(miss)Summarise by region, with counts
by_region = (
miss.groupby("region")["score"]
.agg(n="size", mean="mean", median="median")
.round(3)
)
by_regionSummarise by position, then merge back
by_position = (
miss.groupby("position")["score"]
.agg(n_subs="size", mean_score="mean")
.reset_index()
)
by_position.sort_values("mean_score").head(10) # the most affected positionsmerged = df.merge(by_position, on="position", how="left") # adds n_subs and mean_score to every row
assert len(merged) == len(df), "a merge changed the number of rows!"
merged[["hgvs_pro", "score", "n_subs", "mean_score"]].head()how="left" keeps every row of the left table and fills blanks where there is no match. The assert after a merge is cheap insurance: if the key is not unique on the right side, a merge silently multiplies rows.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: a reusable group summary
summarise_by(df, column) that keeps only missense rows, groups by column, and returns a DataFrame with columns n, mean and median of the score. One function now answers "by region?", "by new letters?", "by anything?".def summarise_by(df, column):
pass # TODOt = summarise_by(df, "region")
assert list(t.columns) == ["n", "mean", "median"]
assert t["n"].sum() == (df["record_type"] == "missense").sum()
assert 19 <= len(summarise_by(df, "alt")) <= 20
print("all checks passed")Show a solution (try for ten minutes first)
def summarise_by(df, column):
"""n, mean and median missense score per group."""
miss = df[df["record_type"] == "missense"]
return miss.groupby(column)["score"].agg(n="size", mean="mean", median="median")Your turn: the most sensitive positions
most_sensitive. Your miss table from this stage is the starting point.most_sensitive = [] # TODO: groupby position, filter on n, sort, head(10)g = miss.groupby("position")["score"].agg(n="size", mean="mean")
g = g[g["n"] >= 15].sort_values("mean")
assert len(most_sensitive) == 10
assert [int(p) for p in most_sensitive] == [int(p) for p in g.index[:10]]
print("all checks passed")Show a solution (try for ten minutes first)
g = miss.groupby("position")["score"].agg(n="size", mean="mean")
g = g[g["n"] >= 15].sort_values("mean")
most_sensitive = list(g.index[:10])Your turn: attach the notes
annotation column with a plain-English description. Merge it onto df through the region column so every row gets its description (rows with no region stay, with a blank). Call the result with_notes, and make sure the row count did not change.with_notes = None # TODO: df.merge(...)assert len(with_notes) == len(df)
assert "annotation" in with_notes.columns
assert with_notes.loc[with_notes["region"] == "phosphatase_domain", "annotation"].notna().all()
print("all checks passed")Show a solution (try for ten minutes first)
with_notes = df.merge(regions[["region", "annotation"]], on="region", how="left")First star
Using only missense rows, what is the mean score of variants in the phosphatase_domain region, to 3 decimal places? (Regions include both their start and end positions.)
Stuck? Nudge and solution
Parse positions, label regions, filter on record_type == "missense" and region == "phosphatase_domain", then .mean() of score. If your number includes nonsense or synonymous rows, it will be off.
sub = df[(df["record_type"] == "missense") & (df["region"] == "phosphatase_domain")]
print(round(sub["score"].mean(), 3), len(sub))Second star
Among the three annotated regions (phosphatase_domain, c2_domain, disordered_tail), which has the lowest median missense score? Write the region name.
Stuck? Nudge and solution
The by_region table above has the medians. Ignore the rows named other and no_position, then look at the smallest median.
by_region = miss.groupby("region")["score"].median()
print(by_region.drop(["other", "no_position"], errors="ignore").idxmin())Histogram Meadow: plots for distributions
Seven plots for the question every experimentalist asks first: what does my data look like, and do my groups differ? You will learn when each one is the right choice. About 60 minutes.
Make notebooks/04_distributions.ipynb, paste the first-cell block from Stage 6, then load, parse and label the table (your module does the work):
from vampseq_tools import parse_hgvs, label_regions
df = pd.read_csv(RAW / "pten-variant-abundance.csv")
regions = pd.read_csv(RAW / "pten-region-annotations.csv")
df = label_regions(parse_hgvs(df), regions)
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid", context="notebook", palette="colorblind")
TYPES = ["synonymous", "missense", "nonsense"]
data = df[df["record_type"].isin(TYPES)]Two libraries, one mental model
matplotlib is the foundation: a figure is the canvas and an axes is one chart on it. seaborn sits on top and draws statistical plots from a DataFrame in one line. The pattern you will repeat all stage:
fig, ax = plt.subplots(figsize=(6, 4)) # make a canvas with one chart
sns.<something>(data=..., x=..., y=..., ax=ax) # draw onto that chart
ax.set(xlabel="...", ylabel="...", title="...") # label itWhich plot answers which question?
| Question | Plot | Watch out for |
|---|---|---|
| What is the shape of one variable? | Histogram | The number of bins changes the picture. Try 20, 40 and 80. |
| Same, smoothed, for several groups | KDE (density) | Smoothing can invent bumps and spill past the real range. |
| Compare distributions without choosing bins | ECDF | Less familiar, but the most honest. Read it as "what fraction is below this value". |
| Compare group centres and spread | Box plot | Hides the shape. Two humps look like one box. |
| Compare group shapes | Violin | Needs plenty of points per group. |
| Show every observation | Strip plot | Overlapping points. Use alpha and a small size. |
| How many of each category? | Count / bar plot | Start bars at zero. Always. |
The gallery
Each cell below is a complete plot. Run them, change one thing, run again.
fig, ax = plt.subplots(figsize=(6, 4))
sns.histplot(data=data[data["record_type"] == "missense"], x="score", bins=40, ax=ax)
ax.set(xlabel="abundance score", ylabel="number of variants", title="Histogram: missense variants")fig, ax = plt.subplots(figsize=(6, 4))
sns.kdeplot(data=data, x="score", hue="record_type", hue_order=TYPES,
common_norm=False, fill=True, alpha=0.3, ax=ax)
ax.set(xlabel="abundance score", title="KDE: one curve per record type")fig, ax = plt.subplots(figsize=(6, 4))
sns.ecdfplot(data=data, x="score", hue="record_type", hue_order=TYPES, ax=ax)
ax.set(xlabel="abundance score", ylabel="fraction of variants at or below", title="ECDF")fig, ax = plt.subplots(figsize=(6, 4))
sns.boxplot(data=data, x="record_type", y="score", order=TYPES, ax=ax)
ax.set(xlabel="", ylabel="abundance score", title="Box plot")fig, ax = plt.subplots(figsize=(6, 4))
sns.violinplot(data=data, x="record_type", y="score", order=TYPES, inner="quart", cut=0, ax=ax)
ax.set(xlabel="", ylabel="abundance score", title="Violin plot")fig, ax = plt.subplots(figsize=(6, 4))
sns.stripplot(data=data, x="record_type", y="score", order=TYPES, size=2, alpha=0.4, jitter=0.25, ax=ax)
ax.set(xlabel="", ylabel="abundance score", title="Strip plot: every variant is a dot")fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(data=data, x="record_type", order=TYPES, ax=ax)
ax.set(xlabel="", ylabel="number of variants", title="Count plot")Assemble a multi-panel figure and save it
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
sns.histplot(data=data[data["record_type"] == "missense"], x="score", bins=40, ax=axes[0])
sns.ecdfplot(data=data, x="score", hue="record_type", hue_order=TYPES, ax=axes[1])
sns.boxplot(data=data, x="record_type", y="score", order=TYPES, ax=axes[2])
for ax, letter in zip(axes, "abc"):
ax.set_title(letter, loc="left", fontweight="bold")
axes[0].set(xlabel="abundance score", ylabel="number of variants")
axes[1].set(xlabel="abundance score", ylabel="cumulative fraction")
axes[2].set(xlabel="", ylabel="abundance score")
fig.tight_layout()
FIGS = ROOT / "results" / "figures"
FIGS.mkdir(parents=True, exist_ok=True)
fig.savefig(FIGS / "score_distributions.png", dpi=300, bbox_inches="tight")
fig.savefig(FIGS / "score_distributions.pdf", bbox_inches="tight")- PNG at 300 dpi for slides and documents; PDF or SVG (vector, infinitely sharp) for papers and posters.
bbox_inches="tight"trims the white border so labels are not clipped.- Use one palette and keep each category the same colour in every figure. Readers learn your colours.
- The
colorblindpalette is a safe default; avoid red/green as the only distinction.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: a plotting function
plot_ecdf_by(data, column, ax) that draws one ECDF line per group of column onto the given ax, labels the x-axis, and returns the axes. Functions that take an ax can be dropped into any multi-panel figure.def plot_ecdf_by(data, column, ax):
pass # TODOfig, ax = plt.subplots()
out = plot_ecdf_by(data, "record_type", ax)
assert out is ax
assert len(ax.lines) >= 3
assert ax.get_xlabel() != ""
print("all checks passed")Show a solution (try for ten minutes first)
def plot_ecdf_by(data, column, ax):
"""One ECDF line per group, drawn onto ax."""
sns.ecdfplot(data=data, x="score", hue=column, ax=ax)
ax.set(xlabel="abundance score", ylabel="cumulative fraction")
return axYour turn: design your own figure
region. Label all axes, tidy the layout, and save it as my_figure.png in results/figures at 300 dpi. Choose colours you would be happy to show a supervisor.fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# TODO: left panel, right panel, labels, tight_layout, savefigassert (FIGS / "my_figure.png").exists() and (FIGS / "my_figure.png").stat().st_size > 10_000
assert len(axes[0].patches) > 10
assert axes[0].get_xlabel() != "" and axes[1].get_ylabel() != ""
print("all checks passed")Show a solution (try for ten minutes first)
miss = df[df["record_type"] == "missense"]
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.histplot(data=miss, x="score", bins=30, ax=axes[0])
sns.boxplot(data=miss, x="region", y="score", ax=axes[1])
axes[0].set(xlabel="abundance score", ylabel="number of variants")
axes[1].set(xlabel="region", ylabel="abundance score")
axes[1].tick_params(axis="x", rotation=30)
fig.tight_layout()
fig.savefig(FIGS / "my_figure.png", dpi=300, bbox_inches="tight")First star
What is the median score of the nonsense variants, to 2 decimal places? This is the line in the middle of the nonsense box in your box plot.
Stuck? Nudge and solution
Filter to record_type == "nonsense", take ["score"].median().
print(round(df.loc[df["record_type"] == "nonsense", "score"].median(), 2))Second star
The histogram of missense scores is rarely one tidy hump. Count the missense variants with a score strictly below 0.5.
Stuck? Nudge and solution
A boolean mask again: (miss["score"] < 0.5).sum() adds up the True values.
miss = df[df["record_type"] == "missense"]
print((miss["score"] < 0.5).sum())Heatmap Heights: plots for relationships and patterns
Scatter plots, density views, error bars, a track along the sequence, and the heatmap that shows thousands of values at once. About 65 minutes.
Make notebooks/05_patterns.ipynb with the same setup cells as Stage 9, plus miss = df[df["record_type"] == "missense"]. Distributions answered "what does it look like?". These plots answer "what goes with what?" and "where along the protein?".
Two variables
fig, ax = plt.subplots(figsize=(6, 4.5))
sns.scatterplot(data=miss, x="se", y="score", s=8, alpha=0.4, ax=ax)
ax.set(xlabel="standard error", ylabel="abundance score", title="Do noisier measurements score differently?")fig, ax = plt.subplots(figsize=(6, 4.5))
sns.regplot(data=miss, x="expts", y="se", x_jitter=0.2,
scatter_kws={"s": 6, "alpha": 0.3}, line_kws={"color": "C3"}, ax=ax)
ax.set(xlabel="number of experiments", ylabel="standard error")
miss[["score", "se", "expts"]].corr(method="spearman").round(2)fig, ax = plt.subplots(figsize=(6, 4.5))
hb = ax.hexbin(miss["score"], miss["se"], gridsize=30, mincnt=1, cmap="viridis")
fig.colorbar(hb, ax=ax, label="number of variants")
ax.set(xlabel="abundance score", ylabel="standard error", title="Hexbin: when dots overlap")Show the uncertainty
sub = miss[miss["position"].between(75, 76)].sort_values("hgvs_pro")
lower = (sub["score"] - sub["lower_ci"]).clip(lower=0) # error bars must not be negative
upper = (sub["upper_ci"] - sub["score"]).clip(lower=0)
fig, ax = plt.subplots(figsize=(11, 4))
ax.errorbar(range(len(sub)), sub["score"], yerr=[lower, upper], fmt="o", capsize=2, ms=4)
ax.set_xticks(range(len(sub)), sub["hgvs_pro"], rotation=90, fontsize=8)
ax.axhline(1, ls="--", color="grey", lw=1)
ax.set(ylabel="abundance score", title="Scores with 95% intervals")A track along the sequence
Position on the x-axis, a summary on the y-axis, and shaded bands for the annotated regions. This is the plot that connects a table to the structure of the thing it came from.
by_position = (
miss.groupby("position")["score"]
.agg(n_subs="size", mean_score="mean")
.reset_index()
)
x = by_position["position"].astype(int)
smooth = by_position["mean_score"].rolling(5, center=True, min_periods=1).mean()
fig, ax = plt.subplots(figsize=(12, 3.8))
for r, colour in zip(regions.itertuples(), ["C0", "C1", "C2"]):
ax.axvspan(r.start, r.end, color=colour, alpha=0.12)
ax.text((r.start + r.end) / 2, 1.28, r.region, ha="center", fontsize=9)
ax.plot(x, by_position["mean_score"], lw=0.8, color="grey", label="mean per position")
ax.plot(x, smooth, lw=2, color="black", label="5-position rolling mean")
ax.set(xlim=(1, 403), ylim=(0, 1.35), xlabel="position", ylabel="mean missense score")
ax.legend(loc="lower right", frameon=False)The heatmap
One cell per substitution: position along the x-axis, the new letters on the y-axis, colour for the score. Thousands of numbers become one picture where stripes mean "this position hates any change" and horizontal bands mean "this substitution is bad everywhere".
AA = ["Ala", "Val", "Ile", "Leu", "Met", "Phe", "Trp", "Tyr", "Cys", "Gly",
"Pro", "Ser", "Thr", "Asn", "Gln", "Asp", "Glu", "His", "Lys", "Arg"]
grid = miss.pivot_table(index="alt", columns="position", values="score").reindex(AA)
fig, ax = plt.subplots(figsize=(14, 5))
ax.set_facecolor("#dddddd") # untested substitutions show as grey
sns.heatmap(grid, cmap="viridis", vmin=0, vmax=1.2, xticklabels=25,
cbar_kws={"label": "abundance score"}, ax=ax)
ax.set(xlabel="position", ylabel="new letters")Plot hygiene checklist
- Axis labels with units; a title that states the finding, not just the variables.
- Same colour = same category in every figure.
- Text readable at the size it will be printed (14 to 16 pt at final size is a good aim).
- Save from code, with a descriptive filename, into
results/figures/. - Show the raw data points where you can; summaries alone hide too much.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: window_means
window_means(miss, size=10) returning a DataFrame with columns window, n and mean (mean missense score), one row per window of size positions.def window_means(miss, size=10):
pass # TODOw10 = window_means(miss)
w25 = window_means(miss, size=25)
assert list(w10.columns) == ["window", "n", "mean"]
assert len(w25) < len(w10)
assert w10["n"].sum() == len(miss)
assert (w25["window"] % 25 == 0).all()
print("all checks passed")Show a solution (try for ten minutes first)
def window_means(miss, size=10):
"""Mean missense score in windows of `size` positions."""
windows = miss["position"] // size * size
out = miss.groupby(windows)["score"].agg(n="size", mean="mean")
out.index.name = "window"
return out.reset_index()Your turn: zoom in on a stretch
AA. Store the pivoted table in grid_zoom and plot it with annot=False and tick labels on every position. Zooming in is how you turn a pattern into a story.grid_zoom = None # TODO: filter positions, pivot_table, reindex(AA)
# TODO: sns.heatmap(...)assert grid_zoom.shape[0] == 20
assert 1 <= grid_zoom.shape[1] <= 41
assert min(int(c) for c in grid_zoom.columns) >= 100 and max(int(c) for c in grid_zoom.columns) <= 140
print("all checks passed")Show a solution (try for ten minutes first)
zoom = miss[miss["position"].between(100, 140)]
grid_zoom = zoom.pivot_table(index="alt", columns="position", values="score").reindex(AA)
fig, ax = plt.subplots(figsize=(12, 4.5))
ax.set_facecolor("#dddddd")
sns.heatmap(grid_zoom, cmap="viridis", vmin=0, vmax=1.2, xticklabels=1, ax=ax)Your turn: does noisier mean lower?
score and the mean se (put them in a DataFrame by_pos with columns mean_score and mean_se). Draw a scatter of one against the other, and store the Spearman correlation between the two columns in rho. Then write one sentence in a Markdown cell: what does the plot say?by_pos = None # TODO
rho = None # TODOassert list(by_pos.columns) == ["mean_score", "mean_se"]
assert -1 <= rho <= 1
assert abs(rho - by_pos["mean_score"].corr(by_pos["mean_se"], method="spearman")) < 1e-12
print("all checks passed")Show a solution (try for ten minutes first)
by_pos = miss.groupby("position").agg(mean_score=("score", "mean"), mean_se=("se", "mean"))
rho = by_pos["mean_score"].corr(by_pos["mean_se"], method="spearman")
fig, ax = plt.subplots(figsize=(5.5, 4.5))
sns.scatterplot(data=by_pos, x="mean_se", y="mean_score", s=14, ax=ax)
ax.set(xlabel="mean standard error", ylabel="mean score", title=f"Spearman rho = {rho:.2f}")First star
Using only missense rows, group by the new letters (alt) and take the mean score. Which new amino acid, written as its three letters like Ala, has the lowest mean? It is the dimmest horizontal band in your heatmap.
Stuck? Nudge and solution
miss.groupby("alt")["score"].mean().idxmin(). Remember miss must only contain missense rows, otherwise Ter and = sneak in.
print(miss.groupby("alt")["score"].mean().idxmin())Second star
Cut the sequence into windows of 10 positions: window = position // 10 * 10, so positions 120 to 129 form window 120. Among windows that contain at least 30 missense variants, which window has the lowest mean missense score? Answer with the window's starting number.
Stuck? Nudge and solution
Make a window column, groupby("window") with n=("score", "size") and mean=("score", "mean"), filter on n >= 30, then idxmin() of the mean.
miss = miss.assign(window=miss["position"] // 10 * 10)
w = miss.groupby("window")["score"].agg(n="size", mean="mean")
print(w[w["n"] >= 30]["mean"].idxmin())The Summit: does your conclusion survive a different rule?
A result is only as solid as the choices behind it. You will change one rule, measure how much the answer moves, and package everything so anyone can re-run it. About 60 minutes.
Every analysis contains judgement calls: which rows to keep, how to score, where to cut. A trustworthy analysis shows how much the answer depends on them. Take one call you have already made implicitly: should we trust variants that were measured in only a couple of experiments?
Turn the rule into a dial
def summarise(df, region="phosphatase_domain", min_expts=None):
"""Count and mean of missense scores in one region.
min_expts: keep only variants measured in at least this many experiments
(None keeps everything).
"""
keep = (df["record_type"] == "missense") & (df["region"] == region)
if min_expts is not None:
keep &= df["expts"] >= min_expts
return df.loc[keep, "score"].agg(["count", "mean"])
summarise(df), summarise(df, min_expts=3)Now sweep the dial and look at the whole curve, not one cherry-picked value:
rows = []
for k in [1, 2, 3, 4, 5, 6, 7, 8]:
s = summarise(df, min_expts=k)
rows.append({"min_expts": k, "n_variants": int(s["count"]), "mean_score": s["mean"]})
sweep = pd.DataFrame(rows)
sweep.round(3)fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
sns.lineplot(data=sweep, x="min_expts", y="n_variants", marker="o", ax=axes[0])
sns.lineplot(data=sweep, x="min_expts", y="mean_score", marker="o", ax=axes[1])
axes[0].set(xlabel="minimum experiments", ylabel="variants kept")
axes[1].set(xlabel="minimum experiments", ylabel="mean missense score")
fig.tight_layout()If the mean barely moves while the count falls, the conclusion is robust to this rule. If it swings, say so in your write-up. Either answer is a result.
Make it re-runnable end to end
- Restart and Run All every notebook. Fix anything that fails or depends on a cell you ran once and forgot.
- Move final plotting code into a script so figures regenerate with one command. Save this as
src/make_figures.py:
"""Rebuild every figure from the raw data.
Run from anywhere: python src/make_figures.py
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg") # draw straight to files, no window
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from vampseq_tools import label_regions, parse_hgvs # same folder as this script
ROOT = Path(__file__).resolve().parent.parent
RAW, FIGS = ROOT / "data" / "raw", ROOT / "results" / "figures"
FIGS.mkdir(parents=True, exist_ok=True)
sns.set_theme(style="whitegrid", context="notebook", palette="colorblind")
TYPES = ["synonymous", "missense", "nonsense"]
df = pd.read_csv(RAW / "pten-variant-abundance.csv")
regions = pd.read_csv(RAW / "pten-region-annotations.csv")
df = label_regions(parse_hgvs(df), regions)
data = df[df["record_type"].isin(TYPES)]
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
sns.ecdfplot(data=data, x="score", hue="record_type", hue_order=TYPES, ax=axes[0])
sns.boxplot(data=data, x="record_type", y="score", order=TYPES, ax=axes[1])
axes[0].set(xlabel="abundance score", ylabel="cumulative fraction")
axes[1].set(xlabel="", ylabel="abundance score")
fig.tight_layout()
fig.savefig(FIGS / "score_distributions.png", dpi=300, bbox_inches="tight")
print("wrote", FIGS / "score_distributions.png")- Run it from the project root:
python src/make_figures.py. Deleteresults/figuresfirst to prove it rebuilds everything. - Re-export the environment:
conda env export --from-history > environment.yml. - Update the README with the dial you chose and why, then commit.
python src/make_figures.pyYour final src/vampseq_tools.py now holds add_scores, parse_hgvs and label_regions: three small functions, each documented, each reused by notebooks and scripts alike. That is the whole habit in miniature.
Where to go next
- 10 minutes to pandas: the official short tour. You can now read all of it.
- seaborn gallery and matplotlib gallery: browse for a plot shape, copy the code, adapt it.
- Put your project on GitHub so your future lab can see it.
- Pick a question of your own about this dataset and answer it with a figure, a paragraph, and a function.
Practice: your turn
Reading code teaches you what code looks like; writing it teaches you to code. Do these in your notebook. Each has a starter, a check cell that stays silent when you are right and raises an error when you are not (read it, that is your Stage 2 skill at work), and a hidden solution. Tick Done when the check prints all checks passed.
Your turn: sweep as a function
sweep(df, ks) that calls summarise for every value in ks and returns a DataFrame with columns min_expts, n_variants, mean_score. Then call it with [1, 3, 5, 7] and plot mean_score against min_expts.def sweep(df, ks):
pass # TODOs = sweep(df, [1, 3, 5])
assert list(s.columns) == ["min_expts", "n_variants", "mean_score"]
assert len(s) == 3
assert s["n_variants"].is_monotonic_decreasing
print("all checks passed")Show a solution (try for ten minutes first)
def sweep(df, ks):
"""Mean missense score in the phosphatase domain for each min_expts."""
rows = []
for k in ks:
r = summarise(df, min_expts=k)
rows.append({"min_expts": k, "n_variants": int(r["count"]), "mean_score": r["mean"]})
return pd.DataFrame(rows)Your turn: your own report script
src/report.py: a script, not a notebook, that loads the two CSVs from data/raw (build the paths from Path(__file__) so it works from anywhere), parses and labels regions with your module, then prints exactly: a line rows: N, a line missense: N, and the mean missense score per region (rounded to 3 places). Run it from the terminal with python src/report.py. The check cell below runs it for you."""Print a short report about the variant table."""
# TODO: imports, paths from __file__, load, parse, label, print three thingsimport subprocess, sys
out = subprocess.run([sys.executable, str(ROOT / "src" / "report.py")], capture_output=True, text=True)
assert out.returncode == 0, out.stderr
assert "rows:" in out.stdout and "missense:" in out.stdout
assert "phosphatase_domain" in out.stdout
print("all checks passed")Show a solution (try for ten minutes first)
from pathlib import Path
import pandas as pd
from vampseq_tools import label_regions, parse_hgvs
ROOT = Path(__file__).resolve().parent.parent
df = pd.read_csv(ROOT / "data" / "raw" / "pten-variant-abundance.csv")
regions = pd.read_csv(ROOT / "data" / "raw" / "pten-region-annotations.csv")
df = label_regions(parse_hgvs(df), regions)
miss = df[df["record_type"] == "missense"]
print("rows:", len(df))
print("missense:", len(miss))
print(miss.groupby("region")["score"].mean().round(3).to_string())First star
Keep only rows whose expts is at least 3, across the whole table (every record type). How many rows remain?
Stuck? Nudge and solution
(df["expts"] >= 3).sum() or len(df[df["expts"] >= 3]). Rows with a missing expts fail the comparison and are not counted.
print((df["expts"] >= 3).sum())Second star
Use summarise for the phosphatase_domain. Compute the mean missense score with the rule min_expts=3 minus the mean without it. Give the difference (after minus before) to 3 decimal places; keep the sign.
Stuck? Nudge and solution
summarise(df, min_expts=3)["mean"] - summarise(df)["mean"]. A tiny difference is good news for the robustness of the earlier answer.
after = summarise(df, min_expts=3)["mean"]
before = summarise(df)["mean"]
print(round(after - before, 3))Taking your work to the HPC cluster
What this guide was for, when a laptop stops being enough, and how to move the same project onto the cluster. No stars here, just a map.
What this guide was really for
Your laptop is where you learn and where you develop. Data is small, feedback is instant, and a mistake costs seconds. Everything in the twelve stages was practice for the habits that make a cluster usable later:
| Habit you built | Why the cluster needs it |
|---|---|
Isolated environment plus environment.yml | You rebuild the exact same Python on the cluster in one command. |
Scripts like src/make_figures.py | A cluster job runs a script, not a notebook you click through. |
| Relative paths from a project root | The same code runs in a different folder on a different machine. |
| Functions with parameters | One script can process any sample or setting you hand it. |
| Raw data never edited, results regenerated | Jobs can be re-run safely, and you always know where an output came from. |
| Reading the last line of an error | Failed jobs leave you a log file full of tracebacks. You already know how to read one. |
When do you actually need HPC?
Not for this dataset: four thousand rows fit on any laptop. Develop small, then scale up when one of these happens:
- Memory: you hit
MemoryError, or your laptop starts crawling because the data does not fit in RAM. - Time: one run takes hours, or you need to repeat it for many samples or many parameter settings.
- Hardware: you need many cores at once, or a GPU.
The same project, on the cluster
- Get access. Request an account and read how resources are allocated: getting an account and requesting resources.
- Log in. Use a terminal (SSH) or the browser portal (Open OnDemand). OnDemand also offers Python and Jupyter sessions if you want a notebook on a compute node.
- Bring your code. Put the project in a Git repository on your laptop, then
git cloneit on the cluster. Code travels through Git; it keeps a history and avoids "which copy is newest?". - Bring your data. Large files go through a transfer tool, not Git. See file transfer (Globus is the usual choice) and the file systems page to decide where files should live. Big temporary files belong in scratch.
- Rebuild the environment. Load conda or Python the way the Python page describes, then recreate yours:
conda env create -f environment.yml. This is why you froze it in Stage 6. - Test on a small slice. Ask for a short interactive job, run your script on a few rows, and fix errors where you can see them.
- Submit a batch job. Write a short job script and hand it to Slurm (batch jobs).
- Check, then copy results home. Read the log, confirm the outputs look right, and copy
results/back to your laptop for figures and writing.
A job script
A batch script is a shell script with #SBATCH lines that tell Slurm what to reserve. The names of clusters and partitions, and sensible memory and time requests, are specific to your group; copy real values from the manual rather than from this sketch.
#!/bin/bash
#SBATCH --job-name=make-figures
#SBATCH --cluster=CLUSTER_NAME # placeholder: copy real values from the manual
#SBATCH --partition=PARTITION_NAME # placeholder
#SBATCH --nodes=1
#SBATCH --cpus-per-task=4
#SBATCH --mem=16G
#SBATCH --time=01:00:00
#SBATCH --output=logs/%x-%j.out # %x = job name, %j = job id
# 1. make the same Python the same way you did on your laptop
# (how to load conda or Python on the cluster is on the manual's Python page)
conda activate pandas-quest
# 2. run from the project root so relative paths behave
cd "$SLURM_SUBMIT_DIR"
# 3. run the script, not a notebook
python src/make_figures.pySubmit it and keep an eye on it:
mkdir -p logs
sbatch run_make_figures.sh # prints: Submitted batch job 12345678
squeue --me # what is queued or running
sacct -j 12345678 # what happened, after it finishes
scancel 12345678 # stop itHabits that make cluster work painless
- Develop small, run big. Add a small slice or a
--sampleoption to your script so you can test in seconds. - Many similar tasks? Do not write a loop in one long job; use a job array so each sample runs on its own.
- Keep sessions alive. tmux and nohup let long-running work survive a dropped connection.
- Edit comfortably. VS Code can work on cluster files directly: see the VS Code page.
- Record what you ran. Commit the job script, the
environment.ymland the README. Next month, they are your memory. - Stuck? Check Common Problems and the main user manual, then ask the support team with the job ID and the last lines of the log.
The full documentation for everything above lives in the CRCD User Manual.
Summit register
You reached the top. Sign the register: enter your name, then print it or save it as a PDF.
This certifies that
Your name here
completed MSCBIO2025: The Right Way: set up a Python environment, learned pandas, wrote reusable functions, audited a real dataset and plotted it more than a dozen ways.
atharva-bhagwat.github.io/articles/mscbio2025-the-right-way