Atharva Bhagwat

MSCBIO2025: The Right Way

0 / 24TrailheadTrail

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.
In [1]:
trail
Out[1]:
stagestar1star2
0BasecampNaNNaN
1Notebook RidgeNaNNaN
2Traceback GorgeNaNNaN
3Table FlatsNaNNaN
4Score FallsNaNNaN
5Function ForgeNaNNaN
6Project LodgeNaNNaN
7Audit PassNaNNaN
8Region RapidsNaNNaN
9Histogram MeadowNaNNaN
10Heatmap HeightsNaNNaN
11The SummitNaNNaN
In [2]:
trail[["star1", "star2"]].notna().sum().sum()
Out[2]:
0
Stage 0

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.

macOS / Linux terminal
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 zsh
Windows (PowerShell)
Invoke-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 powershell

The 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:

terminal
conda --version
which python    # Windows: Get-Command python

Optional but pleasant: stop conda from activating base in every new terminal.

terminal
conda config --set auto_activate_base false

3. Create the environment

One command makes a fresh environment called pandas-quest with everything this guide needs:

terminal
conda create -n pandas-quest -c conda-forge python=3.12 pandas numpy matplotlib seaborn scipy ipykernel -y

Then step inside it. You will do this every time you open a new terminal:

terminal
conda activate pandas-quest

Your 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

macOS / Linux terminal
mkdir pandas-quest
cd pandas-quest
code .
Windows (Anaconda Prompt)
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.

  1. 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).
  2. 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.
  3. Type the name from the table below, for example Python. Results appear underneath, most popular first.
  4. 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.
  5. 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.
  6. Repeat for each extension in the table.
Search forPublisherWhat it does for you
PythonMicrosoftRuns and debugs .py files, finds your conda environments, adds autocomplete.
JupyterMicrosoftOpens .ipynb notebooks inside VS Code and runs cells.
Rainbow CSVmechatronerColours each column of a CSV differently so you can read raw data files.
Data WranglerMicrosoftOpens a DataFrame as a scrollable grid with summary stats.
Error LensAlexander (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.

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):

example output
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-quest

First 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.

Stage 1

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:

cell 1
x = 10
cell 2
print(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:

cell
n_reads = 1250
print(f"reads counted: {n_reads}")
print(f"as thousands: {n_reads / 1000:.2f}k")

Data types, in five lines

cell
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.

cell
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

cell
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))
cell
score = 1.4
if score < 0.5:
    label = "low"
elif score < 1.0:
    label = "in between"
else:
    label = "wild-type-like"
print(label)
cell
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.)

cell
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.
starter: copy into a cell and complete it
seq = "MTAIIKEIVSRNKRR"

n_I = 0
# TODO: loop over seq and add 1 to n_I for every "I"
check: run this after your code
assert n_I == 3
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
seq = "MTAIIKEIVSRNKRR"

n_I = 0
for letter in seq:
    if letter == "I":
        n_I += 1

Your turn: letter frequencies

Build a dictionary 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.
starter: copy into a cell and complete it
seq = "MTAIIKEIVSRNKRR"

counts = {}
# TODO: loop over seq and update counts
check: run this after your code
assert 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)
one solution
seq = "MTAIIKEIVSRNKRR"

counts = {}
for letter in seq:
    counts[letter] = counts.get(letter, 0) + 1

Your turn: filter with a comprehension

From variants, make a list late holding the positions (as integers) that are greater than 30, using a single list comprehension.
starter: copy into a cell and complete it
variants = ["p.Gly12Gly", "p.Trp18Ter", "p.Leu25Pro", "p.Val30Asp",
            "p.Gln36Ter", "p.Ile40Asn", "p.Ser45Thr", "p.Gly46Arg"]

late = []   # TODO: replace with a list comprehension
check: run this after your code
assert late == [36, 40, 45, 46]
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
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.

one way to get it
total = 0
for v in variants:
    total += int(v[5:-3])
print(total)
Stage 2

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'
  1. 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.
  2. Find your line. The arrow ----> 2 points at your code. Ignore the middle section about library internals; those are the pandas authors' lines, and the bug is almost never theirs.
  3. Ask what you would expect. Which column names really exist? print(df.columns). You will see score, lowercase. Keys are case-sensitive.

The usual suspects

ErrorPlain-English meaningFirst thing to try
SyntaxErrorPython could not even read the code. A missing colon, quote or bracket. Nothing ran.Look at the line it marks and the line above.
NameErrorYou used a name Python has never seen.Typo? Or you did not run the cell that creates it (kernel memory!).
TypeErrorRight operation, wrong kind of thing, such as adding a string to a number.print(type(x)) for each thing involved.
ValueErrorRight kind of thing, unacceptable value, such as int("abc").Print the value that was passed in.
KeyErrorA dictionary key or DataFrame column is not there.df.columns. Check case and stray spaces.
IndexErrorYou asked for item 10 of a list with 5 items.len(...) before indexing.
AttributeErrorThat object has no such method or attribute.Typo, or it is a different type than you think.
FileNotFoundErrorThe path points to nothing.Print the current folder with from pathlib import Path; Path.cwd().
ModuleNotFoundErrorThe library is not installed in this environment.Wrong kernel or interpreter. Stage 0, step 6.

A routine that works

  1. Read the last line of the traceback out loud, slowly.
  2. Go to the line the arrow points at. Is it what you expected to run?
  3. Check what you assumed: print(type(x)), df.shape, df.columns, df.head().
  4. Shrink the problem. Reproduce it on three rows, or in a fresh cell with two lines.
  5. Suspect the kernel. Restart and Run All. A surprising number of bugs vanish.
  6. Search the message with your own names removed: KeyError pandas column beats KeyError 'Score'. Read the answer for why it happens, not just a line to copy.
  7. 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

Each pair of lines has a bug that raises an error. Run the cell, read the error, fix one line at a time. The comment says what you want.
starter: copy into a cell and complete it
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 245
check: run this after your code
assert last == "p.Leu25Pro"
assert wt_score == 3.49
assert total == 245
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
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

Each snippet below fails in a different way. Loop over them, run each inside 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.
starter: copy into a cell and complete it
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__
check: run this after your code
assert error_names == ["NameError", "TypeError", "KeyError", "IndexError", "ValueError"]
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
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.

Which line of a traceback tells you what went wrong?
KeyError: 'Score' after df["Score"]. The file's header is variant,score,se. What is the fix?
Your notebook is in notebooks/, the file is at data/raw/toy.csv, and pd.read_csv("data/raw/toy.csv") gives FileNotFoundError. Most likely cause?
total = 0, then for s in ["1.5", "2.5"]: total = total + s gives TypeError: unsupported operand type(s) for +: 'int' and 'str'. What is s?

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.

Stage 3

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):

cell
import pandas as pd

URL = "https://atharva-bhagwat.github.io/assets/data/toy_vampseq_bin_counts.csv"
df = pd.read_csv(URL)
df

Look before you touch

Before any analysis, find out what you actually have. Run each of these in its own cell:

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 variants

Pick columns

cell
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.

cell
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

cell
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 columns

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: name the nonsense

Using the toy table df, make a sorted list of unique variant names whose variant_type is nonsense, called nonsense_names.
starter: copy into a cell and complete it
nonsense_names = []   # TODO: filter df, take the variant column, make it unique and sorted
check: run this after your code
assert nonsense_names == ["p.Gln36Ter", "p.Trp18Ter", "p.Tyr52Ter"]
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
nonsense_names = sorted(df.loc[df["variant_type"] == "nonsense", "variant"].unique())

Your turn: which rows are bright?

In replicate 1, find the variants whose 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.
starter: copy into a cell and complete it
bright = []   # TODO
check: run this after your code
assert "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)
one solution
rep1 = df[df["replicate"] == 1]
bright = sorted(rep1.loc[rep1["bin4_count"] > rep1["bin1_count"], "variant"])

Your turn: top three

Among 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.
starter: copy into a cell and complete it
top3 = []   # TODO: filter, sort_values(..., ascending=False), head(3), then tolist()
check: run this after your code
assert top3 == ["p.Leu25Pro", "p.Gly46Arg", "p.Val30Asp"]
print("all checks passed")
Show a solution (try for ten minutes first)
one solution
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].

one way to get it
sub = df[(df["variant_type"] == "missense") & (df["replicate"] == 2)]
print(len(sub))
Stage 4

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.

the formula
score = (1*bin1 + 2*bin2 + 3*bin3 + 4*bin4) / total_reads

Make 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.

cell
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

cell
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.

cell
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

cell
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?
cell
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

Continue in your Stage 4 notebook, where 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.
starter: copy into a cell and complete it
top_variant = ""   # TODO
check: run this after your code
assert "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)
one solution
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

Make a Series type_means with the mean score for each variant_type. Two of its facts will be checked: which type scores lowest, and which highest.
starter: copy into a cell and complete it
type_means = None   # TODO: groupby + mean
check: run this after your code
assert 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)
one solution
type_means = df.groupby("variant_type")["score"].mean()

Your turn: a summary table

Build a DataFrame 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.
starter: copy into a cell and complete it
summary = None   # TODO
check: run this after your code
assert 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)
one solution
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.

one way to get it
print(df[df["variant"] == "WT"]["score"].mean().round(2))
Stage 5

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

cell
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 out
  • def name(parameters): starts the recipe. Parameters with = have defaults, so callers only mention what they want to change.
  • The triple-quoted text right under def is the docstring. It is what help(add_scores) and Shift+Tab show.
  • return hands the result back. A function with no return gives back None, 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

cell
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:

cell
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 modified

Documentation is a kindness to future you

  • Name things for what they are. add_scores beats func2. 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

Write a function 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.
starter: copy into a cell and complete it
def fraction_in_bin(df, k):
    # TODO: docstring, validation, calculation
    pass
check: run this after your code
f = 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)
one solution
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

Write 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.
starter: copy into a cell and complete it
def flag_low_coverage(df, min_reads=50):
    pass   # TODO
check: run this after your code
assert 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)
one solution
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_reads

Your turn: summarise_variants

Reuse 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.
starter: copy into a cell and complete it
def summarise_variants(df, min_reads=50):
    pass   # TODO: scored = add_scores(...), then groupby("variant")
check: run this after your code
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)
one solution
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.

one way to get it
scored = add_scores(df)
row = scored[(scored["variant"] == "p.Ser45Thr") & (scored["replicate"] == 1)]
print(round(row["score"].iloc[0], 3))
Stage 6

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 screenshots

1. Build the skeleton

From your project folder, in the terminal where (pandas-quest) is active (this one-liner works on every operating system):

terminal
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.

FileWhat it is
toy_vampseq_bin_counts.csvInvented table of read counts, used in Stages 3 to 5.
pten-variant-abundance.csvThe real table: one row per protein variant, used from Stage 7.
pten-region-annotations.csvThree 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_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:

any cell or script
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:

src/vampseq_tools.py
"""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 out

Now any notebook can import it. Put this in the first cell of each notebook:

first cell of every 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.path line? Python only imports from certain folders. This adds yours.
  • Why %autoreload 2? The kernel remembers imported code (Stage 1!). Without autoreload, editing vampseq_tools.py changes nothing until you restart the kernel. With it, edits are picked up on the next run.

5. Write it down: README and environment

README.md
# 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:

terminal
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

terminal
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:

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

Write 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.)
starter: copy into a cell and complete it
import hashlib
from pathlib import Path


def file_fingerprint(path):
    pass   # TODO
check: run this after your code
import 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)
one solution
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

Write 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.
starter: copy into a cell and complete it
from pathlib import Path


def ensure_dirs(root, names):
    pass   # TODO
check: run this after your code
import 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)
one solution
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 created

First 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.

one way to get it
import hashlib
from pathlib import Path

print(hashlib.sha256(Path("data/raw/pten-variant-abundance.csv").read_bytes()).hexdigest())
Stage 7

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:

cell
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):

ColumnMeaning
hgvs_proVariant name such as p.Asn12Ile: reference letters, position, new letters. This is the identity of a row.
scoreThe measured abundance, already normalised: about 1 means "like the unchanged reference", about 0 means "like a broken one".
sd, se, lower_ci, upper_ciSpread and uncertainty of that measurement.
exptsHow many experiments contributed to the score.
record_typemissense (one letter swapped), synonymous (no real change), nonsense (stops early), wild_type (the reference itself).
abundance_class, abundance_class_labelA binned version of the score, as a number and as a text label.
accession, snvDatabase identifier, and a flag for whether one DNA change can produce it.

The audit checklist

cell
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:

cell
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()
PieceMeans
^ 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.

cell
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:

cell
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):

add to src/vampseq_tools.py
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 out

Then 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

Turn the audit checklist into a function. Write 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.
starter: copy into a cell and complete it
def audit(df):
    pass   # TODO
check: run this after your code
tiny = 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)
one solution
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

Without pandas this time. Write 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.
starter: copy into a cell and complete it
import re


def split_variant(name):
    pass   # TODO
check: run this after your code
assert 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)
one solution
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.

one way to get it
print(len(df))
Stage 8

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:

cell
regions = pd.read_csv(RAW / "pten-region-annotations.csv")
regions

Label 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:

cell
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:

add to src/vampseq_tools.py
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 out

Focus 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:

cell
miss = df[df["record_type"] == "missense"]
len(miss)

Summarise by region, with counts

cell
by_region = (
    miss.groupby("region")["score"]
        .agg(n="size", mean="mean", median="median")
        .round(3)
)
by_region

Summarise by position, then merge back

cell
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 positions
cell
merged = 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

Write 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?".
starter: copy into a cell and complete it
def summarise_by(df, column):
    pass   # TODO
check: run this after your code
t = 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)
one solution
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

Among positions with at least 15 missense variants, find the 10 positions with the lowest mean missense score. Store them, lowest first, in a list most_sensitive. Your miss table from this stage is the starting point.
starter: copy into a cell and complete it
most_sensitive = []   # TODO: groupby position, filter on n, sort, head(10)
check: run this after your code
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)
one solution
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

The regions table has an 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.
starter: copy into a cell and complete it
with_notes = None   # TODO: df.merge(...)
check: run this after your code
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)
one solution
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.

one way to get it
sub = df[(df["record_type"] == "missense") & (df["region"] == "phosphatase_domain")]
print(round(sub["score"].mean(), 3), len(sub))
Stage 9

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):

first cells
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:

the pattern
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 it

Which plot answers which question?

QuestionPlotWatch out for
What is the shape of one variable?HistogramThe number of bins changes the picture. Try 20, 40 and 80.
Same, smoothed, for several groupsKDE (density)Smoothing can invent bumps and spill past the real range.
Compare distributions without choosing binsECDFLess familiar, but the most honest. Read it as "what fraction is below this value".
Compare group centres and spreadBox plotHides the shape. Two humps look like one box.
Compare group shapesViolinNeeds plenty of points per group.
Show every observationStrip plotOverlapping points. Use alpha and a small size.
How many of each category?Count / bar plotStart bars at zero. Always.

The gallery

Each cell below is a complete plot. Run them, change one thing, run again.

1. histogram
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")
2. density (KDE)
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")
3. ECDF
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")
4. box plot
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")
5. violin
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")
6. strip 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")
7. count plot
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

a three-panel figure
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 colorblind palette 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

Write 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.
starter: copy into a cell and complete it
def plot_ecdf_by(data, column, ax):
    pass   # TODO
check: run this after your code
fig, 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)
one solution
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 ax

Your turn: design your own figure

Make a figure with two panels side by side: on the left a histogram of missense scores (30 bins); on the right a box plot of missense score by 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.
starter: copy into a cell and complete it
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# TODO: left panel, right panel, labels, tight_layout, savefig
check: run this after your code
assert (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)
one solution
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().

one way to get it
print(round(df.loc[df["record_type"] == "nonsense", "score"].median(), 2))
Stage 10

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

1. scatter
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?")
2. trend line + correlation
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)
3. hexbin (many points)
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

4. points with error bars
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.

5. line track with region bands
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".

6. heatmap
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

Generalise the 10-position windows from the puzzle. Write window_means(miss, size=10) returning a DataFrame with columns window, n and mean (mean missense score), one row per window of size positions.
starter: copy into a cell and complete it
def window_means(miss, size=10):
    pass   # TODO
check: run this after your code
w10 = 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)
one solution
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

Draw the heatmap again, but only for positions 100 to 140, with the new letters in the order of 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.
starter: copy into a cell and complete it
grid_zoom = None   # TODO: filter positions, pivot_table, reindex(AA)
# TODO: sns.heatmap(...)
check: run this after your code
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)
one solution
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?

For each position compute the mean missense 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?
starter: copy into a cell and complete it
by_pos = None   # TODO
rho = None      # TODO
check: run this after your code
assert 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)
one solution
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.

one way to get it
print(miss.groupby("alt")["score"].mean().idxmin())
Stage 11

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

cell
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:

cell
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)
cell
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

  1. Restart and Run All every notebook. Fix anything that fails or depends on a cell you ran once and forgot.
  2. Move final plotting code into a script so figures regenerate with one command. Save this as src/make_figures.py:
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")
  1. Run it from the project root: python src/make_figures.py. Delete results/figures first to prove it rebuilds everything.
  2. Re-export the environment: conda env export --from-history > environment.yml.
  3. Update the README with the dial you chose and why, then commit.
terminal
python src/make_figures.py

Your 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

Write 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.
starter: copy into a cell and complete it
def sweep(df, ks):
    pass   # TODO
check: run this after your code
s = 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)
one solution
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

Create 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.
starter: copy into a cell and complete it
"""Print a short report about the variant table."""
# TODO: imports, paths from __file__, load, parse, label, print three things
check: run this after your code
import 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)
one solution
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.

one way to get it
print((df["expts"] >= 3).sum())
After the summit

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 builtWhy the cluster needs it
Isolated environment plus environment.ymlYou rebuild the exact same Python on the cluster in one command.
Scripts like src/make_figures.pyA cluster job runs a script, not a notebook you click through.
Relative paths from a project rootThe same code runs in a different folder on a different machine.
Functions with parametersOne script can process any sample or setting you hand it.
Raw data never edited, results regeneratedJobs can be re-run safely, and you always know where an output came from.
Reading the last line of an errorFailed 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

  1. Get access. Request an account and read how resources are allocated: getting an account and requesting resources.
  2. 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.
  3. Bring your code. Put the project in a Git repository on your laptop, then git clone it on the cluster. Code travels through Git; it keeps a history and avoids "which copy is newest?".
  4. 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.
  5. 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.
  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.
  7. Submit a batch job. Write a short job script and hand it to Slurm (batch jobs).
  8. 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.

run_make_figures.sh
#!/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.py

Submit it and keep an eye on it:

terminal on the cluster
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 it

Habits that make cluster work painless

  • Develop small, run big. Add a small slice or a --sample option 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.yml and 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.