jaechang-hits/SciAgent-Skills
GitHub用于Opentrons OT-2/Flex液路处理机器人的Python协议开发。支持移液、稀释、PCR及模块控制,提供本地模拟功能。多厂商自动化建议使用pylabrobot。
安装全部 Skills
npx skills add jaechang-hits/SciAgent-Skills --all -g -y
更多选项
预览集合内 Skills
npx skills add jaechang-hits/SciAgent-Skills --list
集合内 Skills (199)
legacy/opentrons-integration/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill opentrons-integration -g -y
SKILL.md
Frontmatter
{
"name": "opentrons-integration",
"license": "Apache-2.0",
"description": "Opentrons Protocol API v2 for OT-2\/Flex: Python protocols for pipetting, serial dilutions, PCR, plate replication; control thermocycler, heater-shaker, magnetic, temperature modules. Use pylabrobot for multi-vendor."
}
Opentrons Integration — Lab Automation
Overview
Opentrons provides a Python-based Protocol API (v2) for programming OT-2 and Flex liquid handling robots. Protocols are structured Python files with metadata and a run() function that controls pipettes, labware, and hardware modules. All protocols can be simulated locally before running on physical hardware.
When to Use
- Automating liquid handling workflows (pipetting, mixing, distributing)
- Writing PCR setup protocols with thermocycler control
- Performing serial dilutions across plates
- Replicating plates or reformatting between plate types
- Controlling hardware modules (temperature, magnetic, heater-shaker, thermocycler)
- Setting up multi-channel pipetting for 96-well plate operations
- Simulating protocols before running on the robot
- For multi-vendor automation (Hamilton, Beckman, etc.), use pylabrobot instead
- For flow cytometry analysis of automated experiment results, use flowio/flowkit
Prerequisites
pip install opentrons
# Simulate protocols locally (no robot needed)
opentrons_simulate my_protocol.py
Protocol API Version: Always use the latest stable API level (currently 2.19). Set apiLevel in protocol metadata. Protocols are forward-compatible within major versions.
Robot Types: Flex (newer, larger deck, 96-channel pipette) vs OT-2 (smaller, 8-channel max). Key differences: deck slot naming (Flex: A1-D3, OT-2: 1-11), available pipettes, and module support.
Quick Start
from opentrons import protocol_api
metadata = {"protocolName": "Quick Transfer", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
pipette = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
pipette.distribute(50, source["A1"], plate.wells()[:12], new_tip="once")
Core API
1. Protocol Structure
Every Opentrons protocol follows a required structure: metadata dict + run() function.
from opentrons import protocol_api
metadata = {
"protocolName": "My Protocol",
"author": "Name <email>",
"description": "Protocol description",
"apiLevel": "2.19",
}
# Optional: specify robot type
requirements = {"robotType": "Flex", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
# All protocol logic goes here
protocol.comment("Protocol started")
2. Labware and Deck Layout
Load labware (plates, reservoirs, tip racks) onto deck slots and optionally onto adapters.
def run(protocol: protocol_api.ProtocolContext):
# Tip racks
tips_300 = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
tips_20 = protocol.load_labware("opentrons_96_tiprack_20ul", "4")
# Plates and reservoirs
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "2", label="Sample Plate")
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "3")
# Labware on adapter (Flex)
adapter = protocol.load_adapter("opentrons_flex_96_tiprack_adapter", "B1")
tips_on_adapter = adapter.load_labware("opentrons_flex_96_tiprack_200ul")
# Pipettes
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips_300])
p20 = protocol.load_instrument("p20_single_gen2", "right", tip_racks=[tips_20])
Common pipette names:
- OT-2:
p20_single_gen2,p300_single_gen2,p1000_single_gen2,p20_multi_gen2,p300_multi_gen2 - Flex:
p50_single_flex,p1000_single_flex,p50_multi_flex,p1000_multi_flex
3. Pipette Operations
Basic, compound, and advanced liquid handling operations.
def run(protocol: protocol_api.ProtocolContext):
# ... (labware loaded above)
# === Basic operations ===
p300.pick_up_tip()
p300.aspirate(100, source["A1"]) # Draw 100 µL
p300.dispense(100, dest["B1"]) # Expel 100 µL
p300.drop_tip()
# === Compound operations (auto tip management) ===
# Transfer: single source → single dest
p300.transfer(100, source["A1"], dest["B1"], new_tip="always")
# Distribute: one source → many dests
p300.distribute(50, reservoir["A1"],
[plate["A1"], plate["A2"], plate["A3"]], new_tip="once")
# Consolidate: many sources → one dest
p300.consolidate(50, [plate["A1"], plate["A2"]], reservoir["A1"])
# === Advanced techniques ===
p300.pick_up_tip()
p300.mix(repetitions=3, volume=50, location=plate["A1"]) # Mix in place
p300.aspirate(100, source["A1"])
p300.air_gap(20) # Prevent dripping
p300.dispense(120, dest["A1"])
p300.blow_out(dest["A1"].top()) # Expel residual
p300.touch_tip(plate["A1"]) # Remove exterior drops
p300.drop_tip()
4. Well Access and Locations
Navigate wells by name, index, row, or column. Control vertical position within wells.
def run(protocol: protocol_api.ProtocolContext):
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "1")
# Access by name or index
well = plate["A1"]
first = plate.wells()[0] # Same as plate["A1"]
# Iterate rows/columns
row_a = plate.rows()[0] # [A1, A2, ..., A12]
col_1 = plate.columns()[0] # [A1, B1, ..., H1]
# Vertical positions
pipette.aspirate(100, well.top()) # 1mm below top
pipette.aspirate(100, well.bottom(z=2)) # 2mm above bottom
pipette.aspirate(100, well.center()) # Center of well
pipette.dispense(100, well.top(z=5)) # 5mm above top
5. Hardware Modules
Control temperature, magnetic, heater-shaker, and thermocycler modules.
def run(protocol: protocol_api.ProtocolContext):
# Temperature module
temp_mod = protocol.load_module("temperature module gen2", "3")
temp_plate = temp_mod.load_labware("corning_96_wellplate_360ul_flat")
temp_mod.set_temperature(celsius=4)
# temp_mod.temperature → current temp; temp_mod.deactivate()
# Magnetic module
mag_mod = protocol.load_module("magnetic module gen2", "6")
mag_plate = mag_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
mag_mod.engage(height_from_base=10) # Raise magnets (mm)
mag_mod.disengage()
# Heater-Shaker module
hs_mod = protocol.load_module("heaterShakerModuleV1", "1")
hs_plate = hs_mod.load_labware("corning_96_wellplate_360ul_flat")
hs_mod.close_labware_latch()
hs_mod.set_target_temperature(celsius=37)
hs_mod.wait_for_temperature()
hs_mod.set_and_wait_for_shake_speed(rpm=500)
hs_mod.deactivate_shaker()
hs_mod.deactivate_heater()
hs_mod.open_labware_latch()
# Thermocycler (auto-assigned to slots)
tc_mod = protocol.load_module("thermocyclerModuleV2")
tc_plate = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
tc_mod.open_lid()
tc_mod.close_lid()
tc_mod.set_lid_temperature(celsius=105)
tc_mod.set_block_temperature(95, hold_time_seconds=180)
profile = [
{"temperature": 95, "hold_time_seconds": 15},
{"temperature": 60, "hold_time_seconds": 30},
{"temperature": 72, "hold_time_seconds": 60},
]
tc_mod.execute_profile(steps=profile, repetitions=30, block_max_volume=50)
tc_mod.deactivate_lid()
tc_mod.deactivate_block()
6. Protocol Control and Utilities
Pause, delay, comment, liquid tracking, and simulation detection.
def run(protocol: protocol_api.ProtocolContext):
# Execution control
protocol.pause(msg="Replace tip box and resume")
protocol.delay(seconds=60)
protocol.delay(minutes=5)
protocol.comment("Starting serial dilution")
protocol.home()
# Liquid tracking (visual in Opentrons App)
water = protocol.define_liquid(name="Water", description="Ultrapure water",
display_color="#0000FF")
reservoir["A1"].load_liquid(liquid=water, volume=50000)
plate["B1"].load_empty()
# Check simulation vs real run
if protocol.is_simulating():
protocol.comment("Simulation mode")
# Flow rate control (µL/s)
pipette.flow_rate.aspirate = 150
pipette.flow_rate.dispense = 300
pipette.flow_rate.blow_out = 400
Key Concepts
Protocol File Structure
All Opentrons protocols are Python files with this required structure:
┌─ metadata dict ──────────────── protocolName, apiLevel, author
├─ requirements dict (optional) ── robotType
└─ def run(protocol): ─────────── All robot commands
The run() function receives a ProtocolContext object — all labware loading, pipette operations, and module control happen through this single entry point. Protocols cannot import arbitrary packages for execution on the robot.
OT-2 vs Flex Differences
| Feature | OT-2 | Flex |
|---|---|---|
| Deck slots | 1-11 (numeric) | A1-D3 (grid) |
| Pipettes | Gen2 (p20, p300, p1000) |
Flex (p50, p1000, 96-channel) |
| Max channels | 8-channel multi | 96-channel |
| Modules | Gen1/Gen2 | V2 modules |
| Adapters | Not supported | Supported (tiprack, flat) |
Multi-Channel Pipette Behavior
When using multi-channel pipettes, referencing a single well accesses the entire column:
multi = protocol.load_instrument("p300_multi_gen2", "left", tip_racks=[tips])
# This transfers from ALL wells in column 1 of source to column 1 of dest
multi.transfer(100, source["A1"], dest["A1"])
Common Workflows
Workflow: Serial Dilution
from opentrons import protocol_api
metadata = {"protocolName": "Serial Dilution", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# Add diluent to columns 2-12
p300.transfer(100, reservoir["A1"], plate.rows()[0][1:])
# Serial dilution across row A
p300.transfer(
100,
plate.rows()[0][:11],
plate.rows()[0][1:],
mix_after=(3, 50),
new_tip="always",
)
Workflow: PCR Setup with Thermocycler
from opentrons import protocol_api
metadata = {"protocolName": "PCR Setup", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tc_mod = protocol.load_module("thermocyclerModuleV2")
tc_plate = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
reagents = protocol.load_labware("opentrons_24_tuberack_nest_1.5ml_snapcap", "2")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
tc_mod.open_lid()
# Distribute master mix
p300.distribute(20, reagents["A1"], tc_plate.wells()[:8], new_tip="once")
# Add samples
for i in range(8):
p300.transfer(5, reagents.wells()[i + 1], tc_plate.wells()[i], new_tip="always")
# Run PCR
tc_mod.close_lid()
tc_mod.set_lid_temperature(105)
tc_mod.set_block_temperature(95, hold_time_seconds=180) # Initial denaturation
profile = [
{"temperature": 95, "hold_time_seconds": 15},
{"temperature": 60, "hold_time_seconds": 30},
{"temperature": 72, "hold_time_seconds": 30},
]
tc_mod.execute_profile(steps=profile, repetitions=35, block_max_volume=25)
tc_mod.set_block_temperature(72, hold_time_minutes=5) # Final extension
tc_mod.set_block_temperature(4) # Hold
tc_mod.deactivate_lid()
tc_mod.open_lid()
Workflow: Magnetic Bead Cleanup
- Load magnetic module with deep-well plate, reservoir with wash buffers
- Engage magnets → aspirate supernatant → dispense to waste
- Disengage magnets → add wash buffer → mix → engage → remove wash (repeat 2x)
- Disengage → add elution buffer → mix → engage → transfer eluate to clean plate
Key Parameters
| Parameter | Function | Default | Range | Effect |
|---|---|---|---|---|
volume |
aspirate, dispense, transfer |
— | 1–1000 µL | Liquid volume |
new_tip |
transfer, distribute, consolidate |
"always" |
"always", "once", "never" |
Tip change strategy |
mix_after |
transfer |
None |
(reps, vol) tuple |
Post-dispense mixing |
mix_before |
transfer |
None |
(reps, vol) tuple |
Pre-aspirate mixing |
blow_out |
transfer |
False |
True/False |
Blow out after dispense |
touch_tip |
transfer |
False |
True/False |
Touch tip after dispense |
air_gap |
transfer |
0 |
0–pipette max µL | Air gap volume |
flow_rate.aspirate |
pipette property | varies | 1–1000 µL/s | Aspirate speed |
flow_rate.dispense |
pipette property | varies | 1–1000 µL/s | Dispense speed |
height_from_base |
mag_module.engage |
— | 0–20 mm | Magnet engagement height |
Best Practices
-
Always simulate first: Run
opentrons_simulate my_protocol.pybefore uploading to the robot. Catches labware conflicts, volume errors, and tip shortages without wasting consumables. -
Use compound operations over basic: Prefer
transfer(),distribute(),consolidate()over manualpick_up_tip/aspirate/dispense/drop_tipsequences — they handle tip management automatically. -
Anti-pattern — ignoring tip count: A protocol that runs out of tips will error mid-run. Count total tip uses vs rack capacity before running.
-
Track liquids for setup validation: Use
define_liquid()andload_liquid()to enable volume tracking in the Opentrons App. -
Anti-pattern — hardcoding deck slots across robot types: Flex uses grid coordinates (A1-D3), OT-2 uses numbers (1-11). Write separate metadata or use
requirements["robotType"]to ensure compatibility. -
Control flow rates for difficult liquids: Reduce aspirate speed for viscous solutions (glycerol, PEG), increase for water-like liquids.
-
Use pauses for manual intervention:
protocol.pause(msg=...)is safer thanprotocol.delay()when you need user action (e.g., adding reagent, sealing plate).
Common Recipes
Recipe: Plate Replication
from opentrons import protocol_api
metadata = {"protocolName": "Plate Replication", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")
dest = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
p300.transfer(100, source.wells(), dest.wells(), new_tip="always")
Recipe: Reagent Distribution with Multi-Channel
from opentrons import protocol_api
metadata = {"protocolName": "Multi-Channel Distribution", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
multi = protocol.load_instrument("p300_multi_gen2", "left", tip_racks=[tips])
# Fill all 96 wells: 12 columns × 8 rows via multi-channel
multi.transfer(100, reservoir["A1"], plate.rows()[0], new_tip="once")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
OutOfTipsError |
Protocol needs more tips than available | Add multiple tip racks to tip_racks= list, or reload tips with pipette.reset_tipracks() |
| Labware collision on deck | Two items assigned to overlapping slots | Check deck map — thermocycler auto-occupies multiple slots; use protocol.deck to inspect |
| Volume exceeds pipette capacity | Attempting to aspirate/dispense > max volume | Use distribute() which auto-splits volumes, or switch to a larger pipette |
LabwareNotFoundError |
Wrong labware API name | Check names at labware.opentrons.com; use exact API name strings |
| Protocol works in simulation but fails on robot | Hardware-specific timing issue | Add protocol.delay() between temperature changes; increase magnet engage time |
| Inaccurate volumes | Pipette calibration or air bubbles | Recalibrate pipette; pre-wet tips with mix(); adjust flow rates for viscous liquids |
ModuleNotAttachedError |
Module not connected or wrong model string | Verify module serial connection; use exact model strings ("temperature module gen2") |
Related Skills
- pylabrobot — multi-vendor lab automation (Hamilton, Beckman, Tecan) for cross-platform protocols
- biopython-molecular-biology — sequence design and primer tools for PCR protocol inputs
References
- Opentrons Protocol API v2 docs — official API reference
- Opentrons Labware Library — searchable labware definitions
- Opentrons Python Protocol Tutorial — step-by-step getting started guide
legacy/plotly-interactive-visualization/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill plotly-interactive-visualization -g -y
SKILL.md
Frontmatter
{
"name": "plotly-interactive-visualization",
"license": "MIT",
"description": "Interactive visualization with Plotly. 40+ chart types (scatter, line, heatmap, 3D, geographic) with hover, zoom, pan. Two APIs: Plotly Express (DataFrame) and Graph Objects (fine control). For static publication figures use matplotlib; for statistical grammar use seaborn."
}
Plotly — Interactive Scientific Visualization
Overview
Plotly is a Python graphing library for interactive, web-embeddable visualizations with 40+ chart types. It provides two APIs: Plotly Express (high-level, pandas-native) for quick plots and Graph Objects (low-level) for full customization. Output to interactive HTML, static PNG/PDF/SVG, or Dash web apps.
When to Use
- Creating interactive charts with hover tooltips, zoom, and pan
- Building multi-panel exploratory dashboards for data analysis
- Visualizing 3D data (surfaces, scatter3d, mesh, volume)
- Making geographic/map visualizations (choropleth, scatter_geo)
- Presenting data in web-embeddable HTML format
- Statistical distribution comparison (violin, box, histogram with marginals)
- Time series with range sliders and animation frames
- For static publication-quality figures (journal submissions), use
matplotlibinstead - For statistical grammar-of-graphics style, use
seaborninstead
Prerequisites
- Python packages:
plotly,pandas,numpy - For static export:
kaleido(PNG/PDF/SVG rendering) - For web apps:
dash(optional)
pip install plotly kaleido
Quick Start
import plotly.express as px
import pandas as pd
import numpy as np
# Sample data
np.random.seed(42)
df = pd.DataFrame({
"x": np.random.randn(200),
"y": np.random.randn(200),
"group": np.random.choice(["A", "B", "C"], 200),
"size": np.random.uniform(5, 20, 200),
})
fig = px.scatter(df, x="x", y="y", color="group", size="size",
title="Interactive Scatter Plot", hover_data=["group"])
fig.write_html("scatter.html")
fig.write_image("scatter.png", width=800, height=500, scale=2)
print("Saved scatter.html and scatter.png")
Core API
1. Plotly Express (High-Level API)
Quick, one-line charts from pandas DataFrames. Returns go.Figure objects that can be further customized.
import plotly.express as px
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({
"temperature": np.linspace(20, 80, 50),
"yield": 50 + 0.8 * np.linspace(20, 80, 50) + np.random.randn(50) * 5,
"catalyst": np.random.choice(["Pd", "Pt", "Rh"], 50),
})
# Scatter with trendline
fig = px.scatter(df, x="temperature", y="yield", color="catalyst",
trendline="ols", title="Temperature vs Yield")
fig.write_image("scatter_trend.png", width=700, height=450)
print("Saved scatter_trend.png")
# Bar chart
summary = df.groupby("catalyst")["yield"].mean().reset_index()
fig = px.bar(summary, x="catalyst", y="yield", color="catalyst",
title="Mean Yield by Catalyst")
fig.write_image("bar_catalyst.png", width=600, height=400)
print("Saved bar_catalyst.png")
# Heatmap from correlation matrix
import plotly.express as px
import pandas as pd
import numpy as np
np.random.seed(42)
data = pd.DataFrame(np.random.randn(100, 5), columns=["Gene_A", "Gene_B", "Gene_C", "Gene_D", "Gene_E"])
corr = data.corr()
fig = px.imshow(corr, text_auto=".2f", color_continuous_scale="RdBu_r",
zmin=-1, zmax=1, title="Gene Expression Correlation")
fig.write_image("heatmap.png", width=600, height=500)
print("Saved heatmap.png")
2. Graph Objects (Low-Level API)
Full control over individual traces, layouts, and annotations.
import plotly.graph_objects as go
import numpy as np
# 3D surface plot
x = np.linspace(-5, 5, 50)
y = np.linspace(-5, 5, 50)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.sqrt(X**2 + Y**2))
fig = go.Figure(data=[go.Surface(z=Z, x=X[0], y=y, colorscale="Viridis")])
fig.update_layout(title="3D Surface Plot",
scene=dict(xaxis_title="X", yaxis_title="Y", zaxis_title="Z"))
fig.write_image("surface_3d.png", width=700, height=500)
print("Saved surface_3d.png")
# Multi-trace figure with custom styling
import plotly.graph_objects as go
import numpy as np
np.random.seed(42)
x = np.linspace(0, 10, 100)
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=np.sin(x), mode="lines", name="sin(x)",
line=dict(color="blue", width=2)))
fig.add_trace(go.Scatter(x=x, y=np.cos(x), mode="lines", name="cos(x)",
line=dict(color="red", width=2, dash="dash")))
fig.add_hline(y=0, line_dash="dot", line_color="gray", opacity=0.5)
fig.add_annotation(x=np.pi/2, y=1, text="sin peak", showarrow=True, arrowhead=2)
fig.update_layout(template="plotly_white", title="Trigonometric Functions",
xaxis_title="x", yaxis_title="f(x)")
fig.write_image("multi_trace.png", width=700, height=400)
print("Saved multi_trace.png")
3. Subplots and Multi-Panel Layouts
Create figure grids with shared or independent axes.
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import numpy as np
np.random.seed(42)
data = np.random.randn(500)
fig = make_subplots(
rows=2, cols=2,
subplot_titles=("Histogram", "Box Plot", "Scatter", "Violin"),
specs=[[{"type": "histogram"}, {"type": "box"}],
[{"type": "scatter"}, {"type": "violin"}]],
)
fig.add_trace(go.Histogram(x=data, nbinsx=30, name="Hist"), row=1, col=1)
fig.add_trace(go.Box(y=data, name="Box"), row=1, col=2)
fig.add_trace(go.Scatter(x=data[:100], y=data[100:200], mode="markers", name="Scatter"), row=2, col=1)
fig.add_trace(go.Violin(y=data, name="Violin", box_visible=True), row=2, col=2)
fig.update_layout(height=700, width=800, title_text="Multi-Panel Dashboard", showlegend=False)
fig.write_image("subplots.png", width=800, height=700)
print("Saved subplots.png")
4. Statistical Charts
Distribution comparison, error bars, and statistical annotations.
import plotly.express as px
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({
"value": np.concatenate([np.random.normal(0, 1, 100), np.random.normal(2, 1.5, 100)]),
"group": ["Control"] * 100 + ["Treatment"] * 100,
})
# Histogram with marginal box plot
fig = px.histogram(df, x="value", color="group", marginal="box",
nbins=30, barmode="overlay", opacity=0.7,
title="Distribution Comparison")
fig.write_image("stat_hist.png", width=700, height=450)
print("Saved stat_hist.png")
# Violin plot with individual points
fig = px.violin(df, x="group", y="value", box=True, points="all",
title="Treatment Effect (Violin + Points)")
fig.write_image("violin.png", width=500, height=450)
print("Saved violin.png")
# Error bars
import plotly.graph_objects as go
import numpy as np
conditions = ["Control", "Low Dose", "Med Dose", "High Dose"]
means = [5.2, 7.1, 9.8, 11.3]
sems = [0.4, 0.6, 0.5, 0.8]
fig = go.Figure(data=[go.Bar(
x=conditions, y=means,
error_y=dict(type="data", array=sems, visible=True),
marker_color=["#636EFA", "#EF553B", "#00CC96", "#AB63FA"],
)])
fig.update_layout(title="Dose Response (mean ± SEM)", yaxis_title="Response",
template="plotly_white")
fig.write_image("error_bars.png", width=600, height=400)
print("Saved error_bars.png")
5. Export and Rendering
Save to interactive HTML, static images, or embed in notebooks.
import plotly.express as px
import pandas as pd
df = px.data.iris()
fig = px.scatter(df, x="sepal_width", y="sepal_length", color="species")
# Interactive HTML (full standalone)
fig.write_html("interactive.html")
# HTML with CDN (smaller file, needs internet)
fig.write_html("interactive_cdn.html", include_plotlyjs="cdn")
# Static images (requires kaleido)
fig.write_image("plot.png", width=800, height=500, scale=2) # 2x resolution
fig.write_image("plot.pdf") # Vector PDF
fig.write_image("plot.svg") # Vector SVG
# Get image as bytes (for embedding)
img_bytes = fig.to_image(format="png", width=600, height=400)
print(f"PNG bytes: {len(img_bytes)}")
6. Interactivity Features
Customize hover, animations, buttons, and range sliders.
import plotly.express as px
import pandas as pd
import numpy as np
# Custom hover template
np.random.seed(42)
df = pd.DataFrame({
"date": pd.date_range("2024-01-01", periods=100),
"price": 100 + np.cumsum(np.random.randn(100) * 2),
"volume": np.random.randint(1000, 5000, 100),
})
fig = px.line(df, x="date", y="price", title="Stock Price",
hover_data={"volume": True, "price": ":.2f"})
fig.update_traces(hovertemplate="<b>%{x|%Y-%m-%d}</b><br>Price: $%{y:.2f}<br>Volume: %{customdata[0]:,}<extra></extra>")
fig.update_xaxes(rangeslider_visible=True)
fig.write_html("timeseries.html")
print("Saved timeseries.html with range slider")
Common Workflows
Workflow 1: Exploratory Data Analysis Dashboard
Goal: Create a multi-panel interactive dashboard for dataset exploration.
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
import numpy as np
# Sample dataset
np.random.seed(42)
n = 300
df = pd.DataFrame({
"gene_expression": np.random.lognormal(2, 1, n),
"protein_level": np.random.lognormal(1.5, 0.8, n),
"cell_type": np.random.choice(["Neuron", "Astrocyte", "Microglia"], n),
"treatment": np.random.choice(["Control", "Drug_A", "Drug_B"], n),
"viability": np.random.uniform(0.3, 1.0, n),
})
fig = make_subplots(rows=2, cols=2,
subplot_titles=("Expression vs Protein", "Expression by Cell Type",
"Viability by Treatment", "Expression Distribution"))
# Panel 1: Scatter
for ct in df["cell_type"].unique():
sub = df[df["cell_type"] == ct]
fig.add_trace(go.Scatter(x=sub["gene_expression"], y=sub["protein_level"],
mode="markers", name=ct, opacity=0.6), row=1, col=1)
# Panel 2: Box
for ct in df["cell_type"].unique():
fig.add_trace(go.Box(y=df[df["cell_type"]==ct]["gene_expression"],
name=ct, showlegend=False), row=1, col=2)
# Panel 3: Violin
for tx in df["treatment"].unique():
fig.add_trace(go.Violin(y=df[df["treatment"]==tx]["viability"],
name=tx, showlegend=False, box_visible=True), row=2, col=1)
# Panel 4: Histogram
fig.add_trace(go.Histogram(x=df["gene_expression"], nbinsx=30,
name="Expression", showlegend=False), row=2, col=2)
fig.update_layout(height=800, width=1000, title="Exploratory Data Analysis")
fig.write_html("eda_dashboard.html")
fig.write_image("eda_dashboard.png", width=1000, height=800)
print("Saved eda_dashboard.html and eda_dashboard.png")
Workflow 2: Publication Figure with Annotations
Goal: Create a polished, annotated figure suitable for supplementary materials or presentations.
import plotly.graph_objects as go
import numpy as np
np.random.seed(42)
x = np.linspace(0, 24, 100)
control = 50 + 10 * np.sin(x * np.pi / 12) + np.random.randn(100) * 3
treatment = 70 + 15 * np.sin(x * np.pi / 12 + 0.5) + np.random.randn(100) * 4
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=control, mode="lines", name="Control",
line=dict(color="#636EFA", width=2)))
fig.add_trace(go.Scatter(x=x, y=treatment, mode="lines", name="Treatment",
line=dict(color="#EF553B", width=2)))
# Add shaded region for treatment window
fig.add_vrect(x0=6, x1=18, fillcolor="yellow", opacity=0.1, line_width=0,
annotation_text="Treatment Window", annotation_position="top left")
# Add annotation at peak difference
fig.add_annotation(x=12, y=85, text="Peak difference<br>p < 0.001",
showarrow=True, arrowhead=2, font=dict(size=11))
fig.update_layout(
template="plotly_white",
title="Circadian Response to Treatment",
xaxis_title="Time (hours)", yaxis_title="Response (AU)",
font=dict(family="Arial", size=12),
legend=dict(x=0.02, y=0.98),
width=700, height=450,
)
fig.write_image("publication_figure.png", width=700, height=450, scale=3)
print("Saved publication_figure.png (3x resolution)")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
template |
update_layout |
"plotly" |
"plotly_white", "plotly_dark", "ggplot2", "seaborn", "simple_white" |
Global figure styling theme |
color_continuous_scale |
px / go |
varies | "Viridis", "Plasma", "RdBu", "RdBu_r", "Blues" |
Color scale for continuous data |
barmode |
px.histogram |
"relative" |
"group", "overlay", "relative", "stack" |
How multiple histograms are arranged |
trendline |
px.scatter |
None |
None, "ols", "lowess", "expanding" |
Regression line overlay |
marginal |
px.scatter/histogram |
None |
None, "rug", "box", "violin", "histogram" |
Marginal distribution display |
scale |
write_image |
1 |
1–5 |
Image resolution multiplier (2=retina, 3=print) |
include_plotlyjs |
write_html |
True |
True, "cdn", "directory", False |
How Plotly.js is bundled in HTML |
opacity |
most trace types | 1.0 |
0.0–1.0 |
Trace transparency for overlapping data |
Best Practices
-
Start with Plotly Express, customize with Graph Objects:
pxfunctions returngo.Figureobjects, so you can always add Graph Objects methods after. Don't start withgounlesspxgenuinely cannot express what you need.fig = px.scatter(df, x="x", y="y", color="group") fig.update_layout(template="plotly_white") # go method on px figure fig.add_hline(y=threshold) -
Use
plotly_whitetemplate for publication figures: The defaultplotlytemplate has a gray background that looks unprofessional in papers.plotly_whiteorsimple_whitegives a clean look. -
Always set explicit width/height for static export: Without dimensions,
write_imageuses the default viewport size which may not match your target (journal column width, slide dimensions). -
Use
scale=2or higher for print-quality images: Defaultscale=1produces 72 DPI equivalent. For publications, usescale=3(216 DPI effective). -
Prefer HTML export for interactive data sharing: HTML files are self-contained and can be opened in any browser without Python. Use
include_plotlyjs="cdn"to reduce file size.
Common Recipes
Recipe: Correlation Matrix Heatmap
import plotly.express as px
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame(np.random.randn(100, 6), columns=[f"Var_{i}" for i in range(6)])
corr = df.corr()
# Mask upper triangle
mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
corr_masked = corr.where(~mask)
fig = px.imshow(corr_masked, text_auto=".2f", color_continuous_scale="RdBu_r",
zmin=-1, zmax=1, title="Correlation Matrix")
fig.write_image("correlation.png", width=600, height=500, scale=2)
print("Saved correlation.png")
Recipe: Animated Scatter Plot
import plotly.express as px
import pandas as pd
import numpy as np
np.random.seed(42)
frames = []
for year in range(2010, 2025):
n = 50
frames.append(pd.DataFrame({
"x": np.random.randn(n) * (year - 2009),
"y": np.random.randn(n) * (year - 2009),
"size": np.random.uniform(5, 20, n),
"year": year,
}))
df = pd.concat(frames)
fig = px.scatter(df, x="x", y="y", size="size", animation_frame="year",
range_x=[-30, 30], range_y=[-30, 30], title="Animated Scatter")
fig.write_html("animated.html")
print("Saved animated.html")
Recipe: Geographic Choropleth Map
import plotly.express as px
# Built-in gapminder dataset
df = px.data.gapminder().query("year == 2007")
fig = px.choropleth(df, locations="iso_alpha", color="gdpPercap",
hover_name="country", color_continuous_scale="Plasma",
title="GDP per Capita (2007)")
fig.write_image("choropleth.png", width=900, height=500, scale=2)
print("Saved choropleth.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
write_image fails with ValueError |
kaleido not installed |
pip install kaleido |
Blank/white image from write_image |
Plotly version mismatch with kaleido | Update both: pip install --upgrade plotly kaleido |
| HTML file very large (>10 MB) | Full Plotly.js bundled | Use fig.write_html(path, include_plotlyjs="cdn") |
| Hover data not showing | Column not in DataFrame or wrong name | Check hover_data parameter matches DataFrame columns exactly |
| Subplot traces appear in wrong panel | Incorrect row/col in add_trace |
Verify row= and col= match your make_subplots grid (1-indexed) |
| Colors don't match between px and go | Different default color sequences | Set explicitly: fig.update_layout(colorway=px.colors.qualitative.Plotly) |
| Animation slow/choppy | Too many points per frame | Reduce data points or use px.scatter with render_mode="webgl" for large datasets |
Related Skills
- matplotlib — static, publication-quality figures for journal submissions (more control over typography and layout)
- seaborn — statistical visualization with grammar-of-graphics approach (built on matplotlib)
- scientific-visualization — general principles of scientific figure design and color theory
References
- Plotly Python documentation — official guides and API reference
- Plotly Express API — high-level API reference
- Dash documentation — interactive web application framework
- Plotly community forum — community support and examples
legacy/seaborn-statistical-visualization/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill seaborn-statistical-visualization -g -y
SKILL.md
Frontmatter
{
"name": "seaborn-statistical-visualization",
"license": "BSD-3-Clause",
"description": "Statistical visualization on matplotlib + pandas. Distributions (histplot, kdeplot, violin, box), relational (scatter, line), categorical, regression, correlation heatmaps. Auto aggregation\/CIs. Use plotly for interactive; matplotlib for low-level."
}
Seaborn — Statistical Visualization
Overview
Seaborn is a Python visualization library for creating publication-quality statistical graphics with minimal code. It works directly with pandas DataFrames, provides automatic statistical estimation (means, CIs, KDE), and offers attractive default themes. Built on matplotlib for full customization access.
When to Use
- Creating distribution plots (histograms, KDE, violin plots, box plots) for data exploration
- Visualizing relationships between variables with automatic trend fitting and confidence intervals
- Comparing distributions across categorical groups (treatment vs control, tissue types)
- Generating correlation heatmaps and clustered heatmaps
- Quick exploratory data analysis with
pairplotfor all pairwise relationships - Multi-panel figures with automatic faceting by categorical variables
- For interactive plots with hover/zoom, use plotly instead
- For low-level figure control or custom layouts, use matplotlib directly
Prerequisites
pip install seaborn matplotlib pandas
Quick Start
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
df = sns.load_dataset("tips")
sns.scatterplot(data=df, x="total_bill", y="tip", hue="day", style="time")
plt.title("Tips by Day and Time")
plt.tight_layout()
plt.savefig("scatter.png", dpi=150)
print("Saved scatter.png")
Core API
1. Distribution Plots
Visualize univariate and bivariate distributions.
import seaborn as sns
import matplotlib.pyplot as plt
df = sns.load_dataset("tips")
# Histogram with density normalization
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
sns.histplot(data=df, x="total_bill", hue="time", stat="density",
multiple="stack", ax=axes[0])
axes[0].set_title("Histogram")
# KDE (smooth density estimate)
sns.kdeplot(data=df, x="total_bill", hue="time", fill=True,
bw_adjust=0.8, ax=axes[1])
axes[1].set_title("KDE")
# ECDF (empirical cumulative distribution)
sns.ecdfplot(data=df, x="total_bill", hue="time", ax=axes[2])
axes[2].set_title("ECDF")
plt.tight_layout()
plt.savefig("distributions.png", dpi=150)
print("Saved distributions.png")
# Bivariate KDE with contours
sns.kdeplot(data=df, x="total_bill", y="tip", fill=True,
levels=5, thresh=0.1, cmap="mako")
plt.title("Bivariate KDE")
plt.savefig("bivariate_kde.png", dpi=150)
2. Categorical Plots
Compare distributions or estimates across discrete categories.
import seaborn as sns
import matplotlib.pyplot as plt
df = sns.load_dataset("tips")
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# Box plot — quartiles and outliers
sns.boxplot(data=df, x="day", y="total_bill", hue="sex",
dodge=True, ax=axes[0])
axes[0].set_title("Box Plot")
# Violin plot — KDE + quartiles
sns.violinplot(data=df, x="day", y="total_bill", hue="sex",
split=True, inner="quart", ax=axes[1])
axes[1].set_title("Violin Plot")
# Bar plot — mean with CI
sns.barplot(data=df, x="day", y="total_bill", hue="sex",
estimator="mean", errorbar="ci", ax=axes[2])
axes[2].set_title("Bar Plot (mean ± 95% CI)")
plt.tight_layout()
plt.savefig("categorical.png", dpi=150)
print("Saved categorical.png")
# Swarm plot — all individual observations, non-overlapping
sns.swarmplot(data=df, x="day", y="total_bill", hue="sex", dodge=True)
plt.title("Swarm Plot")
plt.savefig("swarm.png", dpi=150)
3. Relational Plots
Explore relationships between continuous variables.
import seaborn as sns
import matplotlib.pyplot as plt
df = sns.load_dataset("tips")
# Scatter with multiple semantic mappings
sns.scatterplot(data=df, x="total_bill", y="tip",
hue="day", size="size", style="time")
plt.title("Scatter with Multi-Encoding")
plt.savefig("relational.png", dpi=150)
# Line plot with automatic aggregation and CI
fmri = sns.load_dataset("fmri")
sns.lineplot(data=fmri, x="timepoint", y="signal",
hue="region", style="event", errorbar="sd")
plt.title("Line Plot (mean ± SD)")
plt.savefig("lineplot.png", dpi=150)
4. Regression Plots
Fit and visualize linear models.
import seaborn as sns
import matplotlib.pyplot as plt
df = sns.load_dataset("tips")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Linear regression with CI band
sns.regplot(data=df, x="total_bill", y="tip", ci=95, ax=axes[0])
axes[0].set_title("Linear Regression")
# Residual plot (check model assumptions)
sns.residplot(data=df, x="total_bill", y="tip", ax=axes[1])
axes[1].set_title("Residuals")
plt.tight_layout()
plt.savefig("regression.png", dpi=150)
print("Saved regression.png")
5. Matrix Plots
Visualize rectangular data (correlations, heatmaps).
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
# Correlation heatmap
df = sns.load_dataset("tips")
corr = df.select_dtypes(include=[np.number]).corr()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm",
center=0, square=True, linewidths=0.5)
plt.title("Correlation Heatmap")
plt.tight_layout()
plt.savefig("heatmap.png", dpi=150)
print("Saved heatmap.png")
# Clustered heatmap with hierarchical clustering
flights = sns.load_dataset("flights").pivot(index="month", columns="year", values="passengers")
sns.clustermap(flights, cmap="viridis", standard_scale=1,
figsize=(10, 8), linewidths=0.5)
plt.savefig("clustermap.png", dpi=150)
6. Figure-Level Functions and Faceting
Create multi-panel figures with automatic faceting.
import seaborn as sns
df = sns.load_dataset("tips")
# relplot — faceted scatter/line plots
g = sns.relplot(data=df, x="total_bill", y="tip",
col="time", row="sex", hue="smoker",
kind="scatter", height=3, aspect=1.2)
g.set_axis_labels("Total Bill ($)", "Tip ($)")
g.savefig("faceted_scatter.png", dpi=150)
print("Saved faceted_scatter.png")
# catplot — faceted categorical plots
g = sns.catplot(data=df, x="day", y="total_bill",
col="time", kind="box", height=4, aspect=1)
g.set_titles("{col_name}")
g.savefig("faceted_boxplot.png", dpi=150)
7. Exploratory Grids (pairplot, jointplot)
Quickly explore all pairwise relationships.
import seaborn as sns
iris = sns.load_dataset("iris")
# Pairplot — matrix of pairwise relationships
g = sns.pairplot(iris, hue="species", corner=True,
diag_kind="kde", plot_kws={"alpha": 0.6})
g.savefig("pairplot.png", dpi=150)
print("Saved pairplot.png")
# Joint plot — bivariate + marginal distributions
g = sns.jointplot(data=iris, x="sepal_length", y="petal_length",
hue="species", kind="scatter")
g.savefig("jointplot.png", dpi=150)
Key Concepts
Figure-Level vs Axes-Level Functions
Understanding this distinction is critical for composing seaborn with matplotlib:
| Feature | Axes-Level | Figure-Level |
|---|---|---|
| Examples | scatterplot, histplot, boxplot, heatmap |
relplot, displot, catplot, lmplot |
| Returns | matplotlib.axes.Axes |
FacetGrid / JointGrid / PairGrid |
| Faceting | Manual (create subplots yourself) | Built-in (col, row params) |
| Sizing | figsize on parent figure |
height + aspect per subplot |
| Placement | ax= parameter |
Cannot be placed in existing figure |
| Use when | Combining with other plot types, custom layouts | Quick faceted views, exploratory analysis |
# Axes-level: embed in custom layout
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sns.boxplot(data=df, x="day", y="tip", ax=axes[0])
sns.scatterplot(data=df, x="total_bill", y="tip", ax=axes[1])
Data Format: Long vs Wide
Seaborn strongly prefers long-form (tidy) data where each variable is a column:
# Long-form (preferred) — works with all functions
# subject condition value
# 0 1 control 10.5
# 1 1 treatment 12.3
# Wide-form — works with some functions (heatmap, lineplot)
# control treatment
# 0 10.5 12.3
# Convert wide → long
df_long = df.melt(var_name="condition", value_name="value")
Common Workflows
Workflow 1: Exploratory Data Analysis
Goal: Quickly survey a new dataset's distributions and relationships.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
df = sns.load_dataset("penguins").dropna()
# 1. Pairwise relationships
g = sns.pairplot(df, hue="species", corner=True)
g.savefig("eda_pairplot.png", dpi=150)
# 2. Correlation heatmap
fig, ax = plt.subplots(figsize=(8, 6))
corr = df.select_dtypes(include=[np.number]).corr()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", center=0, ax=ax)
ax.set_title("Feature Correlations")
plt.tight_layout()
plt.savefig("eda_corr.png", dpi=150)
# 3. Distribution by group
g = sns.displot(df, x="flipper_length_mm", hue="species",
kind="kde", fill=True, col="sex", height=4)
g.savefig("eda_dist.png", dpi=150)
print("EDA figures saved")
Workflow 2: Publication-Quality Figure
Goal: Create a polished multi-panel figure for a paper.
import seaborn as sns
import matplotlib.pyplot as plt
sns.set_theme(style="ticks", context="paper", font_scale=1.1)
df = sns.load_dataset("penguins").dropna()
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
# Panel A: Box plot
sns.boxplot(data=df, x="species", y="body_mass_g", hue="sex",
palette="Set2", ax=axes[0])
axes[0].set_ylabel("Body Mass (g)")
axes[0].set_title("A", loc="left", fontweight="bold")
# Panel B: Scatter with regression
sns.regplot(data=df, x="flipper_length_mm", y="body_mass_g",
scatter_kws={"alpha": 0.5, "s": 20}, ax=axes[1])
axes[1].set_xlabel("Flipper Length (mm)")
axes[1].set_ylabel("Body Mass (g)")
axes[1].set_title("B", loc="left", fontweight="bold")
# Panel C: Violin plot
sns.violinplot(data=df, x="species", y="bill_length_mm",
inner="quart", palette="muted", ax=axes[2])
axes[2].set_ylabel("Bill Length (mm)")
axes[2].set_title("C", loc="left", fontweight="bold")
sns.despine(trim=True)
plt.tight_layout()
plt.savefig("figure_pub.pdf", dpi=300, bbox_inches="tight")
plt.savefig("figure_pub.png", dpi=300, bbox_inches="tight")
print("Publication figure saved as PDF and PNG")
Key Parameters
| Parameter | Function | Default | Range / Options | Effect |
|---|---|---|---|---|
hue |
All plot functions | None | Column name | Color-encode a categorical/continuous variable |
style |
scatterplot, lineplot |
None | Column name | Marker/line style encoding |
size |
scatterplot, lineplot |
None | Column name | Point/line size encoding |
col / row |
Figure-level only | None | Column name | Create faceted subplots |
col_wrap |
Figure-level only | None | int | Max columns before wrapping |
estimator |
barplot, pointplot |
"mean" |
"mean", "median", callable |
Aggregation function |
errorbar |
barplot, lineplot |
("ci", 95) |
"ci", "sd", "se", "pi" |
Error bar type |
stat |
histplot |
"count" |
"count", "frequency", "density", "probability" |
Histogram normalization |
bw_adjust |
kdeplot, violinplot |
1.0 |
0.1–3.0 |
KDE bandwidth multiplier (higher=smoother) |
multiple |
histplot, kdeplot |
"layer" |
"layer", "stack", "dodge", "fill" |
How to handle overlapping hue groups |
kind |
relplot, catplot, displot |
varies | Plot type string | Select specific plot type for figure-level functions |
Best Practices
-
Use DataFrames with named columns: Seaborn's strength is semantic mapping from column names. Avoid passing raw arrays — you lose axis labels and legend entries.
-
Choose axes-level for custom layouts, figure-level for faceting: If you need to combine different plot types in one figure, use axes-level functions with
ax=. If you want automatic faceting, use figure-level functions. -
Use
set_theme()once at the start: Set style, context, and palette globally before creating plots. Reset withsns.set_theme(). -
Use
"colorblind"palette for accessibility:sns.set_palette("colorblind")ensures your plots are distinguishable for readers with color vision deficiency. -
Always call
plt.tight_layout()before saving: Prevents axis labels from being clipped. For figure-level functions, useg.tight_layout(). -
Anti-pattern — using seaborn for highly customized layouts: If you need pixel-perfect control over every element, use matplotlib directly. Seaborn is for quick, attractive statistical plots, not for custom infographics.
-
Anti-pattern — wide-form data with semantic mappings: Functions like
scatterplot(hue=...)require long-form data. Usepd.melt()to convert wide-form first.
Common Recipes
Recipe: Annotated Heatmap with Significance Stars
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
from scipy import stats
# Compute correlation and p-values
df = sns.load_dataset("penguins").dropna().select_dtypes(include=[np.number])
n = len(df)
corr = df.corr()
p_values = df.corr().copy()
for i in df.columns:
for j in df.columns:
_, p = stats.pearsonr(df[i], df[j])
p_values.loc[i, j] = p
# Create annotation with stars
annot = corr.round(2).astype(str)
for i in range(len(corr)):
for j in range(len(corr)):
if i != j and p_values.iloc[i, j] < 0.001:
annot.iloc[i, j] += "***"
elif i != j and p_values.iloc[i, j] < 0.01:
annot.iloc[i, j] += "**"
sns.heatmap(corr, annot=annot, fmt="", cmap="coolwarm", center=0, square=True)
plt.title("Correlation with Significance")
plt.tight_layout()
plt.savefig("heatmap_sig.png", dpi=150)
Recipe: Custom PairGrid with Mixed Plot Types
import seaborn as sns
df = sns.load_dataset("penguins").dropna()
g = sns.PairGrid(df, hue="species", corner=True)
g.map_upper(sns.scatterplot, alpha=0.5)
g.map_lower(sns.kdeplot, fill=True, alpha=0.3)
g.map_diag(sns.histplot, kde=True)
g.add_legend()
g.savefig("custom_pairgrid.png", dpi=150)
print("Saved custom_pairgrid.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Legend outside plot area (clipped) | Figure-level functions place legend outside by default | Use g._legend.set_bbox_to_anchor((0.9, 0.5)) or plt.tight_layout() |
| Overlapping x-axis labels | Long category names | plt.xticks(rotation=45, ha="right") + plt.tight_layout() |
| Figure too small | Default sizing insufficient | Axes-level: fig, ax = plt.subplots(figsize=(10, 6)); Figure-level: height=6, aspect=1.5 |
| Colors not distinct enough | Default palette has too-similar colors | Use sns.set_palette("bright") or sns.color_palette("husl", n_colors=N) |
| KDE too smooth or jagged | Bandwidth too wide or narrow | Adjust bw_adjust: lower (0.5) for detail, higher (2.0) for smoothing |
FacetGrid cannot be placed in existing figure |
Figure-level functions create their own figure | Use the corresponding axes-level function with ax= parameter |
ValueError with hue on wide-form data |
Semantic mappings require long-form | Convert with df.melt(var_name=..., value_name=...) |
Related Skills
- matplotlib-scientific-plotting — low-level control, custom layouts, and publication-quality figure export
- plotly-interactive-visualization — interactive charts with hover, zoom, and HTML export
- statsmodels-statistical-modeling — statistical models whose results can be visualized with seaborn regression plots
References
- Seaborn documentation — official API reference and tutorial
- Seaborn gallery — visual examples of all plot types
- Waskom (2021) "seaborn: statistical data visualization" — JOSS
legacy/single-cell-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill single-cell-annotation -g -y
SKILL.md
Frontmatter
{
"name": "single-cell-annotation",
"license": "open",
"description": "Best practices for single-cell RNA-seq cell type annotation including marker-based, reference-based, and automated classification approaches."
}
Single Cell RNA-seq Cell Type Annotation
Metadata
Short Description: Best practices for annotating cell types in single-cell RNA-seq data using marker-based, automated, and reference-based approaches.
Authors: Distilled from "Single-cell best practices" by Luecken, M.D. et al.
Affiliations: Helmholtz Munich, Wellcome Sanger Institute, Harvard Medical School, and contributors
Version: 1.0
Last Updated: January 2025
License: CC BY 4.0
Commercial Use: ✅ Allowed
Source: https://www.sc-best-practices.org/cellular_structure/annotation.html
Citation: Luecken, M.D., Theis, F.J. et al. (2023). Current best practices in single-cell RNA-seq analysis: a tutorial. Molecular Systems Biology.
Overview
Cell type annotation is the process of assigning cell type labels to clusters or individual cells in single-cell RNA-seq data. This guide covers three main approaches and their practical implementation.
Key Concepts
Cell Type vs. Cell State
A cell type is a stable identity defined by a developmental trajectory and core marker gene program (e.g., CD4+ T cell, hepatocyte). A cell state is a transient condition (activated, cycling, stressed) overlaid on a cell type. Annotation should target cell types first; states are attributes that may further subdivide a type but should not be conflated with type identity.
Marker Genes and Marker Panels
Marker genes are genes whose expression is enriched in a specific cell type relative to other cells in the same tissue context. Reliable annotation uses panels of multiple markers (typically 3-5 per type) rather than a single gene, because expression is noisy in droplet-based scRNA-seq and many markers are shared across related types. Markers come in two flavors: canonical (literature-derived, e.g., CD3D for T cells) and data-derived (from differential expression on the dataset).
Reference Atlases and Label Transfer
A reference atlas is a previously annotated dataset (e.g., Human Cell Atlas, Tabula Sapiens) used to project labels onto a new "query" dataset. Label transfer methods (scArches, scANVI, Azimuth, SingleR) align query cells into the reference latent space and assign the nearest neighbor's label. Quality of transfer depends on tissue match, technology match (e.g., 10x v3 vs. Smart-seq2), and species match.
Decision Framework
Use this tree to choose an annotation approach:
Do you have a well-characterized tissue
with a high-quality reference atlas?
│
┌─────────────┴─────────────┐
│ │
YES NO
│ │
▼ ▼
Is this a standard tissue Are you studying
(PBMC, lung, gut) with a novel cell types or
pre-trained classifier? exploratory data?
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │
YES NO YES NO
│ │ │ │
▼ ▼ ▼ ▼
Automated Reference- Manual marker Manual +
(CellTypist) based based automated
(scArches, (Scanpy, cross-check
Azimuth, Seurat)
SingleR)
Decision Table
| Scenario | Approach | Primary Tool | Validation |
|---|---|---|---|
| Standard human PBMC, large dataset (>100k cells) | Automated | CellTypist | Spot-check with manual markers |
| Well-characterized tissue (lung, kidney, brain) | Reference-based label transfer | scArches / Azimuth | Marker consistency on top clusters |
| Novel/rare tissue, no good reference | Manual marker-based | Scanpy / Seurat | Hierarchical, broad-to-fine |
| Cross-species (e.g., zebrafish) | Manual markers + ortholog mapping | Scanpy + custom panel | Compare to closest reference species |
| Developmental / continuous trajectory | Reference-based with state-aware model | scANVI / scArches | Trajectory coherence + markers |
| Disease tissue with known perturbation | Manual + automated cross-check | CellTypist + Scanpy | Confirm disease-specific states separately |
Three Annotation Approaches
1. Manual Marker-Based Annotation
Identify cell types by examining expression of known marker genes in each cluster.
Tools: Scanpy, Seurat Best for: Small datasets, novel cell types, high confidence needs
2. Automated Annotation
Use pre-trained classifiers to automatically assign cell type labels.
Tools: CellTypist, scAnnotate Best for: Standard tissues, quick preliminary annotation, large datasets
3. Reference-Based Label Transfer
Transfer labels from annotated reference datasets to your query data.
Tools: scArches, scANVI, Azimuth, SingleR Best for: Well-characterized tissues, integration with public data
Recommended Workflow
Step 1: Quality Control First
- Remove low-quality cells before annotation
- Filter doublets (expected doublet rate: 0.8% per 1000 cells)
- Check for ambient RNA contamination
- Verify cluster quality and resolution
Step 2: Initial Marker-Based Assessment
# Scanpy example
import scanpy as sc
# Calculate marker genes for clusters
sc.tl.rank_genes_groups(adata, 'leiden', method='wilcoxon')
# Visualize top markers
sc.pl.rank_genes_groups(adata, n_genes=25, sharey=False)
# Plot known markers
markers = {
'T cells': ['CD3D', 'CD3E', 'CD4', 'CD8A'],
'B cells': ['CD19', 'MS4A1', 'CD79A'],
'Monocytes': ['CD14', 'FCGR3A', 'LYZ'],
'NK cells': ['NCAM1', 'NKG7', 'GNLY']
}
sc.pl.dotplot(adata, markers, groupby='leiden')
Step 3: Use Automated Tools for Validation
# CellTypist example (fast, accurate for immune cells)
import celltypist
from celltypist import models
# Download immune cell model
model = models.Model.load(model='Immune_All_Low.pkl')
# Predict cell types
predictions = celltypist.annotate(adata, model=model, majority_voting=True)
adata = predictions.to_adata()
Step 4: Reference-Based Refinement
# scArches example for label transfer
import scarches as sca
# Load pre-trained reference model
model = sca.models.SCANVI.load_query_data(
adata=adata, # Your query data
reference_model="path/to/reference_model"
)
# Transfer labels
model.train(max_epochs=100)
adata.obs['transferred_labels'] = model.predict()
Best Practices
Do's:
- Always combine multiple approaches - Use marker-based validation even with automated tools
- Check cluster purity - Ensure clusters represent single cell types
- Validate with multiple marker sets - Don't rely on single markers
- Consider biological context - Tissue type, disease state, developmental stage
- Document confidence levels - Note uncertain annotations
- Use hierarchical annotation - Broad categories first, then subtypes
Don'ts:
- Don't over-cluster - Too fine resolution creates artificial distinctions
- Don't ignore batch effects - Correct before annotation
- Don't trust automation blindly - Always validate predictions
- Don't mix cell states with cell types - Activated vs. resting cells are states, not types
- Don't annotate low-quality cells - Remove them first
Common Pitfalls
- Doublet Clusters: Clusters that show markers from multiple cell types are often doublets, not novel hybrid populations.
- How to avoid: Run doublet detection tools (Scrublet, DoubletFinder) before annotation and remove flagged cells.
- Ambient RNA Contamination: Background markers appear across all cells, blurring cell type boundaries.
- How to avoid: Apply SoupX or CellBender decontamination during preprocessing — don't trust raw counts on droplet data.
- Over-interpretation of Small Clusters: Rare clusters (<25 cells) are often technical artifacts rather than biological subtypes.
- How to avoid: Require a minimum cell count threshold and validate with an independent dataset before naming the cluster.
- Reference Mismatch: Transferring labels from a reference built on a different tissue, species, or condition produces confidently wrong annotations.
- How to avoid: Use tissue- and species-matched references, and check marker-gene overlap between query and reference before label transfer.
- Confusing Cell States with Cell Types: Activated vs. resting T cells, M1 vs. M2 macrophages, and cycling vs. quiescent cells are states, not distinct types.
- How to avoid: Annotate cell type first using stable lineage markers, then layer state annotations on top — don't mix the two axes.
- Trusting Automated Tools Blindly: CellTypist or SingleR predictions look authoritative but can fail silently on out-of-distribution cells.
- How to avoid: Always cross-check automated calls against marker-based dot plots, and flag low-confidence predictions for manual review.
- Annotating Low-Quality Cells: Including cells with high mitochondrial content or low gene counts contaminates downstream signatures.
- How to avoid: Apply QC filters (mt%, n_genes, n_counts) before clustering — don't annotate first and clean up later.
Tool Selection Guide
| Scenario | Recommended Tool | Why |
|---|---|---|
| Immune cells (human) | CellTypist | Pre-trained on large immune atlases |
| Mouse tissues | scArches + Mouse Cell Atlas | Comprehensive mouse reference |
| Novel cell types | Manual + Scanpy/Seurat | Need domain expertise |
| Large datasets (>100k cells) | CellTypist | Fast, scalable |
| Cross-species | Manual markers | Limited reference transfer |
| Developmental data | scArches | Handles continuous states |
Key Marker Genes by Cell Type
Blood/Immune:
- T cells: CD3D, CD3E (all T cells); CD4, CD8A (subtypes)
- B cells: CD19, MS4A1 (CD20), CD79A
- Monocytes/Macrophages: CD14, CD68, LYZ
- NK cells: NCAM1 (CD56), NKG7, KLRD1
- Dendritic cells: FCER1A, CD1C
Epithelial:
- General epithelial: EPCAM, KRT18, KRT19
- Lung AT1: AGER, PDPN
- Lung AT2: SFTPC, SFTPA1
- Intestinal: VIL1, MUC2
Stromal:
- Fibroblasts: COL1A1, DCN, LUM
- Endothelial: PECAM1 (CD31), VWF, CDH5
- Smooth muscle: ACTA2, MYH11, TAGLN
Validation Checklist
- Cluster purity: >80% cells with same label per cluster
- Marker consistency: Top DE genes match expected markers
- Biological plausibility: Expected proportions for tissue type
- Cross-method agreement: Manual and automated annotations align
- Reference quality: >70% cells successfully transferred
- Doublet check: No clusters with multi-lineage markers
- Documentation: Record confidence levels and uncertain calls
References
Tools:
- Scanpy: https://scanpy.readthedocs.io/
- CellTypist: https://www.celltypist.org/
- scArches: https://scarches.readthedocs.io/
- Seurat: https://satijalab.org/seurat/
Marker Databases & Atlases:
- PanglaoDB: https://panglaodb.se/ (Database of marker genes)
- CellMarker: http://bio-bigdata.hrbmu.edu.cn/CellMarker/ (Curated cell marker database)
- Human Cell Atlas: https://www.humancellatlas.org/ (Reference datasets)
- Single Cell Best Practices: https://www.sc-best-practices.org/cellular_structure/annotation.html
- Luecken & Theis (2023): Current best practices in single-cell RNA-seq analysis. Molecular Systems Biology.
Pre-trained Models:
- CellTypist models: 30+ tissue-specific models
- Azimuth references: PBMC, lung, kidney, etc.
- scArches models: Multiple tissue references
Troubleshooting
Issue: All clusters look similar → Increase clustering resolution, check if data is normalized
Issue: Too many small clusters → Decrease resolution, merge similar clusters based on markers
Issue: Automated tool gives inconsistent results → Check input normalization, try multiple tools, fall back to manual
Issue: Can't find clear markers for cluster → May be transitional state, doublet, or low-quality cells
Issue: Reference transfer fails → Check batch correction, ensure overlapping gene sets, verify tissue match
skills/biostatistics/pymc-bayesian-modeling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pymc-bayesian-modeling -g -y
SKILL.md
Frontmatter
{
"name": "pymc-bayesian-modeling",
"license": "Apache-2.0",
"description": "Bayesian modeling with PyMC 5: priors, likelihood, NUTS\/ADVI sampling, diagnostics (R-hat, ESS), LOO\/WAIC comparison, prediction. Hierarchical, logistic, GP variants; predictive checks."
}
PyMC Bayesian Modeling
Overview
PyMC is a Python library for Bayesian statistical modeling and probabilistic programming. It provides an expressive syntax for defining probabilistic models and efficient inference via MCMC (NUTS) and variational methods (ADVI). This skill covers the full Bayesian modeling cycle from model specification through diagnostics, comparison, and prediction.
When to Use
- Estimating parameters with full uncertainty quantification (credible intervals, not just point estimates)
- Fitting hierarchical/multilevel models to grouped or nested data
- Performing prior and posterior predictive checks to validate model assumptions
- Comparing candidate models using information criteria (LOO-CV, WAIC)
- Building regression models (linear, logistic, Poisson) in a Bayesian framework
- Handling missing data or measurement error as latent parameters
- Modeling time series with autoregressive or random walk priors
- Generating posterior predictions for new observations with uncertainty bounds
- Use Stan/PyStan instead for compiled, more scalable Bayesian inference on large models; use statsmodels for frequentist statistical tests
Prerequisites
- Python packages:
pymc >= 5.0,arviz,numpy,matplotlib - Data: NumPy arrays or pandas DataFrames with numeric columns
- Environment: CPU sufficient for most models; GPU via JAX backend for large models
pip install pymc arviz numpy matplotlib
# Optional: JAX backend for GPU acceleration
pip install pymc[jax]
Quick Start
import pymc as pm
import arviz as az
import numpy as np
# Simulate data
np.random.seed(42)
X = np.random.randn(100)
y = 2.5 + 1.3 * X + np.random.randn(100) * 0.5
# Build and fit model
with pm.Model() as model:
alpha = pm.Normal("alpha", mu=0, sigma=5)
beta = pm.Normal("beta", mu=0, sigma=5)
sigma = pm.HalfNormal("sigma", sigma=1)
mu = alpha + beta * X
y_obs = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y)
idata = pm.sample(1000, tune=1000, chains=4, random_seed=42)
print(az.summary(idata, var_names=["alpha", "beta", "sigma"]))
# Expected: alpha ~ 2.5, beta ~ 1.3, sigma ~ 0.5
Workflow
Step 1: Prepare Data
Standardize continuous predictors for better sampling efficiency. Use named coordinates for readable models and ArviZ integration.
import pymc as pm
import arviz as az
import numpy as np
# Load data
X = np.random.randn(200, 3) # 200 obs, 3 predictors
y = X @ np.array([1.0, -0.5, 0.3]) + np.random.randn(200) * 0.8
# Standardize predictors
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_scaled = (X - X_mean) / X_std
# Define coordinates for named dimensions
coords = {
"predictors": ["var1", "var2", "var3"],
"obs_id": np.arange(len(y)),
}
print(f"Data shape: X={X_scaled.shape}, y={y.shape}")
Step 2: Define Model and Set Priors
Specify the model structure inside a pm.Model() context. Use weakly informative priors, dims for named dimensions, and HalfNormal or Exponential for scale parameters.
with pm.Model(coords=coords) as model:
# Priors — weakly informative, not flat
alpha = pm.Normal("alpha", mu=0, sigma=1)
beta = pm.Normal("beta", mu=0, sigma=1, dims="predictors")
sigma = pm.HalfNormal("sigma", sigma=1)
# Linear predictor
mu = alpha + pm.math.dot(X_scaled, beta)
# Likelihood
y_obs = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y, dims="obs_id")
# Inspect model variables
print(model.basic_RVs) # Lists: [alpha, beta, sigma, y_obs]
Step 3: Prior Predictive Check
Validate that priors produce plausible data ranges before fitting. Adjust priors if simulated data is unreasonable.
with model:
prior_pred = pm.sample_prior_predictive(samples=1000, random_seed=42)
# Check prior-implied data range
prior_y = prior_pred.prior_predictive["y_obs"].values.flatten()
print(f"Prior predictive range: [{prior_y.min():.1f}, {prior_y.max():.1f}]")
print(f"Observed data range: [{y.min():.1f}, {y.max():.1f}]")
az.plot_ppc(prior_pred, group="prior", num_pp_samples=100)
Step 4: Sample Posterior (MCMC)
Run NUTS sampling with multiple chains. Include log_likelihood=True if you plan model comparison later.
with model:
idata = pm.sample(
draws=2000,
tune=1000,
chains=4,
target_accept=0.9,
random_seed=42,
idata_kwargs={"log_likelihood": True},
)
print(f"Posterior shape: {idata.posterior['beta'].shape}")
# Expected: (4 chains, 2000 draws, 3 predictors)
Step 5: Diagnose Sampling
Check convergence before interpreting results. All three diagnostics (R-hat, ESS, divergences) must pass.
# Summary with convergence diagnostics
summary = az.summary(idata, var_names=["alpha", "beta", "sigma"])
print(summary[["mean", "sd", "hdi_3%", "hdi_97%", "r_hat", "ess_bulk"]])
# R-hat convergence check
bad_rhat = summary[summary["r_hat"] > 1.01]
if len(bad_rhat) > 0:
print(f"WARNING: {len(bad_rhat)} parameters with R-hat > 1.01")
print(bad_rhat[["r_hat"]])
# Effective sample size check
low_ess = summary[summary["ess_bulk"] < 400]
if len(low_ess) > 0:
print(f"WARNING: {len(low_ess)} parameters with ESS < 400")
# Divergence check
n_div = idata.sample_stats.diverging.sum().item()
total = len(idata.posterior.draw) * len(idata.posterior.chain)
print(f"Divergences: {n_div}/{total} ({n_div / total * 100:.2f}%)")
# Visual diagnostics — trace plots and rank plots
az.plot_trace(idata, var_names=["alpha", "beta", "sigma"])
az.plot_rank(idata, var_names=["alpha", "beta", "sigma"])
Step 6: Posterior Predictive Check
Validate model fit by comparing simulated data from the posterior to observed data.
with model:
pm.sample_posterior_predictive(idata, extend_inferencedata=True, random_seed=42)
az.plot_ppc(idata, num_pp_samples=100)
# Blue = observed data, grey = posterior simulations
# Systematic deviations indicate model misspecification
Step 7: Compare Models
Use LOO-CV or WAIC to compare candidate models. Lower information criterion is better.
# Fit multiple models with log_likelihood=True, then compare
# Example: compare linear vs a second model
idatas = {"linear": idata} # add more fitted models here
comparison = az.compare(idatas, ic="loo")
print(comparison[["rank", "elpd_loo", "p_loo", "d_loo", "weight"]])
# Check LOO reliability via Pareto-k diagnostics
loo_result = az.loo(idata, pointwise=True)
high_k = (loo_result.pareto_k > 0.7).sum().item()
print(f"Observations with Pareto-k > 0.7: {high_k}")
# Interpretation: Dloo < 2 = similar models; Dloo > 10 = strong evidence
az.plot_compare(comparison)
Step 8: Generate Predictions
Produce posterior predictions for new data with full uncertainty propagation.
X_new = np.array([[0.5, -1.0, 0.2]])
X_new_scaled = (X_new - X_mean) / X_std
with model:
pm.set_data({"X_scaled": X_new_scaled})
post_pred = pm.sample_posterior_predictive(
idata.posterior, var_names=["y_obs"], random_seed=42
)
y_pred = post_pred.posterior_predictive["y_obs"]
print(f"Predicted mean: {y_pred.mean().item():.3f}")
print(f"94% HDI: {az.hdi(y_pred, hdi_prob=0.94).values}")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
draws |
1000 |
500-10000 |
Number of posterior samples per chain |
tune |
1000 |
500-5000 |
Warmup iterations (discarded); increase for complex posteriors |
chains |
4 |
2-8 |
Number of independent chains; minimum 4 for reliable R-hat |
cores |
all CPUs | 1-N |
Parallel chains; set equal to chains for full parallelism |
target_accept |
0.8 |
0.8-0.99 |
NUTS acceptance rate; increase to reduce divergences |
init |
"auto" |
"adapt_diag", "jitter+adapt_diag", "advi" |
Initialization strategy for sampler |
random_seed |
None |
any int | Seed for reproducibility |
idata_kwargs |
{} |
{"log_likelihood": True} |
Store log-likelihood for LOO/WAIC model comparison |
method (pm.fit) |
"advi" |
"advi", "fullrank_advi", "svgd" |
Variational inference algorithm |
n (pm.fit) |
10000 |
5000-100000 |
VI optimization iterations |
samples (prior pred) |
500 |
100-5000 |
Prior predictive samples for validation |
Key Concepts
Prior/Distribution Selection Guide
| Distribution | Use When | Key Parameters |
|---|---|---|
Normal(mu, sigma) |
Unbounded real-valued parameter (standardized data) | mu: center, sigma: spread |
HalfNormal(sigma) |
Scale/standard deviation parameter (positive) | sigma: spread of positive half |
Exponential(lam) |
Scale parameter, alternative to HalfNormal | lam: rate (1/mean) |
StudentT(nu, mu, sigma) |
Robust alternative to Normal (outlier-resistant) | nu: degrees of freedom (<10 = heavier tails) |
Beta(alpha, beta) |
Probability or proportion in [0,1] | alpha=beta=2: weakly informative |
Gamma(alpha, beta) |
Positive parameter (rate, concentration) | alpha: shape, beta: rate |
LogNormal(mu, sigma) |
Positive parameter with multiplicative effects | mu, sigma: of underlying Normal |
LKJCorr(n, eta) |
Correlation matrix prior | eta=1: uniform; eta>1: prefer identity |
Dirichlet(a) |
Probability vector (sums to 1) | a: concentration; uniform if all equal |
Bernoulli(p / logit_p) |
Binary outcome likelihood | Use logit_p for numerical stability |
Poisson(mu) |
Count data (equidispersed) | mu: rate; use NegBinomial if overdispersed |
NegativeBinomial(mu, alpha) |
Overdispersed count data | alpha: dispersion (smaller = more overdispersion) |
Diagnostic Thresholds
| Metric | Threshold | Interpretation | Action if Failed |
|---|---|---|---|
| R-hat | < 1.01 | Chains converged | Run longer chains; check multimodality |
| ESS bulk | > 400 | Sufficient independent samples | Increase draws; reparameterize |
| ESS tail | > 400 | Reliable tail estimates | Increase draws |
| Divergences | 0 | NUTS explored successfully | Increase target_accept; non-centered param. |
| Pareto-k (LOO) | < 0.7 | LOO estimate reliable | Use WAIC or k-fold CV |
| Max tree depth | < 10 | No trajectory truncation | Reparameterize or increase max_treedepth |
Model Variants Overview
| Problem Type | Recipe | Likelihood | Key Feature |
|---|---|---|---|
| Grouped/nested data | Hierarchical Model | Normal (varies) | Non-centered parameterization, partial pooling |
| Binary outcome | Logistic Regression | Bernoulli | logit_p link function |
| Nonlinear/spatial | Gaussian Process | Normal | Kernel-based covariance, flexible shape |
| Count data | (use Poisson in Workflow) | Poisson / NegBinomial | Log link; NegBinomial for overdispersion |
| Time series | (see references) | AR / GaussianRandomWalk | Autoregressive coefficients |
| Mixture/clustering | (see references) | Mixture / NormalMixture | Component weights via Dirichlet |
Common Recipes
Recipe: Hierarchical Model
When to use: data has natural grouping (patients within hospitals, students within schools). Non-centered parameterization avoids divergences from funnel geometry.
import pymc as pm
import arviz as az
import numpy as np
n_groups, n_per_group = 5, 30
group_idx = np.repeat(np.arange(n_groups), n_per_group)
group_names = [f"group_{i}" for i in range(n_groups)]
# Simulated grouped data
true_alphas = np.random.normal(3.0, 1.5, n_groups)
y_obs = np.random.normal(true_alphas[group_idx], 0.5)
with pm.Model(coords={"groups": group_names}) as hierarchical_model:
# Hyperpriors (population level)
mu_alpha = pm.Normal("mu_alpha", mu=0, sigma=5)
sigma_alpha = pm.HalfNormal("sigma_alpha", sigma=2)
# Non-centered parameterization
alpha_offset = pm.Normal("alpha_offset", mu=0, sigma=1, dims="groups")
alpha = pm.Deterministic(
"alpha", mu_alpha + sigma_alpha * alpha_offset, dims="groups"
)
# Observation level
sigma = pm.HalfNormal("sigma", sigma=1)
y = pm.Normal("y", mu=alpha[group_idx], sigma=sigma, observed=y_obs)
idata_hier = pm.sample(2000, tune=1000, target_accept=0.95, random_seed=42)
print(az.summary(idata_hier, var_names=["mu_alpha", "sigma_alpha", "alpha", "sigma"]))
Recipe: Logistic Regression
When to use: binary outcome variable (success/failure, disease/healthy). Use logit_p for numerical stability instead of computing probabilities directly.
import pymc as pm
import arviz as az
import numpy as np
# Simulated binary outcome
np.random.seed(42)
n, p = 200, 3
X_lr = np.random.randn(n, p)
true_beta = np.array([1.0, -0.5, 0.3])
prob = 1 / (1 + np.exp(-(0.2 + X_lr @ true_beta)))
y_binary = np.random.binomial(1, prob)
with pm.Model() as logistic_model:
alpha = pm.Normal("alpha", mu=0, sigma=2)
beta = pm.Normal("beta", mu=0, sigma=2, shape=p)
logit_p = alpha + pm.math.dot(X_lr, beta)
y = pm.Bernoulli("y", logit_p=logit_p, observed=y_binary)
idata_logit = pm.sample(2000, tune=1000, target_accept=0.9, random_seed=42)
summary = az.summary(idata_logit, var_names=["alpha", "beta"])
print(summary[["mean", "sd", "hdi_3%", "hdi_97%"]])
# Odds ratios
beta_samples = idata_logit.posterior["beta"].values.reshape(-1, p)
print(f"Odds ratios (median): {np.median(np.exp(beta_samples), axis=0)}")
Recipe: Gaussian Process
When to use: modeling unknown nonlinear functions, spatial data, or smooth latent processes. Computationally expensive for n > 1000; consider sparse approximations for larger datasets.
import pymc as pm
import arviz as az
import numpy as np
# Simulated nonlinear data
np.random.seed(42)
X_gp = np.sort(np.random.uniform(0, 10, 60))[:, None]
y_gp = np.sin(X_gp[:, 0]) + np.random.randn(60) * 0.3
with pm.Model() as gp_model:
# GP hyperparameters
ls = pm.Gamma("lengthscale", alpha=2, beta=1)
eta = pm.HalfNormal("amplitude", sigma=2)
sigma_noise = pm.HalfNormal("noise", sigma=0.5)
# Covariance function
cov = eta**2 * pm.gp.cov.Matern52(1, ls=ls)
gp = pm.gp.Marginal(cov_func=cov)
# Marginal likelihood
y_ = gp.marginal_likelihood("y", X=X_gp, y=y_gp, sigma=sigma_noise)
idata_gp = pm.sample(1000, tune=1000, target_accept=0.9, random_seed=42)
# Predict on new points
X_new_gp = np.linspace(0, 10, 100)[:, None]
with gp_model:
f_pred = gp.conditional("f_pred", X_new_gp)
pred_samples = pm.sample_posterior_predictive(idata_gp, var_names=["f_pred"])
print(f"GP predictions shape: {pred_samples.posterior_predictive['f_pred'].shape}")
Expected Outputs
- Trace plots (
az.plot_trace) -- chain mixing visualization; healthy chains look like "fuzzy caterpillars" - Posterior summary (
az.summary) -- mean, SD, HDI, R-hat, ESS per parameter - Prior/posterior predictive plots (
az.plot_ppc) -- simulated vs observed data distributions - Forest plots (
az.plot_forest) -- coefficient estimates with credible intervals - Model comparison table (
az.compare) -- ranked models with LOO/WAIC, weights, warnings - Rank plots (
az.plot_rank) -- uniform histograms indicate good mixing - Energy plot (
az.plot_energy) -- HMC energy transition diagnostics - InferenceData file (
idata.to_netcdf("results.nc")) -- serialized results for later analysis
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Divergent transitions | Posterior geometry difficult for NUTS (funnels, ridges) | Increase target_accept=0.95; use non-centered parameterization; add stronger priors |
| Low ESS (< 400) | High autocorrelation between samples | Increase draws=5000; reparameterize to reduce correlation; try QR decomposition for correlated predictors |
| R-hat > 1.01 | Chains have not converged | Increase tune=2000, draws=5000; check for multimodality; initialize with ADVI |
| Slow sampling | Complex posterior or large dataset | Use ADVI for initialization; reduce model complexity; increase cores; try JAX backend |
| Pareto-k > 0.7 in LOO | Influential observations affecting LOO estimate | Use WAIC instead; investigate influential points; consider k-fold CV |
| Hit max tree depth | Model geometry requires long trajectories | Reparameterize model; increase max_treedepth parameter |
SamplingError at start |
Poor initialization | Try init="adapt_diag" or init="jitter+adapt_diag"; provide initvals via MAP |
| Biased posterior (prior dominates) | Priors too strong relative to data | Weaken priors (increase sigma); check prior predictive; verify data is correctly passed |
ValueError: logp = -inf |
Parameter hit impossible region | Check data for NaN/Inf; ensure positive params use HalfNormal/Gamma; verify likelihood matches data type |
Best Practices
- Use weakly informative priors, not flat priors. Flat priors (Uniform over large ranges) cause sampling difficulties and are rarely appropriate.
- Always run prior predictive checks before fitting. If prior-implied data is implausible, adjust priors before sampling.
- Standardize continuous predictors to mean=0, sd=1 for better NUTS geometry and faster sampling.
- Use non-centered parameterization for hierarchical models:
offset ~ Normal(0,1); param = mu + sigma * offsetinstead ofparam ~ Normal(mu, sigma). - Run at least 4 chains for reliable R-hat and ESS diagnostics. Never trust a single chain.
- Check all three diagnostics (R-hat < 1.01, ESS > 400, 0 divergences) before interpreting posteriors.
- Include
log_likelihood=Truewhen fitting if you plan to compare models via LOO or WAIC. - Use
dimsinstead ofshapefor named dimensions -- integrates with ArviZ for labeled summaries and subsetting. - Report credible intervals (HDI), not just point estimates. The posterior distribution is the result, not a single number.
- Save results with
idata.to_netcdf("results.nc")for reproducibility and later re-analysis.
Bundled Resources
Two reference files provide deeper detail for on-demand consultation:
-
references/distributions_inference.md-- Distribution catalog organized by category (continuous, discrete, multivariate, mixture, time series, special/modifiers) with full parameter signatures, support, and common uses. Sampling and inference methods (NUTS, Metropolis, Slice, CompoundStep, SMC, ADVI, fullrank ADVI, SVGD, MAP) with code examples and a method selection guide table. Reparameterization tricks (non-centered, QR decomposition) with code. Covers: consolidated from originalreferences/distributions.md(320 lines) andreferences/sampling_inference.md(424 lines). Relocated inline: core distribution selection guide table (Key Concepts), diagnostic threshold table (Key Concepts), basic ADVI usage (Recipe: Variational Inference in original, covered in references here). Omitted: shape broadcasting examples (trivial NumPy-styleshape=5), basicdimsusage (covered in Workflow Step 2), prior predictive sampling (covered in Workflow Step 3). -
references/advanced_workflows.md-- Model comparison workflow (LOO, WAIC, Pareto-k reliability checks, model averaging with weighted predictions), comprehensive diagnostic report generation (trace plots, rank plots, autocorrelation, energy, ESS evolution), prior-posterior comparison patterns, data preparation best practices (standardization, centering, missing data imputation), prior selection guidelines (weakly informative vs informative with domain knowledge), named dimensions (dims) usage with xarray subsetting, save/load patterns (NetCDF, pickle). Mixture model pattern. Covers: consolidated from originalreferences/workflows.md(526 lines). Relocated inline: core diagnostic checking logic (Workflow Step 5), model comparison basics (Workflow Step 7), prior predictive check (Workflow Step 3), linear/logistic/hierarchical model code (Recipes). Omitted: complete monolithic workflow template (redundant with 8-step Workflow section).
Per-reference-file disposition (all originals)
Original references/ (3 files):
distributions.md(320 lines) -> consolidated withsampling_inference.mdinto newreferences/distributions_inference.mdsampling_inference.md(424 lines) -> consolidated withdistributions.mdinto newreferences/distributions_inference.mdworkflows.md(526 lines) -> migrated as newreferences/advanced_workflows.md
Original scripts/ (2 files):
model_diagnostics.py(350 lines) ->check_diagnostics()logic (R-hat, ESS, divergences, tree depth checks) inlined in Workflow Step 5;create_diagnostic_report()plot generation patterns described in Expected Outputs and referenced inreferences/advanced_workflows.md;compare_prior_posterior()utility covered inreferences/advanced_workflows.mdmodel_comparison.py(387 lines) ->compare_models()andcheck_loo_reliability()patterns inlined in Workflow Step 7 (usingaz.compareandaz.loodirectly);model_averaging()pattern covered inreferences/advanced_workflows.md;cross_validation_comparison()guidance covered inreferences/advanced_workflows.md;plot_model_comparison()is a thin wrapper aroundaz.plot_compare(inlined in Step 7)
Related Skills
- arviz (planned) -- posterior visualization and diagnostics; PyMC returns ArviZ InferenceData objects
- bambi (planned) -- formula-based interface to PyMC (R-style
y ~ x1 + x2syntax); simpler API for standard GLMs - numpyro (planned) -- JAX-based probabilistic programming; faster NUTS via hardware acceleration
- statsmodels-statistical-modeling -- frequentist regression and GLMs; use when Bayesian framework is not needed
- scikit-learn-machine-learning -- ML classification/regression; use when prediction accuracy matters more than uncertainty quantification
References
- PyMC documentation -- official API reference and tutorials
- PyMC GitHub -- source code and issue tracker
- ArviZ documentation -- posterior analysis and visualization
- Bayesian Modeling and Computation in Python -- textbook using PyMC
- PyMC examples gallery -- curated notebook collection
skills/biostatistics/scikit-survival-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scikit-survival-analysis -g -y
SKILL.md
Frontmatter
{
"name": "scikit-survival-analysis",
"license": "GPL-3.0",
"description": "Time-to-event modeling with scikit-survival: Cox PH (elastic net), Random Survival Forests, Boosting, SVMs for censored data. C-index, Brier, time-dependent AUC; Kaplan-Meier, Nelson-Aalen, competing risks. Pipeline\/GridSearchCV compatible. Use statsmodels for frequentist, pymc for Bayesian, lifelines for parametric."
}
scikit-survival -- Survival Analysis
Overview
scikit-survival is a Python library for time-to-event analysis built on scikit-learn. It handles right-censored data (observations where the event has not yet occurred) using Cox models, ensemble methods, survival SVMs, and non-parametric estimators. All models follow the scikit-learn fit/predict API and integrate with Pipelines, cross-validation, and GridSearchCV.
When to Use
- Modeling time-to-event outcomes with right-censored data (clinical trials, reliability)
- Fitting Cox proportional hazards models (standard or elastic net penalized)
- Building ensemble survival models (Random Survival Forest, Gradient Boosting)
- Training survival SVMs for margin-based learning on medium-sized datasets
- Evaluating survival predictions with censoring-aware metrics (C-index, Brier score, AUC)
- Estimating non-parametric survival curves (Kaplan-Meier, Nelson-Aalen)
- Analyzing competing risks with cumulative incidence functions
- High-dimensional survival data with automatic feature selection (CoxNet L1/L2)
- For simpler parametric models (Weibull, log-normal AFT) or statistical tests (log-rank), use
lifelines - For deep learning survival models, use
pycoxortorchlife
Prerequisites
pip install scikit-survival scikit-learn pandas numpy matplotlib
Python: >= 3.9. Dependencies: scikit-learn, numpy, scipy, pandas, joblib, osqp (for some SVM solvers).
Data format: Survival outcomes are NumPy structured arrays with (event, time) fields. Events are boolean (True = event occurred, False = censored). Times are positive floats.
Quick Start
from sksurv.datasets import load_breast_cancer
from sksurv.ensemble import RandomSurvivalForest
from sksurv.metrics import concordance_index_ipcw
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
rsf = RandomSurvivalForest(n_estimators=100, random_state=42)
rsf.fit(X_train, y_train)
risk_scores = rsf.predict(X_test)
c_index = concordance_index_ipcw(y_train, y_test, risk_scores)[0]
print(f"C-index: {c_index:.3f}") # e.g., 0.68
# Individual survival curves
surv_fns = rsf.predict_survival_function(X_test[:2])
for fn in surv_fns:
print(f"5-year survival: {fn(365 * 5):.3f}")
Core API
Module 1: Data Preparation
Create structured survival arrays and preprocess features.
import numpy as np
import pandas as pd
from sksurv.util import Surv
from sksurv.preprocessing import OneHotEncoder, encode_categorical
from sksurv.datasets import load_gbsg2, load_breast_cancer
from sklearn.preprocessing import StandardScaler
# Create survival outcome from arrays
event = np.array([True, False, True, True, False])
time = np.array([120.0, 365.0, 200.0, 90.0, 400.0])
y = Surv.from_arrays(event=event, time=time)
print(y.dtype) # [('event', '?'), ('time', '<f8')]
# From DataFrame columns
# y = Surv.from_dataframe("event_col", "time_col", df)
# Load built-in datasets
# Available: load_gbsg2, load_breast_cancer, load_veterans_lung_cancer,
# load_whas500, load_aids, load_flchain
X, y = load_gbsg2()
print(f"Shape: {X.shape}, Events: {y['event'].sum()}, "
f"Censoring rate: {1 - y['event'].mean():.1%}")
# Encode categoricals (survival-aware one-hot)
X_encoded = encode_categorical(X) # auto-detect and encode all categorical cols
# Standardize (critical for Cox and SVM models)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_encoded)
from sksurv.io import loadarff
# Load ARFF format (Weka format)
data = loadarff("survival_data.arff")
X_arff, y_arff = data[0], data[1] # DataFrame, structured array
Module 2: Cox Proportional Hazards
Semi-parametric model: h(t|x) = h_0(t) * exp(beta^T x). Interpretable coefficients as log hazard ratios.
from sksurv.linear_model import CoxPHSurvivalAnalysis, CoxnetSurvivalAnalysis, IPCRidge
# Standard Cox PH model
cox = CoxPHSurvivalAnalysis(alpha=0.0, ties="breslow")
cox.fit(X_train, y_train)
print(f"Coefficients: {cox.coef_}") # log hazard ratios
# Hazard ratio interpretation: exp(coef) = HR for 1-unit increase
risk_scores = cox.predict(X_test) # Higher = higher risk
# Survival function for individual patients
surv_funcs = cox.predict_survival_function(X_test[:3])
for fn in surv_funcs:
print(f"5-year survival: {fn(365 * 5):.3f}")
# Penalized Cox (elastic net) -- for high-dimensional data (p > n)
coxnet = CoxnetSurvivalAnalysis(
l1_ratio=0.9, # 0=Ridge, 1=Lasso, between=Elastic Net
alpha_min_ratio=0.01, # smallest alpha / largest alpha ratio
n_alphas=100, # steps in regularization path
)
coxnet.fit(X_train, y_train)
# Feature selection: non-zero coefficients
selected = np.where(coxnet.coef_ != 0)[0]
print(f"Selected {len(selected)} / {X_train.shape[1]} features")
# IPCRidge: accelerated failure time model (predicts log survival time)
ipcridge = IPCRidge(alpha=1.0)
ipcridge.fit(X_train, y_train)
log_survival_time = ipcridge.predict(X_test)
Module 3: Ensemble Methods
Non-parametric tree-based models for complex non-linear relationships.
from sksurv.ensemble import (
RandomSurvivalForest,
GradientBoostingSurvivalAnalysis,
ComponentwiseGradientBoostingSurvivalAnalysis,
ExtraSurvivalTrees,
)
# Random Survival Forest -- robust, minimal tuning
rsf = RandomSurvivalForest(
n_estimators=200, min_samples_split=10, min_samples_leaf=15,
max_features="sqrt", random_state=42, n_jobs=-1,
)
rsf.fit(X_train, y_train)
risk = rsf.predict(X_test)
# Gradient Boosting -- best performance, needs tuning
gbs = GradientBoostingSurvivalAnalysis(
loss="coxph", # "coxph" or "ipcwls" (AFT)
n_estimators=300, learning_rate=0.05, max_depth=3,
subsample=0.8, dropout_rate=0.1, random_state=42,
)
gbs.fit(X_train, y_train)
# ComponentwiseGB -- linear model with automatic feature selection
cgbs = ComponentwiseGradientBoostingSurvivalAnalysis(
n_estimators=100, learning_rate=0.1,
)
cgbs.fit(X_train, y_train)
print(f"Non-zero coefficients: {np.sum(cgbs.coef_ != 0)}")
# ExtraSurvivalTrees -- more regularized than RSF, faster training
est = ExtraSurvivalTrees(n_estimators=100, random_state=42)
est.fit(X_train, y_train)
# Survival curves from any ensemble model
surv_funcs = rsf.predict_survival_function(X_test[:1])
chf_funcs = rsf.predict_cumulative_hazard_function(X_test[:1])
Module 4: Survival SVMs
Margin-based learning for survival ranking. Always standardize features.
from sksurv.svm import FastSurvivalSVM, FastKernelSurvivalSVM, HingeLossSurvivalSVM
# Linear SVM -- fast, for linear relationships
lsvm = FastSurvivalSVM(alpha=1.0, rank_ratio=1.0, max_iter=100, random_state=42)
lsvm.fit(X_train_scaled, y_train)
risk = lsvm.predict(X_test_scaled)
# Kernel SVM -- for non-linear relationships (rbf, poly, sigmoid)
ksvm = FastKernelSurvivalSVM(
alpha=1.0, kernel="rbf", gamma="scale",
max_iter=50, random_state=42,
)
ksvm.fit(X_train_scaled, y_train)
# Hinge loss variant
hsvm = HingeLossSurvivalSVM(alpha=1.0, random_state=42)
hsvm.fit(X_train_scaled, y_train)
# NaiveSurvivalSVM also available but slower (O(n^3))
from sksurv.kernels import ClinicalKernelTransform
# Clinical kernel: combines clinical + molecular features
# Weighs clinical variables separately from high-dimensional molecular data
transform = ClinicalKernelTransform(fit_once=True)
transform.prepare(X_train) # auto-detect clinical features
X_kern = transform.fit_transform(X_train)
Module 5: Non-Parametric Estimation
Estimate survival and hazard curves without model assumptions.
from sksurv.nonparametric import kaplan_meier_estimator, nelson_aalen_estimator
import matplotlib.pyplot as plt
# Kaplan-Meier survival curve
time_km, surv_prob = kaplan_meier_estimator(y["event"], y["time"])
plt.step(time_km, surv_prob, where="post")
plt.xlabel("Time (days)")
plt.ylabel("Survival probability")
plt.title("Kaplan-Meier Estimate")
plt.savefig("km_curve.png", dpi=150)
# With confidence intervals
time_km, surv_prob, conf_int = kaplan_meier_estimator(
y["event"], y["time"], conf_type="log-log",
)
# Nelson-Aalen cumulative hazard
time_na, cum_hazard = nelson_aalen_estimator(y["event"], y["time"])
# Stratified KM by group
for group_name, mask in [("Treated", treated_mask), ("Control", control_mask)]:
t, s = kaplan_meier_estimator(y[mask]["event"], y[mask]["time"])
plt.step(t, s, where="post", label=group_name)
plt.legend()
Module 6: Evaluation Metrics
Censoring-aware metrics for discrimination and calibration.
from sksurv.metrics import (
concordance_index_censored,
concordance_index_ipcw,
cumulative_dynamic_auc,
integrated_brier_score,
brier_score,
as_concordance_index_ipcw_scorer,
as_integrated_brier_score_scorer,
)
import numpy as np
risk_scores = model.predict(X_test)
# Harrell's C-index (simple, biased with high censoring)
c_harrell = concordance_index_censored(
y_test["event"], y_test["time"], risk_scores
)[0]
# Uno's C-index (recommended -- robust to censoring)
c_uno = concordance_index_ipcw(y_train, y_test, risk_scores)[0]
print(f"C-index (Harrell): {c_harrell:.3f}, (Uno): {c_uno:.3f}")
# Time-dependent AUC at clinically relevant timepoints
times = np.array([365, 730, 1095]) # 1, 2, 3 years
auc, mean_auc = cumulative_dynamic_auc(y_train, y_test, risk_scores, times)
print(f"AUC at 1/2/3yr: {auc}, Mean: {mean_auc:.3f}")
# Integrated Brier Score (measures discrimination + calibration)
surv_funcs = model.predict_survival_function(X_test)
preds = np.row_stack([fn(times) for fn in surv_funcs])
ibs = integrated_brier_score(y_train, y_test, preds, times)
print(f"IBS: {ibs:.3f}") # Lower is better; compare vs KM baseline
Module 7: Competing Risks
Multiple mutually exclusive event types (e.g., death from cancer vs cardiovascular).
from sksurv.nonparametric import cumulative_incidence_competing_risks
from sksurv.linear_model import CoxPHSurvivalAnalysis
from sksurv.util import Surv
import numpy as np
# Cumulative Incidence Function (CIF) estimation
# y_competing: structured array with integer event codes
# (0=censored, 1=relapse, 2=death_in_remission)
time_pts, cif_relapse, cif_death = cumulative_incidence_competing_risks(y_competing)
import matplotlib.pyplot as plt
plt.step(time_pts, cif_relapse, where="post", label="Relapse")
plt.step(time_pts, cif_death, where="post", label="Death in remission")
plt.xlabel("Time")
plt.ylabel("Cumulative Incidence")
plt.legend()
# Cause-specific hazard models: fit separate Cox per event type
event_types = np.array([0, 1, 2, 1, 0, 2, 1])
times = np.array([10.2, 5.3, 8.1, 3.7, 12.5, 6.8, 4.2])
# Model for relapse (event type 1); treat type 2 as censored
y_relapse = Surv.from_arrays(event=(event_types == 1), time=times)
cox_relapse = CoxPHSurvivalAnalysis()
cox_relapse.fit(X, y_relapse)
# Model for death (event type 2); treat type 1 as censored
y_death = Surv.from_arrays(event=(event_types == 2), time=times)
cox_death = CoxPHSurvivalAnalysis()
cox_death.fit(X, y_death)
print(f"Relapse HR (age): {np.exp(cox_relapse.coef_[0]):.3f}")
print(f"Death HR (age): {np.exp(cox_death.coef_[0]):.3f}")
Key Concepts
Model Selection Guide
High-dimensional (p > n)?
├── Yes → CoxnetSurvivalAnalysis (elastic net)
└── No
├── Need interpretable coefficients?
│ ├── Yes → CoxPHSurvivalAnalysis or ComponentwiseGB
│ └── No
│ ├── Large data (n > 1000) → GradientBoostingSurvivalAnalysis
│ ├── Medium data → RandomSurvivalForest or FastKernelSurvivalSVM
│ └── Small data → RandomSurvivalForest
└── For max performance → compare multiple models
Censoring Types
scikit-survival primarily handles right-censoring (event not observed by end of study). Left-censoring and interval-censoring require other tools (e.g., lifelines). Censoring rate affects metric choice: >40% censoring favors Uno's C-index over Harrell's.
Concordance Index Interpretation
- 0.5 = random prediction (no discrimination)
- 0.6-0.7 = moderate discrimination
- 0.7-0.8 = good discrimination (typical for clinical models)
- 0.8+ = excellent (rare for clinical survival data)
- C-index measures ranking ability, not calibration. Always pair with Brier score.
Evaluation Metric Decision Table
| Metric | Measures | Best For |
|---|---|---|
| Harrell's C-index | Ranking only | Quick development, low censoring |
| Uno's C-index | Ranking (censoring-adjusted) | Publication, any censoring rate |
| Time-dependent AUC | Discrimination at fixed t | Clinical decision at specific horizons |
| Brier Score (single t) | Calibration at fixed t | Probability accuracy at timepoint |
| Integrated Brier Score | Overall calibration | Comprehensive model assessment |
Common Workflows
Workflow 1: Standard Survival Pipeline
Goal: End-to-end analysis with preprocessing, model fitting, and evaluation.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import GridSearchCV, train_test_split
from sksurv.datasets import load_breast_cancer
from sksurv.ensemble import GradientBoostingSurvivalAnalysis
from sksurv.metrics import (
concordance_index_ipcw, cumulative_dynamic_auc,
integrated_brier_score, as_concordance_index_ipcw_scorer,
)
import numpy as np
X, y = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Build pipeline
pipe = Pipeline([
("scaler", StandardScaler()),
("model", GradientBoostingSurvivalAnalysis(random_state=42)),
])
# Hyperparameter search
param_grid = {
"model__learning_rate": [0.01, 0.05, 0.1],
"model__n_estimators": [100, 200],
"model__max_depth": [3, 5],
}
cv = GridSearchCV(pipe, param_grid, cv=5,
scoring=as_concordance_index_ipcw_scorer(), n_jobs=-1)
cv.fit(X_train, y_train)
print(f"Best CV C-index: {cv.best_score_:.3f}, Params: {cv.best_params_}")
# Final evaluation on test set
best = cv.best_estimator_
risk = best.predict(X_test)
c_uno = concordance_index_ipcw(y_train, y_test, risk)[0]
times = np.percentile(y_test["time"][y_test["event"]], [25, 50, 75])
auc, mean_auc = cumulative_dynamic_auc(y_train, y_test, risk, times)
print(f"Test C-index: {c_uno:.3f}, Mean AUC: {mean_auc:.3f}")
Workflow 2: Model Comparison
Goal: Compare multiple model families and select the best.
from sksurv.linear_model import CoxPHSurvivalAnalysis
from sksurv.ensemble import RandomSurvivalForest, GradientBoostingSurvivalAnalysis
from sksurv.svm import FastSurvivalSVM
from sksurv.metrics import concordance_index_ipcw, integrated_brier_score
from sklearn.preprocessing import StandardScaler
import numpy as np
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
models = {
"CoxPH": CoxPHSurvivalAnalysis(),
"RSF": RandomSurvivalForest(n_estimators=200, random_state=42),
"GBS": GradientBoostingSurvivalAnalysis(n_estimators=200, random_state=42),
"LinearSVM": FastSurvivalSVM(alpha=1.0, random_state=42),
}
times = np.percentile(y_test["time"][y_test["event"]], [25, 50, 75])
for name, model in models.items():
X_tr = X_train_s if name in ("CoxPH", "LinearSVM") else X_train
X_te = X_test_s if name in ("CoxPH", "LinearSVM") else X_test
model.fit(X_tr, y_train)
risk = model.predict(X_te)
c = concordance_index_ipcw(y_train, y_test, risk)[0]
print(f"{name:12s}: C-index = {c:.3f}")
Workflow 3: High-Dimensional Feature Selection
- Fit CoxnetSurvivalAnalysis with l1_ratio=0.9 (Lasso-dominant)
- Use GridSearchCV over alpha_min_ratio values with C-index scorer
- Extract non-zero coefficients from best model
- Refit standard CoxPH or RSF on selected features for final model
- Evaluate on held-out test set with Uno's C-index
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
alpha |
CoxPH | 0.0 | >= 0 | Regularization (0 = none) |
l1_ratio |
CoxNet | 0.5 | 0-1 | L1 vs L2 penalty (1=Lasso, 0=Ridge) |
n_estimators |
RSF, GBS | 100 | 50-1000 | Number of trees/iterations |
max_depth |
RSF, GBS | None/3 | 1-20 | Tree depth (GBS: 3-5 recommended) |
learning_rate |
GBS | 0.1 | 0.001-0.3 | Step size shrinkage per iteration |
subsample |
GBS | 1.0 | 0.5-1.0 | Fraction of samples per tree |
dropout_rate |
GBS | 0.0 | 0-0.3 | Drop previous learners during training |
max_features |
RSF | None | "sqrt","log2",int | Features per split |
alpha |
FastSurvivalSVM | 1.0 | 0.01-100 | SVM regularization |
kernel |
FastKernelSVM | "rbf" | "linear","rbf","poly","sigmoid" | Kernel function |
gamma |
FastKernelSVM | "scale" | "scale","auto",float | Kernel coefficient |
Best Practices
-
Always standardize features for Cox and SVM models: Coefficients and margins are scale-dependent. Use
StandardScalerinside a Pipeline to prevent leakage. -
Use Uno's C-index over Harrell's: More robust to censoring distribution differences; required for comparing models across datasets. Pass
y_trainas the first argument. -
Anti-pattern -- using built-in RSF feature importance: It is biased toward continuous and high-cardinality features. Use
sklearn.inspection.permutation_importance()instead. -
Report multiple metrics: C-index alone measures only discrimination. Add Brier score for calibration and time-dependent AUC for time-specific performance.
-
Anti-pattern -- ignoring proportional hazards assumption: Cox models assume constant hazard ratios over time. Validate with Schoenfeld residuals (available in lifelines) or use non-parametric alternatives (RSF, GBS).
-
Ensure sufficient events per feature: Rule of thumb: 10+ events per feature for Cox. With fewer events, use CoxNet (l1_ratio=0.9) or ensemble methods.
-
Use scikit-learn Pipelines: Wrapping preprocessing + model prevents data leakage and simplifies cross-validation and deployment.
-
Anti-pattern -- treating competing events as censored for KM: Kaplan-Meier overestimates event probability when competing risks exist. Use
cumulative_incidence_competing_risks()instead. -
GBS early stopping: For GradientBoosting, set
n_estimators=1000withn_iter_no_change=10to automatically stop when validation performance plateaus.
Common Recipes
Recipe: Hyperparameter Tuning with Cross-Validation
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sksurv.ensemble import RandomSurvivalForest
from sksurv.metrics import as_concordance_index_ipcw_scorer
pipe = Pipeline([
("scaler", StandardScaler()),
("model", RandomSurvivalForest(random_state=42)),
])
param_grid = {
"model__n_estimators": [100, 300, 500],
"model__min_samples_split": [6, 10, 20],
"model__max_depth": [None, 10, 20],
}
cv = GridSearchCV(pipe, param_grid, cv=5,
scoring=as_concordance_index_ipcw_scorer(), n_jobs=-1)
cv.fit(X, y)
print(f"Best: {cv.best_score_:.3f}, Params: {cv.best_params_}")
Recipe: Permutation Feature Importance
from sklearn.inspection import permutation_importance
from sksurv.metrics import as_concordance_index_ipcw_scorer
result = permutation_importance(
rsf, X_test, y_test, n_repeats=15,
scoring=as_concordance_index_ipcw_scorer(), random_state=42, n_jobs=-1,
)
sorted_idx = result.importances_mean.argsort()[::-1]
for i in sorted_idx[:10]:
print(f"{X.columns[i]:20s}: {result.importances_mean[i]:.4f} "
f"+/- {result.importances_std[i]:.4f}")
Recipe: Kaplan-Meier with Confidence Bands
from sksurv.nonparametric import kaplan_meier_estimator
import matplotlib.pyplot as plt
time, surv, conf_int = kaplan_meier_estimator(
y["event"], y["time"], conf_type="log-log",
)
plt.step(time, surv, where="post", label="KM estimate")
plt.fill_between(time, conf_int[0], conf_int[1], alpha=0.25, step="post")
plt.xlabel("Time")
plt.ylabel("Survival probability")
plt.legend()
plt.savefig("km_with_ci.png", dpi=150)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: y must be a structured array |
Wrong outcome format | Use Surv.from_arrays(event, time) -- event must be bool, time must be float |
concordance_index_ipcw raises error |
Missing y_train argument |
IPCW requires training set for censoring distribution; pass y_train as first arg |
| Cox model won't converge | Correlated or unstandardized features | Standardize with StandardScaler; switch to CoxnetSurvivalAnalysis |
| Very low C-index (< 0.5) | Wrong sign or non-predictive features | Verify event/time coding; check feature engineering |
as_concordance_index_ipcw_scorer error in CV |
Time range mismatch between folds | Ensure all folds contain events at evaluated timepoints |
| SVM extremely slow | Large dataset with kernel SVM | Use FastSurvivalSVM (linear, O(np)) for large n; subsample for kernel |
| RSF feature importance misleading | Built-in impurity importance biased | Use permutation_importance() from scikit-learn |
| Kaplan-Meier overestimates | Competing events treated as censoring | Use cumulative_incidence_competing_risks() instead |
Bundled Resources
references/models_evaluation.md
Covers: Detailed Cox model family (CoxPH, CoxNet, IPCRidge with parameter guidance and interpretation), ensemble methods (RSF, GBS, ComponentwiseGB, ExtraSurvivalTrees with hyperparameter tuning, early stopping, loss functions), SVM models (FastSurvivalSVM, FastKernelSurvivalSVM, HingeLossSurvivalSVM, NaiveSurvivalSVM, MinlipSurvivalAnalysis with kernel selection), evaluation metrics (Harrell vs Uno C-index selection, time-dependent AUC plotting, Brier score with null model comparison, comprehensive evaluation pipeline), model comparison tables.
Relocated inline: Core usage patterns for all model families consolidated into Core API Modules 2-4 and Module 6. Model selection decision tree relocated to Key Concepts.
Omitted: CoxNet cross-validation alpha selection via scoring string (not a valid sklearn scorer usage -- use as_concordance_index_ipcw_scorer() as shown in Core API). NaiveSurvivalSVM detailed usage (slow, primarily for benchmarking). MinlipSurvivalAnalysis detailed usage (research-only). Redundant "when to use" prose duplicated across original reference files.
Consolidates 4 originals: cox-models.md (182 lines), ensemble-models.md (327 lines), svm-models.md (411 lines), evaluation-metrics.md (378 lines). Total: 1,298 lines.
references/data_competing_risks.md
Covers: Complete data preprocessing workflows (Surv arrays, loading custom CSV, ARFF I/O, categorical encoding methods, standardization, missing data imputation strategies, feature selection), data validation checks (negative times, censoring rate, events-per-feature), train-test splitting (random and stratified by event/time), complete preprocessing pipeline with sklearn, competing risks analysis (CIF estimation, data format with event types, stratified group comparison, cause-specific hazard modeling, complete practical example with simulated data).
Relocated inline: Core Surv array creation, encode_categorical, StandardScaler usage consolidated into Core API Module 1. CIF estimation and cause-specific Cox models relocated to Core API Module 7.
Omitted: Custom preprocessing function with docstring (duplicates sklearn Pipeline pattern shown in Workflow 1). Time-varying covariates note (scikit-survival does not support them -- mentioned only as a 1-line note in original). Fine-Gray sub-distribution model (not available in scikit-survival; noted as lifelines alternative).
Consolidates 2 originals: data-handling.md (494 lines), competing-risks.md (397 lines). Total: 891 lines.
Related Skills
- scikit-learn-machine-learning -- general ML with same Pipeline/GridSearchCV API; non-survival tasks
- statsmodels-statistical-modeling -- frequentist regression (OLS, GLM); non-censored outcomes
- pymc-bayesian-modeling -- Bayesian survival models with prior specification
- matplotlib-scientific-plotting -- customize Kaplan-Meier curves and survival plots
- statistical-analysis -- test selection framework and reporting guidelines
References
- scikit-survival documentation -- official API reference
- scikit-survival GitHub -- source code and examples
- API reference -- complete class/function list
- Polsterl (2020) "scikit-survival: A Library for Time-to-Event Analysis Built on Top of scikit-learn" -- JMLR 21(212):1-6
skills/biostatistics/statistical-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill statistical-analysis -g -y
SKILL.md
Frontmatter
{
"name": "statistical-analysis",
"license": "CC-BY-4.0",
"description": "Guided statistical analysis: test choice, assumption checks, effect sizes, power, APA reporting. Pick tests, verify assumptions, or format results for publication. Covers frequentist (t-test, ANOVA, chi-square, regression, correlation, survival, count, reliability) and Bayesian. Use statsmodels or pymc-bayesian-modeling to fit."
}
Statistical Analysis
Overview
Statistical analysis is the systematic process of selecting appropriate tests, verifying assumptions, quantifying effect magnitudes, and reporting results. This knowhow guides test selection, assumption diagnostics, and APA-style reporting for frequentist and Bayesian analyses in academic research.
Key Concepts
Frequentist vs Bayesian Framework
| Aspect | Frequentist | Bayesian |
|---|---|---|
| Core output | p-value, confidence interval | Posterior distribution, credible interval |
| Interpretation | "How likely is this data if H0 is true?" | "How likely is H1 given the data?" |
| Null support | Cannot support H0 (only fail to reject) | Can quantify evidence for H0 via Bayes Factor |
| Prior info | Not used | Incorporated via prior distributions |
| Sample size | Requires adequate power | Works with any sample size |
| Best for | Standard analyses, large samples | Small samples, prior info, complex models |
Statistical vs Practical Significance
A statistically significant result (p < .05) may be trivially small in practice. Always report:
- Effect size: Magnitude of the effect (Cohen's d, eta-squared, r, R-squared)
- Confidence interval: Precision of the estimate
- Context: Clinical/practical relevance in the domain
Common Effect Sizes
| Test | Effect Size | Small | Medium | Large |
|---|---|---|---|---|
| t-test | Cohen's d | 0.20 | 0.50 | 0.80 |
| t-test (small n) | Hedges' g | 0.20 | 0.50 | 0.80 |
| ANOVA | eta-squared partial | 0.01 | 0.06 | 0.14 |
| ANOVA | omega-squared | 0.01 | 0.06 | 0.14 |
| Correlation | r | 0.10 | 0.30 | 0.50 |
| Regression | R-squared | 0.02 | 0.13 | 0.26 |
| Regression | f-squared | 0.02 | 0.15 | 0.35 |
| Chi-square | Cramer's V | 0.07 | 0.21 | 0.35 |
| Chi-square 2x2 | phi coefficient | 0.10 | 0.30 | 0.50 |
Cohen's benchmarks are guidelines, not rigid thresholds -- domain context always matters.
Assumptions Overview
Most parametric tests require:
- Independence: Observations are independent of each other
- Normality: Data (or residuals) are approximately normally distributed
- Homogeneity of variance: Groups have similar variances (for group comparisons)
- Linearity: Relationship between variables is linear (for regression)
When assumptions are violated:
- Normality violated, n > 30: Proceed -- parametric tests are robust with large samples
- Normality violated, n < 30: Use non-parametric alternative
- Variance heterogeneity: Use Welch's correction (t-test) or Welch's ANOVA
- Linearity violated: Add polynomial terms, transform variables, or use GAMs
Test-Specific Assumption Workflows
T-test assumptions: (1) Check normality per group with Shapiro-Wilk + Q-Q plots. (2) Check homogeneity with Levene's test. (3) If normality violated: Mann-Whitney U (independent) or Wilcoxon signed-rank (paired). If variance heterogeneity: use Welch's t-test.
ANOVA assumptions: (1) Normality per group. (2) Homogeneity via Levene's test. (3) For repeated measures: check sphericity (Mauchly's test); if violated, apply Greenhouse-Geisser (epsilon < 0.75) or Huynh-Feldt (epsilon > 0.75) correction. (4) If normality violated: Kruskal-Wallis (independent) or Friedman (repeated).
Linear regression assumptions: (1) Linearity via residuals-vs-fitted plot. (2) Independence via Durbin-Watson test (1.5-2.5 acceptable). (3) Homoscedasticity via Breusch-Pagan test + scale-location plot. (4) Normality of residuals via Q-Q plot + Shapiro-Wilk. (5) Multicollinearity via VIF (>10 = severe, >5 = moderate).
Logistic regression assumptions: (1) Independence. (2) Linearity of log-odds with continuous predictors (Box-Tidwell test). (3) No perfect multicollinearity (VIF). (4) Adequate sample size (10-20 events per predictor minimum).
Specialized Test Categories
Beyond the main decision flowchart, several specialized test families address specific data types:
Survival / time-to-event analysis:
- Log-rank test: Compares survival curves between groups (non-parametric)
- Cox proportional hazards: Models time-to-event with covariates; assumes proportional hazards
- Parametric survival models: Weibull, exponential, log-normal for known distributional forms
- Use when outcome is time until an event (death, relapse, failure) with possible censoring
Count outcome models:
- Poisson regression: For count data where mean approximately equals variance
- Negative binomial regression: For overdispersed counts (variance > mean)
- Zero-inflated models: For excess zeros beyond what Poisson/NB predicts
- Use when outcome is a count (number of events, incidents, occurrences)
Agreement and reliability:
- Cohen's kappa: Inter-rater agreement for categorical ratings (2 raters)
- Fleiss' kappa / Krippendorff's alpha: Agreement for >2 raters
- Intraclass correlation coefficient (ICC): Continuous ratings reliability
- Cronbach's alpha: Internal consistency of multi-item scales
- Bland-Altman analysis: Agreement between two measurement methods (continuous)
- Use when assessing measurement reliability or inter-rater consistency
Categorical data extensions:
- McNemar's test: Paired binary outcomes (2x2)
- Cochran's Q test: Paired binary outcomes (3+ conditions)
- Cochran-Armitage trend test: Ordered categories in contingency tables
Decision Framework
Test Selection Flowchart
What is your research question?
|
+-- Comparing GROUPS on a continuous outcome?
| |
| +-- How many groups?
| | +-- 2 groups
| | | +-- Independent -> Independent t-test (or Mann-Whitney U)
| | | +-- Paired/repeated -> Paired t-test (or Wilcoxon signed-rank)
| | +-- 3+ groups
| | +-- Independent -> One-way ANOVA (or Kruskal-Wallis)
| | +-- Repeated -> Repeated-measures ANOVA (or Friedman)
| |
| +-- Multiple factors? -> Factorial ANOVA / Mixed ANOVA
| +-- With covariates? -> ANCOVA
|
+-- Testing a RELATIONSHIP between variables?
| |
| +-- Both continuous?
| | +-- Normal -> Pearson correlation
| | +-- Non-normal or ordinal -> Spearman correlation
| |
| +-- Predicting continuous outcome?
| | +-- 1 predictor -> Simple linear regression
| | +-- Multiple predictors -> Multiple linear regression
| |
| +-- Predicting categorical outcome?
| | +-- Binary -> Logistic regression
| | +-- Ordinal -> Ordinal logistic regression
| |
| +-- Predicting count outcome?
| | +-- Equidispersed -> Poisson regression
| | +-- Overdispersed -> Negative binomial regression
| | +-- Excess zeros -> Zero-inflated Poisson/NB
| |
| +-- Time-to-event outcome?
| +-- Compare survival curves -> Log-rank test
| +-- With covariates -> Cox proportional hazards
|
+-- Testing ASSOCIATION between categorical variables?
| +-- Expected cell count >= 5 -> Chi-square test
| +-- Expected cell count < 5 -> Fisher's exact test
| +-- Ordered categories -> Cochran-Armitage trend test
| +-- Paired categories -> McNemar's test
|
+-- Assessing AGREEMENT / RELIABILITY?
+-- Categorical, 2 raters -> Cohen's kappa
+-- Categorical, >2 raters -> Fleiss' kappa
+-- Continuous ratings -> ICC
+-- Two measurement methods -> Bland-Altman analysis
+-- Internal consistency -> Cronbach's alpha
Quick Reference Table
| Research Question | Data Type | Normal? | Test | Non-parametric Alternative |
|---|---|---|---|---|
| 2 independent groups | Continuous | Yes | Independent t-test | Mann-Whitney U |
| 2 paired groups | Continuous | Yes | Paired t-test | Wilcoxon signed-rank |
| 3+ independent groups | Continuous | Yes | One-way ANOVA | Kruskal-Wallis |
| 3+ repeated groups | Continuous | Yes | Repeated-measures ANOVA | Friedman test |
| 2 variables | Continuous | Yes | Pearson r | Spearman rho |
| Predict continuous | Mixed | -- | Linear regression | -- |
| Predict binary | Mixed | -- | Logistic regression | -- |
| Predict counts | Count | -- | Poisson / Negative binomial | -- |
| Time-to-event | Survival | -- | Cox PH / Log-rank | -- |
| 2 categorical | Categorical | -- | Chi-square / Fisher's exact | -- |
| Rater agreement | Categorical | -- | Cohen's kappa / Fleiss' kappa | -- |
| Method agreement | Continuous | -- | Bland-Altman / ICC | -- |
Best Practices
- Pre-register analyses when possible to distinguish confirmatory from exploratory findings. Specify primary outcome, tests, and correction methods before data collection
- Always check assumptions before interpreting results. Run normality tests (Shapiro-Wilk), homogeneity tests (Levene's), and residual diagnostics. Document results even when assumptions are met
- Report effect sizes with confidence intervals for every test. p-values alone are insufficient -- effect sizes convey practical importance
- Report all planned analyses including non-significant findings. Selective reporting inflates false positive rates
- Use appropriate multiple comparison corrections. Bonferroni (conservative), Holm (step-down, less conservative), or FDR/Benjamini-Hochberg (for many tests). Choose based on the number of comparisons and acceptable error rate
- Visualize data before and after analysis. Box plots for group comparisons, scatter plots for correlations, residual plots for regression diagnostics
- Conduct sensitivity analyses to assess robustness: re-run with outliers removed, different transformations, or alternative tests
- Anti-pattern -- p-hacking: Testing multiple outcomes, subgroups, or model specifications until p < .05 inflates false positives. Pre-register to avoid
- Anti-pattern -- HARKing (Hypothesizing After Results are Known): Presenting exploratory findings as confirmatory undermines scientific integrity
- Anti-pattern -- misinterpreting non-significance: Failure to reject H0 does not mean H0 is true. Use Bayesian methods or equivalence testing to support null
Common Pitfalls
-
Misinterpreting p-values as probability of the hypothesis being true. p-values measure P(data | H0), not P(H0 | data). How to avoid: Use precise language: "If the null hypothesis were true, the probability of observing data this extreme is p = ..."
-
Confusing statistical significance with practical importance. A large sample can make trivially small effects significant. How to avoid: Always report and interpret effect sizes alongside p-values
-
Running post-hoc power analysis after a non-significant result. Post-hoc power is a mathematical function of the p-value and adds no new information. How to avoid: Use sensitivity analysis instead -- determine what effect size the study could detect at 80% power
-
Ignoring assumption violations and proceeding with parametric tests. How to avoid: Run assumption checks systematically. Use Welch's corrections, non-parametric alternatives, or transformations when violated
-
Multiple comparisons without correction. Running 20 tests at alpha = .05 gives ~64% chance of at least one false positive. How to avoid: Apply Bonferroni, Holm, or FDR correction. Report both corrected and uncorrected p-values
-
Treating ordinal data as continuous. Likert scales are ordinal -- means and standard deviations assume equal intervals. How to avoid: Use non-parametric tests (Mann-Whitney, Kruskal-Wallis) or ordinal regression
-
Ignoring missing data patterns. Listwise deletion assumes MCAR, which is rarely true. How to avoid: Assess missingness mechanism (MCAR, MAR, MNAR). Use multiple imputation for MAR data
-
Confusing correlation with causation. Observational studies cannot establish causal relationships regardless of effect size. How to avoid: Use causal language only for experimental designs with random assignment
-
Not reporting non-significant results. Publication bias and file-drawer effect distort the literature. How to avoid: Report all pre-registered analyses. Consider registered reports
-
Using one-tailed tests to "improve" significance. One-tailed tests should be pre-specified based on strong directional hypotheses. How to avoid: Default to two-tailed. Only use one-tailed when justified a priori
Workflow
Standard Analysis Pipeline
-
Define research question and hypotheses
- State H0 and H1 explicitly
- Specify primary outcome and covariates
-
Select statistical test (use Decision Framework above)
- Match test to data type, design, and assumptions
- Plan multiple comparison corrections if needed
-
Conduct a priori power analysis
- Specify target effect size (from literature or clinical relevance)
- Set alpha = .05, power = .80 (minimum), determine required n
- Libraries:
statsmodels.stats.power,pingouin
-
Inspect and clean data
- Check for missing data patterns (MCAR/MAR/MNAR)
- Identify outliers (IQR method: Q1 - 1.5IQR / Q3 + 1.5IQR, or z-scores > 3)
- Verify variable types and coding
- The original
assumption_checks.pyscript provides automated normality, homogeneity, and outlier detection with visualization
-
Check assumptions (see Test-Specific Assumption Workflows above)
- Normality: Shapiro-Wilk test + Q-Q plots (visual primary for n > 50)
- Homogeneity: Levene's test + box plots
- Linearity: Residual plots (for regression)
- Sphericity: Mauchly's test (for repeated measures)
- Document results and remedial actions
-
Run primary analysis
- Execute planned test with appropriate library (scipy.stats, pingouin, statsmodels)
- Calculate effect size and confidence interval
- For Bayesian analyses: specify priors, run MCMC, check convergence (see
references/bayesian_statistics.md)
-
Conduct post-hoc and secondary analyses
- Post-hoc pairwise comparisons (Tukey HSD, Bonferroni)
- Sensitivity analyses (remove outliers, alternative methods)
- Exploratory analyses (clearly labeled)
-
Report results in APA format
- Descriptive statistics (M, SD, n per group)
- Test statistic, degrees of freedom, exact p-value
- Effect size with confidence interval
- See
references/reporting_standards.mdfor templates
Bundled Resources
-
references/effect_sizes_and_power.md-- Detailed guide to calculating, interpreting, and reporting effect sizes (Cohen's d, Hedges' g, Glass's delta, eta-squared, omega-squared, partial eta-squared, phi coefficient, standardized beta, f-squared, Cramer's V, odds ratio); a priori, sensitivity, and correlation power analysis with code examples. Condensed from 582-line original. -
references/bayesian_statistics.md-- Comprehensive Bayesian analysis guide: Bayes' theorem, prior specification, ROPE (Region of Practical Equivalence), prior sensitivity analysis, Bayesian t-test/ANOVA/correlation/regression, hierarchical models, model comparison (WAIC/LOO), convergence diagnostics. Condensed from 662-line original. -
references/reporting_standards.md-- APA-style reporting templates for t-tests, ANOVA, regression, correlation, chi-square, non-parametric, and Bayesian analyses; pre-registration guidance; methods section templates (participants, design, measures); null results reporting; reporting checklist. Condensed from 470-line original.
Fully-Consolidated Files (no separate reference file)
-
test_selection_guide.md(130 lines original) -- Fully consolidated into Decision Framework (flowchart + Quick Reference Table) and Specialized Test Categories subsection in Key Concepts. Combined coverage: flowchart (~35 lines) + Quick Reference Table (~15 lines) + Specialized Test Categories (~35 lines) = ~85 lines covering all original capabilities. Original content on sample size considerations, multiple comparisons, and missing data was consolidated into Best Practices and Common Pitfalls. Omitted: study design considerations (RCTs, observational, clustered data) -- general guidance covered by statsmodels-statistical-modeling skill. -
assumptions_and_diagnostics.md(370 lines original) -- Fully consolidated into Key Concepts (Assumptions Overview + Test-Specific Assumption Workflows) and Workflow Steps 4-5. Combined coverage: Assumptions Overview (~12 lines) + Test-Specific Assumption Workflows (~20 lines) + Workflow Steps 4-5 (~16 lines) = ~48 lines. The original contained detailed code blocks for each assumption check; since this is Knowhow (not Skill), code is referenced rather than reproduced. Key diagnostic thresholds preserved (VIF > 10, Durbin-Watson 1.5-2.5, variance ratio < 2-3). Omitted: extensive Python code blocks for individual checks (normality, homogeneity, linearity, logistic regression diagnostics) -- available in scipy.stats and pingouin documentation. Sample size rules of thumb covered in Workflow Step 3.
Script Disposition
assumption_checks.py(540 lines) -- Contains 6 functions:check_normality(),check_normality_per_group(),check_homogeneity_of_variance(),check_linearity(),detect_outliers(),comprehensive_assumption_check(). As Knowhow entry, script functions are referenced in Workflow Step 4 rather than reproduced inline. Key capabilities (Shapiro-Wilk, Levene's, IQR/z-score outlier detection, Q-Q plots) are described in Assumptions Overview and Test-Specific Assumption Workflows. Users needing automated checking should use scipy.stats and pingouin directly following the patterns described.
Intentional Omissions
- Time series methods (ARIMA, ACF/PACF) -- specialized topic beyond core statistical testing scope
- Mixed-effects models / GEE -- covered by statsmodels-statistical-modeling skill
- Bootstrap and permutation tests -- mentioned in passing; detailed implementation deferred to computational statistics resources
Further Reading
- Cohen, J. (1988). Statistical Power Analysis for the Behavioral Sciences (2nd ed.)
- Field, A. (2013). Discovering Statistics Using IBM SPSS Statistics (4th ed.)
- Gelman, A., & Hill, J. (2006). Data Analysis Using Regression and Multilevel/Hierarchical Models
- Kruschke, J. K. (2014). Doing Bayesian Data Analysis (2nd ed.)
- APA Publication Manual: https://apastyle.apa.org/
- Cross Validated (stats Q&A): https://stats.stackexchange.com/
Related Skills
- statsmodels-statistical-modeling -- Implementing OLS, GLM, Logit, time-series models programmatically
- pymc-bayesian-modeling -- Full Bayesian modeling with MCMC sampling
- scikit-learn-machine-learning -- Predictive modeling, cross-validation, classification
- matplotlib-scientific-plotting -- Creating publication-quality statistical figures
- hypothesis-generation -- Structured hypothesis formulation before statistical testing
skills/biostatistics/statsmodels-statistical-modeling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill statsmodels-statistical-modeling -g -y
SKILL.md
Frontmatter
{
"name": "statsmodels-statistical-modeling",
"license": "BSD-3-Clause",
"description": "Python statistical modeling: regression (OLS, WLS, GLM), discrete (Logit, Poisson, NegBin), time series (ARIMA, SARIMAX, VAR), with rigorous inference, diagnostics, and hypothesis tests. Use scikit-learn for ML; statistical-analysis for test choice."
}
statsmodels
Overview
Statsmodels provides classical statistical modeling with rigorous inference for Python. It covers linear models, generalized linear models, discrete choice, time series, and comprehensive diagnostics. Unlike scikit-learn (prediction-focused), statsmodels emphasizes coefficient interpretation, p-values, confidence intervals, and model diagnostics.
When to Use
- Fitting linear regression (OLS, WLS, GLS) with detailed coefficient tables and diagnostics
- Running logistic regression with odds ratios and marginal effects for clinical/epidemiological studies
- Analyzing count data with Poisson or negative binomial regression
- Time series forecasting with ARIMA, SARIMAX, or exponential smoothing
- Performing ANOVA, t-tests, or non-parametric tests with proper corrections
- Testing model assumptions (heteroskedasticity, autocorrelation, normality of residuals)
- Model comparison using AIC/BIC or likelihood ratio tests
- Using R-style formula interface (
y ~ x1 + x2 + C(group)) for intuitive model specification - For prediction-focused ML with cross-validation and hyperparameter tuning, use
scikit-learninstead - For Bayesian modeling with posterior inference, use
pymcinstead
Prerequisites
- Python packages:
statsmodels,numpy,pandas,scipy - Optional:
matplotlib(for diagnostic plots),patsy(for formula API, included with statsmodels) - Data: Tabular data as pandas DataFrames or NumPy arrays
pip install statsmodels numpy pandas matplotlib
Quick Start
import statsmodels.api as sm
import statsmodels.formula.api as smf
import pandas as pd
import numpy as np
# Generate sample data
np.random.seed(42)
n = 100
df = pd.DataFrame({
"x1": np.random.randn(n),
"x2": np.random.randn(n),
"group": np.random.choice(["A", "B"], n)
})
df["y"] = 2 + 3 * df["x1"] - 1.5 * df["x2"] + np.random.randn(n)
# OLS with formula API (R-style)
results = smf.ols("y ~ x1 + x2 + C(group)", data=df).fit()
print(results.summary())
print(f"R²: {results.rsquared:.3f}, AIC: {results.aic:.1f}")
Core API
Module 1: Linear Regression (OLS, WLS, GLS)
Standard linear models with comprehensive diagnostics.
import statsmodels.api as sm
import numpy as np
# Generate data
np.random.seed(42)
X = np.random.randn(200, 3)
y = 1 + 2*X[:, 0] - 0.5*X[:, 1] + np.random.randn(200)
# ALWAYS add constant for intercept
X_const = sm.add_constant(X)
results = sm.OLS(y, X_const).fit()
print(results.summary())
print(f"\nCoefficients: {results.params}")
print(f"P-values: {results.pvalues}")
print(f"R²: {results.rsquared:.4f}")
# Predictions with confidence intervals
pred = results.get_prediction(X_const[:5])
print(pred.summary_frame())
# Robust standard errors (heteroskedasticity-consistent)
results_robust = sm.OLS(y, X_const).fit(cov_type="HC3")
print("Robust SEs:", results_robust.bse)
# Weighted Least Squares
weights = 1 / np.abs(results.resid + 0.1) # Example weights
results_wls = sm.WLS(y, X_const, weights=weights).fit()
print(f"WLS R²: {results_wls.rsquared:.4f}")
Module 2: Generalized Linear Models (GLM)
Extend regression to non-normal outcomes (binary, count, continuous-positive).
import statsmodels.api as sm
import numpy as np
# Poisson regression for count data
np.random.seed(42)
X = np.random.randn(200, 2)
X_const = sm.add_constant(X)
y_counts = np.random.poisson(np.exp(0.5 + 0.3*X[:, 0]))
model = sm.GLM(y_counts, X_const, family=sm.families.Poisson())
results = model.fit()
print(results.summary())
# Rate ratios
rate_ratios = np.exp(results.params)
print(f"Rate ratios: {rate_ratios}")
# Check overdispersion
overdispersion = results.pearson_chi2 / results.df_resid
print(f"Overdispersion ratio: {overdispersion:.2f}")
if overdispersion > 1.5:
print("→ Consider Negative Binomial model")
Module 3: Discrete Choice Models (Logit, Probit, Count)
Binary, multinomial, and count outcome models.
import statsmodels.api as sm
import numpy as np
# Logistic regression
np.random.seed(42)
X = np.random.randn(300, 2)
X_const = sm.add_constant(X)
prob = 1 / (1 + np.exp(-(0.5 + X[:, 0] - 0.5*X[:, 1])))
y_binary = np.random.binomial(1, prob)
logit_results = sm.Logit(y_binary, X_const).fit()
print(logit_results.summary())
# Odds ratios
odds_ratios = np.exp(logit_results.params)
print(f"Odds ratios: {odds_ratios}")
# Marginal effects (at means)
margeff = logit_results.get_margeff()
print(margeff.summary())
# Predicted probabilities
probs = logit_results.predict(X_const[:5])
print(f"Predicted P(Y=1): {probs}")
Module 4: Time Series (ARIMA, SARIMAX)
Univariate and multivariate time series modeling and forecasting.
import statsmodels.api as sm
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import adfuller
import numpy as np
import pandas as pd
# Generate time series
np.random.seed(42)
dates = pd.date_range("2020-01-01", periods=200, freq="D")
y = np.cumsum(np.random.randn(200)) + 50
ts = pd.Series(y, index=dates)
# Stationarity test
adf_result = adfuller(ts)
print(f"ADF statistic: {adf_result[0]:.4f}, p-value: {adf_result[1]:.4f}")
print("Stationary" if adf_result[1] < 0.05 else "Non-stationary → difference")
# Fit ARIMA
model = ARIMA(ts, order=(1, 1, 1))
results = model.fit()
print(results.summary())
# Forecast with confidence intervals
forecast = results.get_forecast(steps=30)
forecast_df = forecast.summary_frame()
print(f"30-day forecast:\n{forecast_df.head()}")
# Seasonal ARIMA (SARIMAX)
from statsmodels.tsa.statespace.sarimax import SARIMAX
# Monthly data with yearly seasonality
model_sarima = SARIMAX(ts, order=(1, 1, 1), seasonal_order=(1, 1, 1, 12))
results_sarima = model_sarima.fit(disp=False)
print(f"AIC: {results_sarima.aic:.1f}")
# Diagnostic plots
results_sarima.plot_diagnostics(figsize=(12, 8))
Module 5: Statistical Tests and Diagnostics
Assumption tests, hypothesis tests, and model validation.
import statsmodels.api as sm
from statsmodels.stats.diagnostic import het_breuschpagan, acorr_ljungbox
from statsmodels.stats.stattools import jarque_bera
import numpy as np
# Fit a model first
np.random.seed(42)
X = sm.add_constant(np.random.randn(200, 2))
y = 1 + 2*X[:, 1] + np.random.randn(200) * X[:, 1] # Heteroskedastic
results = sm.OLS(y, X).fit()
# Heteroskedasticity test (Breusch-Pagan)
bp_stat, bp_p, _, _ = het_breuschpagan(results.resid, X)
print(f"Breusch-Pagan p-value: {bp_p:.4f} {'→ heteroskedastic' if bp_p < 0.05 else '→ OK'}")
# Normality test (Jarque-Bera)
jb_stat, jb_p, _, _ = jarque_bera(results.resid)
print(f"Jarque-Bera p-value: {jb_p:.4f} {'→ non-normal' if jb_p < 0.05 else '→ OK'}")
# Autocorrelation test (Ljung-Box)
lb_result = acorr_ljungbox(results.resid, lags=[10], return_df=True)
print(f"Ljung-Box p-value (lag 10): {lb_result['lb_pvalue'].values[0]:.4f}")
# Variance Inflation Factor (multicollinearity)
from statsmodels.stats.outliers_influence import variance_inflation_factor
vif_data = pd.DataFrame({
"Variable": [f"x{i}" for i in range(X.shape[1])],
"VIF": [variance_inflation_factor(X, i) for i in range(X.shape[1])]
})
print(vif_data) # VIF > 10 suggests multicollinearity
Module 6: Formula API (R-style)
Intuitive model specification using formulas with automatic dummy coding.
import statsmodels.formula.api as smf
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({
"y": np.random.randn(100),
"x1": np.random.randn(100),
"x2": np.random.randn(100),
"group": np.random.choice(["A", "B", "C"], 100),
})
# Formula with categoricals (auto dummy-coded)
res = smf.ols("y ~ x1 + x2 + C(group)", data=df).fit()
print(res.summary())
# Interactions
res2 = smf.ols("y ~ x1 * x2", data=df).fit() # x1 + x2 + x1:x2
print(f"Interaction term p-value: {res2.pvalues['x1:x2']:.4f}")
# Logit via formula
df["binary"] = (df["y"] > 0).astype(int)
logit_res = smf.logit("binary ~ x1 + x2 + C(group)", data=df).fit()
print(f"Logit AIC: {logit_res.aic:.1f}")
Common Workflows
Workflow 1: Complete Regression Analysis
Goal: Fit OLS, validate assumptions, use robust SEs if needed.
import statsmodels.api as sm
import statsmodels.formula.api as smf
from statsmodels.stats.diagnostic import het_breuschpagan
from statsmodels.stats.outliers_influence import variance_inflation_factor
import numpy as np
import pandas as pd
# 1. Fit initial model
np.random.seed(42)
df = pd.DataFrame({"y": np.random.randn(200), "x1": np.random.randn(200), "x2": np.random.randn(200)})
df["y"] = 2 + 3*df["x1"] - df["x2"] + np.random.randn(200)
results = smf.ols("y ~ x1 + x2", data=df).fit()
# 2. Check heteroskedasticity
bp_stat, bp_p, _, _ = het_breuschpagan(results.resid, results.model.exog)
print(f"Breusch-Pagan p: {bp_p:.4f}")
# 3. If heteroskedastic, use robust SEs
if bp_p < 0.05:
results = smf.ols("y ~ x1 + x2", data=df).fit(cov_type="HC3")
print("Using HC3 robust standard errors")
# 4. Check multicollinearity
X = results.model.exog
for i in range(1, X.shape[1]): # skip constant
print(f"VIF x{i}: {variance_inflation_factor(X, i):.2f}")
# 5. Final results
print(results.summary())
print(f"\nAIC: {results.aic:.1f}, BIC: {results.bic:.1f}")
Workflow 2: Model Comparison
Goal: Compare nested and non-nested models using appropriate criteria.
import statsmodels.formula.api as smf
from scipy import stats
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({"y": np.random.randn(200), "x1": np.random.randn(200),
"x2": np.random.randn(200), "x3": np.random.randn(200)})
df["y"] = 1 + 2*df["x1"] - df["x2"] + 0.1*df["x3"] + np.random.randn(200)
# Fit nested models
m1 = smf.ols("y ~ x1", data=df).fit()
m2 = smf.ols("y ~ x1 + x2", data=df).fit()
m3 = smf.ols("y ~ x1 + x2 + x3", data=df).fit()
# Compare via AIC/BIC (lower = better)
comparison = pd.DataFrame({
"R²": [m.rsquared for m in [m1, m2, m3]],
"AIC": [m.aic for m in [m1, m2, m3]],
"BIC": [m.bic for m in [m1, m2, m3]],
}, index=["y~x1", "y~x1+x2", "y~x1+x2+x3"])
print(comparison)
# Likelihood ratio test (nested: m2 vs m3)
lr_stat = 2 * (m3.llf - m2.llf)
p_val = 1 - stats.chi2.cdf(lr_stat, df=m3.df_model - m2.df_model)
print(f"\nLR test (m3 vs m2): stat={lr_stat:.2f}, p={p_val:.4f}")
Workflow 3: Time Series Forecasting Pipeline
Goal: Test stationarity, identify model order, forecast.
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import adfuller
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Generate data
np.random.seed(42)
ts = pd.Series(np.cumsum(np.random.randn(200)) + 100,
index=pd.date_range("2020-01-01", periods=200, freq="D"))
# 1. Test stationarity
adf_p = adfuller(ts)[1]
print(f"ADF p-value: {adf_p:.4f} → {'stationary' if adf_p < 0.05 else 'non-stationary'}")
# 2. Identify order from ACF/PACF (on differenced series)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6))
plot_acf(ts.diff().dropna(), lags=20, ax=ax1)
plot_pacf(ts.diff().dropna(), lags=20, ax=ax2)
plt.savefig("acf_pacf.png", dpi=150, bbox_inches="tight")
# 3. Fit and forecast
model = ARIMA(ts[:180], order=(1, 1, 1))
results = model.fit()
forecast = results.get_forecast(steps=20)
fc_df = forecast.summary_frame()
print(f"ARIMA AIC: {results.aic:.1f}")
print(f"Forecast (first 5 days):\n{fc_df.head()}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
cov_type |
OLS/WLS/GLM | "nonrobust" |
"HC0"-"HC3", "HAC", "cluster" |
Robust covariance estimator |
family |
GLM | required | Poisson(), Binomial(), Gamma(), etc. |
Distribution family |
order |
ARIMA | required | (p, d, q) tuple |
AR order, differencing, MA order |
seasonal_order |
SARIMAX | (0,0,0,0) |
(P, D, Q, s) tuple |
Seasonal ARIMA parameters |
alpha |
summary(), conf_int() |
0.05 |
0.01-0.10 |
Significance level for CIs |
maxiter |
All .fit() |
35-100 |
50-1000 |
Max optimization iterations |
method |
.fit() |
model-dependent | "newton", "bfgs", "lbfgs", "powell" |
Optimization algorithm |
lags |
ACF/PACF | None |
10-50 |
Number of lags to display |
Best Practices
-
Always add a constant for OLS/GLM:
sm.add_constant(X)or use formula API (adds intercept automatically) -
Match model to outcome type: Binary → Logit/Probit, Counts → Poisson/NegBin, Continuous → OLS/WLS, Time series → ARIMA
-
Check diagnostics before interpreting: Run Breusch-Pagan (heteroskedasticity), Jarque-Bera (normality), Ljung-Box (autocorrelation) on residuals
-
Use robust SEs when assumptions fail:
results = model.fit(cov_type="HC3")for heteroskedasticity-robust inference -
Report effect sizes, not just p-values: Include coefficients, confidence intervals, and R² alongside significance tests
-
Prefer formula API for exploratory work:
smf.ols("y ~ x1 * x2 + C(group)", data=df)is more readable and handles categoricals automatically -
Test stationarity before time series modeling: Use ADF test; difference if non-stationary
Common Recipes
Recipe: ANOVA with Post-hoc Tests
When to use: Comparing means across 3+ groups.
import statsmodels.formula.api as smf
from statsmodels.stats.multicomp import pairwise_tukeyhsd
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({"value": np.concatenate([np.random.normal(m, 1, 30) for m in [5, 6, 7]]),
"group": np.repeat(["A", "B", "C"], 30)})
# One-way ANOVA
anova = smf.ols("value ~ C(group)", data=df).fit()
print(sm.stats.anova_lm(anova))
# Post-hoc Tukey HSD
tukey = pairwise_tukeyhsd(df["value"], df["group"], alpha=0.05)
print(tukey)
Recipe: Power Analysis for Sample Size
When to use: Determining required sample size before a study.
from statsmodels.stats.power import TTestIndPower
analysis = TTestIndPower()
# What sample size for medium effect (d=0.5), 80% power, alpha=0.05?
n = analysis.solve_power(effect_size=0.5, alpha=0.05, power=0.8)
print(f"Required n per group: {n:.0f}")
# Power for given sample size
power = analysis.solve_power(effect_size=0.5, alpha=0.05, nobs1=50)
print(f"Power with n=50: {power:.3f}")
Recipe: Mixed Effects Model
When to use: Hierarchical/clustered data (patients within hospitals, students within schools).
import statsmodels.formula.api as smf
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({
"y": np.random.randn(100), "x": np.random.randn(100),
"group": np.repeat(range(10), 10)
})
# Random intercept model
model = smf.mixedlm("y ~ x", data=df, groups=df["group"])
results = model.fit()
print(results.summary())
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
MissingDataError |
NaN values in data | Drop NAs: df.dropna() or impute before fitting |
| No intercept in results | Forgot sm.add_constant() |
Always add constant, or use smf.ols() formula API |
ConvergenceWarning |
Optimization failed | Increase maxiter, try different method, or scale variables |
| Overdispersion in Poisson | Variance > mean | Switch to NegativeBinomial or use GLM(family=NegativeBinomial()) |
| Non-stationary time series | Trend or unit root | Difference the series (ts.diff()) or increase d in ARIMA |
| Singular matrix error | Perfect multicollinearity | Remove redundant variables; check VIF > 10 |
| Different results from R | Default settings differ | Check: constant term, link function, optimizer, SE type |
PerfectSeparationError in Logit |
Predictor perfectly separates classes | Use regularized logistic (penalized MLE) or Firth's method |
References
- statsmodels documentation — official docs
- statsmodels User Guide — tutorials
- statsmodels API Reference — complete API
- Seabold, S. & Perktold, J. (2010). Statsmodels: Econometric and Statistical Modeling with Python. Proceedings of the 9th Python in Science Conference.
skills/cell-biology/cellpose-cell-segmentation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cellpose-cell-segmentation -g -y
SKILL.md
Frontmatter
{
"name": "cellpose-cell-segmentation",
"license": "BSD-3-Clause",
"description": "DL cell\/nucleus segmentation for fluorescence and brightfield microscopy. Pre-trained models (cyto3, nuclei, tissuenet) and a generalist flow-based algorithm segment cells without retraining. Outputs label masks for morphology and tracking. Use scikit-image watershed for rule-based; Cellpose when DL generalization across staining is needed."
}
Cellpose — Deep Learning Cell Segmentation
Overview
Cellpose uses a flow-based neural network to segment individual cells or nuclei in fluorescence microscopy images without manual parameter tuning. Pre-trained models (cyto3, nuclei, tissuenet) generalize across cell types, magnifications, and staining conditions — eliminating the need for manual threshold selection or watershed parameter optimization. Cellpose outputs integer label masks (each cell = unique integer) compatible with scikit-image regionprops for morphology measurement and with TrackPy for tracking. A built-in diameter estimator removes the need to specify cell size, though providing an approximate diameter improves accuracy.
When to Use
- Segmenting cells or nuclei in fluorescence microscopy images where rule-based thresholding fails due to varying intensity or cell touching
- Processing large microscopy datasets in batch without per-image parameter tuning
- Segmenting diverse cell types (adherent cells, blood cells, bacteria, organoids) with a single model
- Producing label masks for downstream region property measurement (area, intensity, shape) with scikit-image
- 3D volumetric segmentation of z-stack microscopy data with
do_3D=True - Use scikit-image watershed when cells are well-separated and rule-based thresholding is sufficient
- Use StarDist as an alternative deep learning segmenter optimized for star-convex cells (neurons, nuclei)
Prerequisites
- Python packages:
cellpose,numpy,matplotlib - Optional: GPU with CUDA for 10-50× speedup (
pip install cellpose[gui]for GUI) - Input: grayscale or multichannel TIFF/PNG images (2D or 3D arrays)
# Install Cellpose
pip install cellpose
# Install with GUI support
pip install cellpose[gui]
# Install with GPU (PyTorch CUDA)
pip install cellpose torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# Verify
python -c "from cellpose import models; print('Cellpose ready')"
Quick Start
from cellpose import models
import numpy as np
from skimage import io
# Load image (grayscale or 2D array)
img = io.imread("cells.tif") # shape: (H, W) or (H, W, C)
# Initialize model and segment
model = models.Cellpose(model_type="cyto3", gpu=False)
masks, flows, styles, diams = model.eval(img, diameter=0, channels=[0, 0])
print(f"Cells segmented: {masks.max()}") # number of cells
print(f"Estimated diameter: {diams:.1f} px")
print(f"Mask shape: {masks.shape}")
Workflow
Step 1: Load and Inspect Images
Load microscopy images and inspect channel layout before segmentation.
import numpy as np
from skimage import io
import matplotlib.pyplot as plt
# Load single-channel fluorescence image
img_gray = io.imread("nucleus_dapi.tif") # shape: (H, W)
img_rgb = io.imread("cells_multichannel.tif") # shape: (H, W, C)
print(f"Grayscale shape: {img_gray.shape}, dtype: {img_gray.dtype}")
print(f"Multichannel shape: {img_rgb.shape}")
# Preview
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].imshow(img_gray, cmap="gray")
axes[0].set_title("DAPI (nuclei)")
axes[1].imshow(img_rgb[..., 0], cmap="green")
axes[1].set_title("GFP channel")
plt.tight_layout()
plt.savefig("image_preview.png", dpi=100)
print("Saved: image_preview.png")
Step 2: Segment Cells with a Pre-trained Model
Run Cellpose with the appropriate pre-trained model.
from cellpose import models
import numpy as np
from skimage import io
# Available models: 'cyto3' (cells), 'nuclei', 'tissuenet', 'cyto2', 'CP'
model = models.Cellpose(model_type="cyto3", gpu=False)
img = io.imread("cells.tif")
# channels=[cytoplasm_channel, nucleus_channel]
# Use [0, 0] for grayscale; [1, 3] for green cytoplasm + blue nucleus (1-indexed)
masks, flows, styles, diams = model.eval(
img,
diameter=0, # 0 = auto-estimate; or provide px estimate
channels=[0, 0], # grayscale
flow_threshold=0.4, # lower = fewer false positives; range 0.1-1.0
cellprob_threshold=0.0, # lower = more cells detected; range -6 to 6
)
print(f"Cells found: {masks.max()}")
print(f"Estimated cell diameter: {diams:.1f} pixels")
np.save("masks.npy", masks)
Step 3: Segment Nuclei from DAPI Channel
Use the nuclei model for DAPI-stained nuclei.
from cellpose import models
from skimage import io
import numpy as np
model = models.Cellpose(model_type="nuclei", gpu=False)
dapi = io.imread("dapi.tif")
# Nucleus-only segmentation: channels=[0, 0] (single channel)
masks, flows, styles, diams = model.eval(
dapi,
diameter=30, # approximate nucleus diameter in pixels
channels=[0, 0],
flow_threshold=0.4,
cellprob_threshold=0.0,
)
print(f"Nuclei segmented: {masks.max()}")
# Save label mask as TIFF for ImageJ/FIJI compatibility
from skimage import io as skio
skio.imsave("nuclei_masks.tif", masks.astype(np.uint16))
print("Saved: nuclei_masks.tif")
Step 4: Visualize Segmentation Results
Overlay masks on original images for quality control.
from cellpose import plot as cpplot
import matplotlib.pyplot as plt
import numpy as np
from skimage import io
img = io.imread("cells.tif")
masks = np.load("masks.npy")
flows_data = None # load if you saved them: flows = np.load("flows.npy", allow_pickle=True)
# Cellpose built-in visualization
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Original image
axes[0].imshow(img, cmap="gray")
axes[0].set_title(f"Original image")
# Label mask (each cell = unique color)
axes[1].imshow(masks, cmap="tab20")
axes[1].set_title(f"Segmentation masks ({masks.max()} cells)")
# Overlay: outline on original
from skimage.segmentation import find_boundaries
boundaries = find_boundaries(masks, mode="inner")
overlay = np.stack([img / img.max()] * 3, axis=-1)
overlay[boundaries] = [1, 0, 0] # red outlines
axes[2].imshow(overlay)
axes[2].set_title("Outlines overlay")
plt.tight_layout()
plt.savefig("segmentation_result.png", dpi=150)
print("Saved: segmentation_result.png")
Step 5: Measure Cell Properties from Masks
Extract morphology and intensity measurements using scikit-image regionprops.
import numpy as np
import pandas as pd
from skimage.measure import regionprops_table
from skimage import io
masks = np.load("masks.npy")
img = io.imread("cells.tif")
# Measure morphology and intensity per cell
props = regionprops_table(
masks, intensity_image=img,
properties=["label", "area", "centroid", "eccentricity",
"mean_intensity", "max_intensity", "perimeter",
"equivalent_diameter_area"]
)
df = pd.DataFrame(props)
df.columns = ["cell_id", "area_px", "centroid_y", "centroid_x",
"eccentricity", "mean_intensity", "max_intensity",
"perimeter", "diameter_px"]
print(f"Cells measured: {len(df)}")
print(f"Median area: {df['area_px'].median():.0f} px²")
print(f"Median diameter: {df['diameter_px'].median():.1f} px")
print(df.head())
df.to_csv("cell_measurements.csv", index=False)
Step 6: Batch Segment Multiple Images
Process a directory of images and aggregate results.
from cellpose import models
from skimage import io
from skimage.measure import regionprops_table
import pandas as pd
import numpy as np
from pathlib import Path
model = models.Cellpose(model_type="cyto3", gpu=False)
image_dir = Path("images/")
output_dir = Path("results/")
output_dir.mkdir(exist_ok=True)
all_stats = []
for img_path in sorted(image_dir.glob("*.tif")):
img = io.imread(img_path)
masks, _, _, diams = model.eval(img, diameter=0, channels=[0, 0])
# Save mask
np.save(output_dir / f"{img_path.stem}_masks.npy", masks)
# Measure
if masks.max() > 0:
props = regionprops_table(masks, intensity_image=img,
properties=["label", "area", "mean_intensity"])
df = pd.DataFrame(props)
df["image"] = img_path.name
df["est_diameter"] = diams
all_stats.append(df)
print(f"{img_path.name}: {masks.max()} cells, diameter={diams:.0f}px")
summary = pd.concat(all_stats, ignore_index=True)
summary.to_csv(output_dir / "all_cells.csv", index=False)
print(f"\nTotal cells: {len(summary)} across {summary['image'].nunique()} images")
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
model_type |
"cyto3" |
"cyto3", "cyto2", "nuclei", "tissuenet", "CP", custom path |
Pre-trained model; cyto3 is most general; nuclei for DAPI-only |
diameter |
30 |
0–500 px | Approximate cell diameter in pixels; 0 = auto-estimate from image |
channels |
[0, 0] |
[cyto, nucleus] (0=gray, 1=R, 2=G, 3=B) |
Channel indices for cytoplasm and nuclear stain |
flow_threshold |
0.4 |
0.1–1.0 | Cell probability threshold from flow field; lower = stricter |
cellprob_threshold |
0.0 |
−6 to 6 | Cell probability cutoff; increase to find more cells |
gpu |
False |
True, False |
Enable GPU inference (requires CUDA PyTorch) |
do_3D |
False |
True, False |
Enable 3D volumetric segmentation of z-stacks |
min_size |
15 |
integer px² | Minimum object size in pixels²; smaller objects discarded |
batch_size |
8 |
integer | Number of image tiles processed per GPU batch |
normalize |
True |
True, False |
Normalize image intensity before segmentation |
Common Recipes
Recipe 1: Segment Multichannel Image (GFP + DAPI)
from cellpose import models
from skimage import io
import numpy as np
model = models.Cellpose(model_type="cyto3", gpu=False)
# Multichannel image: channel 1 = GFP (cytoplasm), channel 3 = DAPI (nucleus)
img_multi = io.imread("cells_gfp_dapi.tif") # shape: (H, W, 3)
# channels=[cytoplasm_channel, nucleus_channel] (1-indexed for multichannel)
masks, flows, styles, diams = model.eval(
img_multi,
diameter=0,
channels=[2, 3], # GFP=channel2, DAPI=channel3 (1-indexed)
flow_threshold=0.4,
)
print(f"Cells segmented: {masks.max()}, diameter: {diams:.0f}px")
np.save("masks_multichannel.npy", masks)
Recipe 2: Use Cellpose CLI for Directory Batch Processing
# CLI batch segmentation of all TIFFs in a directory
cellpose \
--image_path images/ \
--pretrained_model cyto3 \
--diameter 0 \
--chan 0 \
--save_tif \
--no_npy
# With GPU
cellpose \
--image_path images/ \
--pretrained_model nuclei \
--diameter 30 \
--chan 0 \
--use_gpu \
--save_tif
# Results saved as: images/*_cp_masks.tif
echo "Done. Masks saved in images/ directory."
Recipe 3: Fine-tune Cellpose on Custom Cell Type
from cellpose import models, train
import numpy as np
from skimage import io
# Prepare training data: list of images and corresponding masks
train_images = [io.imread(f"train/img_{i}.tif") for i in range(10)]
train_masks = [np.load(f"train/mask_{i}.npy") for i in range(10)]
# Fine-tune starting from cyto3
model = models.CellposeModel(model_type="cyto3")
# Train: saves model to models/ directory
model_path = train.train_seg(
model.net,
train_data=train_images,
train_labels=train_masks,
channels=[0, 0],
save_path="models/",
n_epochs=100,
learning_rate=0.2,
weight_decay=1e-5,
)
print(f"Fine-tuned model saved: {model_path}")
Expected Outputs
| Output | Format | Description |
|---|---|---|
masks array |
numpy int32 | Label mask: 0=background, 1..N=unique cell IDs |
flows list |
numpy arrays | Flow field components: [XY flows, cell prob, gradient] |
styles array |
numpy float | Style vector embedding (used for model similarity) |
diams float |
scalar | Estimated average cell diameter in pixels |
*_masks.npy |
NumPy | Saved mask array (from np.save) |
*_cp_masks.tif |
TIFF uint16 | Mask TIFF (from CLI --save_tif); compatible with FIJI/ImageJ |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| All cells merged into one mask | Diameter too large or cells too close | Reduce diameter; increase flow_threshold to 0.6–0.8 |
| Very few cells detected | Diameter too small or cellprob_threshold too high |
Increase cellprob_threshold to −2; use diameter=0 for auto |
| Many false positives (background labeled) | Low flow_threshold |
Increase flow_threshold to 0.6–0.9; increase min_size |
| GPU out of memory | Image too large for GPU batch | Process in tiles; reduce batch_size; crop image |
| Poor generalization on new cell type | Model not trained on similar cells | Try all pre-trained models; fine-tune with 10-20 annotated images |
| 3D segmentation very slow | Large z-stack on CPU | Enable GPU; reduce z-stack depth; use anisotropy parameter |
| Mask values overflow uint8 | More than 255 cells in image | Save with dtype=np.uint16 or np.int32 |
Import error: No module named 'cellpose' |
Package not installed | pip install cellpose or conda install -c conda-forge cellpose |
References
- Cellpose GitHub: MouseLand/cellpose — source code, documentation, and model zoo
- Stringer C et al. (2021) "Cellpose: a generalist algorithm for cellular segmentation" — Nature Methods 18:100-106. DOI:10.1038/s41592-020-01018-x
- Pachitariu M & Stringer C (2022) "Cellpose 2.0: how to train your own model" — Nature Methods 19:1500-1508. DOI:10.1038/s41592-022-01663-4
- Cellpose documentation — official API reference and training guide
skills/cell-biology/flowio-flow-cytometry/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill flowio-flow-cytometry -g -y
SKILL.md
Frontmatter
{
"name": "flowio-flow-cytometry",
"license": "BSD-3-Clause",
"description": "Parse\/write FCS (Flow Cytometry) files v2.0-3.1. Events as NumPy, channel metadata, multi-dataset files, CSV\/FCS export. Use FlowKit for gating\/compensation."
}
FlowIO — Flow Cytometry File Handler
Overview
FlowIO is a lightweight Python library for reading and writing Flow Cytometry Standard (FCS) files. It parses FCS metadata, extracts event data as NumPy arrays, and creates new FCS files. Supports FCS versions 2.0, 3.0, and 3.1. Minimal dependencies — ideal for data pipelines and preprocessing before advanced analysis.
When to Use
- Parsing FCS files to extract event data as NumPy arrays
- Reading channel metadata (names, ranges, types) from FCS files
- Converting flow cytometry data to pandas DataFrames or CSV
- Creating new FCS files from NumPy arrays or processed data
- Handling multi-dataset FCS files (separating combined datasets)
- Batch processing directories of FCS files
- Preprocessing flow cytometry data before downstream analysis
- For compensation, gating, and FlowJo workspace support, use FlowKit instead
- For advanced cytometry visualization (density plots, gating plots), use matplotlib or plotly
Prerequisites
pip install flowio numpy pandas
Requires Python 3.9+. No compiled dependencies — installs on any platform.
Quick Start
from flowio import FlowData
flow = FlowData("experiment.fcs")
print(f"Events: {flow.event_count}, Channels: {flow.channel_count}")
print(f"Channels: {flow.pnn_labels}")
events = flow.as_array() # Shape: (n_events, n_channels)
print(f"Data shape: {events.shape}")
Core API
1. Reading FCS Files
The FlowData class is the primary interface for reading FCS files.
from flowio import FlowData
# Standard reading
flow = FlowData("sample.fcs")
print(f"Version: {flow.version}") # '3.0', '3.1', etc.
print(f"Events: {flow.event_count}")
print(f"Channels: {flow.channel_count}")
# Event data
events = flow.as_array() # Preprocessed (gain, log scaling)
raw = flow.as_array(preprocess=False) # Raw values
print(f"Shape: {events.shape}") # (n_events, n_channels)
# Memory-efficient: metadata only (skip DATA segment)
flow_meta = FlowData("sample.fcs", only_text=True)
print(f"Instrument: {flow_meta.text.get('$CYT', 'Unknown')}")
# Handle problematic files
flow = FlowData("bad.fcs", ignore_offset_discrepancy=True)
flow = FlowData("bad.fcs", use_header_offsets=True)
# Exclude null channels
flow = FlowData("sample.fcs", null_channel_list=["Time", "Null"])
2. Channel Metadata
Extract channel names, types, and ranges from FCS files.
flow = FlowData("sample.fcs")
# Channel names
pnn = flow.pnn_labels # Short names: ['FSC-A', 'SSC-A', 'FL1-A', ...]
pns = flow.pns_labels # Descriptive: ['Forward Scatter', 'Side Scatter', 'FITC', ...]
pnr = flow.pnr_values # Range/max values per channel
# Channel type indices
scatter_idx = flow.scatter_indices # [0, 1] — FSC, SSC
fluoro_idx = flow.fluoro_indices # [2, 3, 4] — fluorescence channels
time_idx = flow.time_index # Time channel index (or None)
# Access by type
events = flow.as_array()
scatter_data = events[:, scatter_idx]
fluoro_data = events[:, fluoro_idx]
# Full metadata (TEXT segment dictionary)
text = flow.text
print(f"Date: {text.get('$DATE', 'N/A')}")
print(f"Instrument: {text.get('$CYT', 'N/A')}")
3. Creating FCS Files
Generate new FCS files from NumPy arrays.
import numpy as np
from flowio import create_fcs
# Basic creation
events = np.random.rand(10000, 5) * 1000
channels = ["FSC-A", "SSC-A", "FL1-A", "FL2-A", "Time"]
create_fcs("output.fcs", events, channels)
# With descriptive names and metadata
create_fcs(
"output.fcs",
events,
channels,
opt_channel_names=["Forward Scatter", "Side Scatter", "FITC", "PE", "Time"],
metadata={"$SRC": "Python pipeline", "$DATE": "17-FEB-2026", "$CYT": "Synthetic"},
)
# Output: FCS 3.1, single-precision float
4. Multi-Dataset FCS Files
Handle FCS files containing multiple datasets.
from flowio import FlowData, read_multiple_data_sets, MultipleDataSetsError
# Detect multi-dataset files
try:
flow = FlowData("sample.fcs")
except MultipleDataSetsError:
datasets = read_multiple_data_sets("sample.fcs")
print(f"Found {len(datasets)} datasets")
for i, ds in enumerate(datasets):
print(f"Dataset {i}: {ds.event_count} events, {ds.channel_count} channels")
events = ds.as_array()
# Read specific dataset by offset
first = FlowData("multi.fcs", nextdata_offset=0)
next_offset = int(first.text.get("$NEXTDATA", "0"))
if next_offset > 0:
second = FlowData("multi.fcs", nextdata_offset=next_offset)
5. Modifying and Re-Exporting
Read, modify, and save FCS data.
from flowio import FlowData, create_fcs
# Read original
flow = FlowData("original.fcs")
events = flow.as_array(preprocess=False) # Use raw for modification
# Filter events (e.g., threshold on FSC)
mask = events[:, 0] > 500
filtered = events[mask]
print(f"Before: {len(events)}, After: {len(filtered)}")
# Save filtered data as new FCS
create_fcs(
"filtered.fcs",
filtered,
flow.pnn_labels,
opt_channel_names=flow.pns_labels,
metadata={**flow.text, "$SRC": "Filtered"},
)
# Or write with updated metadata (no event modification)
flow.write_fcs("updated.fcs", metadata={"$SRC": "Updated"})
Key Concepts
FCS File Structure
FCS files consist of four segments:
| Segment | Content | FlowData attribute |
|---|---|---|
| HEADER | Version, byte offsets | flow.header |
| TEXT | Key-value metadata ($DATE, $CYT, channel names) |
flow.text |
| DATA | Event data (binary/float) | flow.events (bytes), flow.as_array() |
| ANALYSIS | Optional processed results | flow.analysis |
Preprocessing (as_array)
When preprocess=True (default), FlowIO applies:
- Gain scaling: Multiply by PnG gain values
- Log transform: Apply PnE exponential transform if present (
value = a × 10^(b × raw)) - Time scaling: Convert time channel to proper units
Use preprocess=False when you need raw values for modification or custom transforms.
Common Workflows
Workflow: Batch FCS Summary
from pathlib import Path
from flowio import FlowData
import pandas as pd
fcs_files = list(Path("data/").glob("*.fcs"))
summaries = []
for f in fcs_files:
try:
flow = FlowData(str(f), only_text=True)
summaries.append({
"file": f.name, "version": flow.version,
"events": flow.event_count, "channels": flow.channel_count,
"date": flow.text.get("$DATE", "N/A"),
})
except Exception as e:
print(f"Error: {f.name}: {e}")
df = pd.DataFrame(summaries)
print(df)
Workflow: FCS to DataFrame with Channel Statistics
from flowio import FlowData
import pandas as pd
import numpy as np
flow = FlowData("sample.fcs")
df = pd.DataFrame(flow.as_array(), columns=flow.pnn_labels)
# Per-channel statistics
for col in df.columns:
print(f"{col}: mean={df[col].mean():.1f}, median={df[col].median():.1f}, std={df[col].std():.1f}")
# Export
df.to_csv("output.csv", index=False)
print(f"Exported {len(df)} events, {len(df.columns)} channels")
Key Parameters
| Parameter | Function | Default | Options | Effect |
|---|---|---|---|---|
preprocess |
as_array() |
True |
True/False |
Apply gain/log scaling |
only_text |
FlowData() |
False |
True/False |
Skip DATA segment (metadata only) |
ignore_offset_discrepancy |
FlowData() |
False |
True/False |
Tolerate HEADER/TEXT offset mismatch |
use_header_offsets |
FlowData() |
False |
True/False |
Prefer HEADER over TEXT offsets |
ignore_offset_error |
FlowData() |
False |
True/False |
Skip all offset validation |
null_channel_list |
FlowData() |
None |
List of names | Exclude channels during parsing |
nextdata_offset |
FlowData() |
None |
byte offset | Read specific dataset in multi-dataset files |
opt_channel_names |
create_fcs() |
None |
List of names | Descriptive channel names (PnS) |
metadata |
create_fcs() |
None |
Dict | Custom TEXT segment key-value pairs |
Best Practices
-
Use
only_text=Truefor metadata scanning: When processing many files, skip DATA segment parsing for 10-100x speedup. -
Use
preprocess=Falsefor data modification: Always work with raw values when filtering/modifying events, then re-export. Preprocessing is irreversible. -
Anti-pattern — modifying
flow.eventsdirectly: FlowIO does not support in-place event modification. Extract withas_array(), modify, thencreate_fcs()to save. -
Preserve metadata on re-export: Pass
flow.textas metadata tocreate_fcs()to retain original acquisition info. -
Check for multi-dataset files: Catch
MultipleDataSetsErrorand useread_multiple_data_sets()— some instruments write multiple acquisitions into one file.
Common Recipes
Recipe: Extract Fluorescence Channels Only
from flowio import FlowData
import numpy as np
flow = FlowData("sample.fcs")
events = flow.as_array()
fluoro = events[:, flow.fluoro_indices]
names = [flow.pnn_labels[i] for i in flow.fluoro_indices]
print(f"Fluorescence channels: {names}, shape: {fluoro.shape}")
Recipe: File Inspection Report
from flowio import FlowData
flow = FlowData("unknown.fcs")
print(f"Version: {flow.version} | Events: {flow.event_count:,} | Channels: {flow.channel_count}")
for i, (pnn, pns) in enumerate(zip(flow.pnn_labels, flow.pns_labels)):
ctype = "scatter" if i in flow.scatter_indices else "fluoro" if i in flow.fluoro_indices else "time" if i == flow.time_index else "other"
print(f" [{i}] {pnn:10s} | {pns:30s} | {ctype}")
for key in ["$DATE", "$CYT", "$INST", "$SRC"]:
print(f" {key}: {flow.text.get(key, 'N/A')}")
Recipe: Normalize Events to [0, 1] Range
When to use: Prepare fluorescence channels for machine learning or cross-sample comparison.
from flowio import FlowData
import numpy as np
flow = FlowData("sample.fcs")
events = flow.as_array()
# Normalize each fluorescence channel to [0, 1]
fluoro_idx = flow.fluoro_indices
fluoro = events[:, fluoro_idx]
pnr = np.array(flow.pnr_values)[fluoro_idx] # Per-channel max range
normalized = fluoro / pnr
print(f"Normalized shape: {normalized.shape}, range: [{normalized.min():.3f}, {normalized.max():.3f}]")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
DataOffsetDiscrepancyError |
HEADER/TEXT offset mismatch | Use ignore_offset_discrepancy=True |
MultipleDataSetsError |
File contains multiple datasets | Use read_multiple_data_sets() instead |
FCSParsingError |
Corrupt or non-standard FCS file | Try ignore_offset_error=True; verify file is valid FCS |
| Out of memory on large files | Millions of events loaded at once | Use only_text=True for metadata; process in chunks by channel |
| Unexpected channel count | Null/padding channels in file | Use null_channel_list=["Time", "Null"] to exclude |
| Modified data has wrong values | Applied preprocessing before modification | Use preprocess=False for raw data when modifying events |
| Channel names missing (empty PnS) | Instrument didn't set descriptive names | Use pnn_labels (short names) instead; PnS is optional in FCS spec |
Related Skills
- matplotlib-scientific-plotting — create scatter plots, density plots, and histograms from extracted cytometry data
- scikit-learn-machine-learning — clustering and dimensionality reduction on cytometry event data
References
- FlowIO documentation — official GitHub repository and API
- FCS file format specification — ISAC data standards for flow cytometry
- Spidlen et al. (2010) "Data File Standard for Flow Cytometry, Version FCS 3.1" — Cytometry Part A
skills/cell-biology/napari-image-viewer/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill napari-image-viewer -g -y
SKILL.md
Frontmatter
{
"name": "napari-image-viewer",
"license": "BSD-3-Clause",
"description": "Interactive viewer for microscopy. Displays 2D\/3D\/4D arrays as Image, Labels, Points, Shapes, Tracks layers; supports annotation, plugin analysis, headless screenshots. Core visualization for Python bioimage workflows. Use ImageJ\/FIJI for macro processing; napari for Python-native interactive visualization and DL segmentation review."
}
napari — Multi-dimensional Image Viewer
Overview
napari is a fast, interactive multi-dimensional viewer for scientific data built on PyQt5 and VisPy. It displays NumPy arrays and zarr arrays as layered visualizations — Image layers for raw data, Labels layers for segmentation masks, Points layers for cell centroids, and Shapes layers for ROI annotations. napari integrates with scikit-image, Cellpose, and StarDist via plugins, making it the standard visualization and annotation tool in Python bioimage analysis pipelines. For headless environments (HPC, CI), napari supports offscreen rendering and viewer.screenshot() for automated figure generation.
When to Use
- Visually inspecting and quality-checking microscopy images and segmentation masks before quantitative analysis
- Annotating training data for deep learning segmentation models (Cellpose, StarDist)
- Overlaying multiple image channels (DAPI, GFP, mCherry) with independent contrast and colormap control
- Reviewing 3D z-stacks and 4D time-lapse experiments with slider-based navigation
- Exporting annotated screenshots or label masks from GUI for publication figures
- Running plugin-based analysis (Cellpose napari plugin, StarDist plugin, n2v denoising) interactively
- Use ImageJ/FIJI for macro/batch scripting with minimal Python dependency
- Use ITK-SNAP as an alternative for medical imaging (DICOM, NIfTI) segmentation
Prerequisites
- Python packages:
napari,numpy,scikit-image - Qt backend: requires display server; for headless use
QT_QPA_PLATFORM=offscreen - Optional plugins:
napari-cellpose,napari-stardist,napari-animation
# Install with all backends
pip install "napari[all]"
# Or minimal install
pip install napari pyqt5
# Verify
python -c "import napari; print(napari.__version__)"
# 0.5.5
# Install useful plugins
pip install napari-cellpose napari-animation
Quick Start
import napari
import numpy as np
from skimage import data
# Open viewer with a sample image
viewer = napari.Viewer()
viewer.add_image(data.cells3d()[:, 1, :, :], name="DAPI", colormap="blue")
napari.run() # blocks until viewer closed (use in scripts)
Core API
Module 1: Image Layer — Display Raw Images
Add and configure multi-channel image layers.
import napari
import numpy as np
from skimage import io
viewer = napari.Viewer()
# Add single grayscale image
img = io.imread("cells.tif") # shape: (H, W)
viewer.add_image(img, name="phase contrast", colormap="gray",
contrast_limits=[0, img.max()])
# Add multichannel image (3 channels)
img_mc = io.imread("multichannel.tif") # shape: (H, W, 3)
viewer.add_image(img_mc[..., 0], name="DAPI", colormap="blue", blending="additive")
viewer.add_image(img_mc[..., 1], name="GFP", colormap="green", blending="additive")
viewer.add_image(img_mc[..., 2], name="mCherry", colormap="red", blending="additive")
print(f"Layers: {[l.name for l in viewer.layers]}")
Module 2: Labels Layer — Visualize Segmentation Masks
Display and edit integer label masks from Cellpose, StarDist, or scikit-image.
import napari
import numpy as np
from skimage import io
viewer = napari.Viewer()
img = io.imread("cells.tif")
masks = np.load("masks.npy") # integer label array: 0=background, 1..N=cells
# Add raw image
viewer.add_image(img, name="raw", colormap="gray")
# Add label mask (each cell gets a unique random color)
label_layer = viewer.add_labels(masks, name="cell_masks", opacity=0.5)
# Access labels for editing
print(f"Unique cells: {len(np.unique(masks)) - 1}")
print(f"Label layer data shape: {label_layer.data.shape}")
Module 3: Points Layer — Mark Cell Centroids
Add and style point markers for centroids, landmarks, or detected features.
import napari
import numpy as np
import pandas as pd
from skimage.measure import regionprops_table
viewer = napari.Viewer()
# Compute centroids from label mask
masks = np.load("masks.npy")
props = regionprops_table(masks, properties=["centroid", "label"])
centroids = np.column_stack([props["centroid-0"], props["centroid-1"]])
# Add centroids as Points layer
viewer.add_points(
centroids,
name=f"centroids ({len(centroids)} cells)",
size=8,
face_color="yellow",
edge_color="black",
edge_width=0.5,
)
print(f"Cells marked: {len(centroids)}")
Module 4: Shapes Layer — Draw ROIs and Annotations
Add bounding boxes, polygons, and line annotations.
import napari
import numpy as np
viewer = napari.Viewer()
# Add rectangles as ROIs (format: [[y1, x1], [y2, x2]])
rois = [
np.array([[50, 100], [200, 300]]), # ROI 1
np.array([[300, 150], [450, 350]]), # ROI 2
]
shapes_layer = viewer.add_shapes(
rois,
shape_type="rectangle",
name="ROIs",
edge_color="cyan",
face_color="transparent",
edge_width=2,
)
# Retrieve shapes data for analysis
for i, shape in enumerate(shapes_layer.data):
y_min, x_min = shape.min(axis=0)
y_max, x_max = shape.max(axis=0)
print(f"ROI {i+1}: y={y_min:.0f}-{y_max:.0f}, x={x_min:.0f}-{x_max:.0f}")
Module 5: 3D and Time-lapse Visualization
Display z-stacks and time series with sliders.
import napari
import numpy as np
from skimage import data
viewer = napari.Viewer()
# 3D z-stack: shape (Z, H, W)
zstack = data.cells3d()[:, 1, :, :] # nuclei channel
viewer.add_image(zstack, name="z-stack nuclei",
colormap="cyan", blending="additive")
# 4D time-lapse: shape (T, H, W) or (T, Z, H, W)
timelapse = np.random.randint(0, 65535, (10, 256, 256), dtype=np.uint16)
viewer.add_image(timelapse, name="timelapse", colormap="gray")
# napari shows axis sliders automatically for ndim > 2
print(f"z-stack shape: {zstack.shape} → slider for Z axis")
print(f"timelapse shape: {timelapse.shape} → sliders for T axis")
Module 6: Headless Screenshot Export
Export screenshots without a display (for HPC and CI environments).
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen" # must be set BEFORE importing napari
import napari
import numpy as np
from skimage import io, data
import matplotlib
matplotlib.use("Agg") # also set matplotlib backend
viewer = napari.Viewer(show=False)
img = data.cells3d()[30, 1, :, :] # single z-slice
masks = (img > img.mean()).astype(int) # simple threshold mask
viewer.add_image(img, name="DAPI", colormap="blue", blending="additive")
viewer.add_labels(masks.astype(np.int32), name="masks", opacity=0.5)
# Export screenshot
screenshot = viewer.screenshot(path="napari_export.png", canvas_only=True)
print(f"Screenshot saved: napari_export.png ({screenshot.shape})")
viewer.close()
Key Parameters
| Parameter | Module | Default | Effect |
|---|---|---|---|
colormap |
add_image |
"gray" |
Colormap name (matplotlib cmaps + napari built-ins: "green", "blue", "cyan") |
contrast_limits |
add_image |
auto | [min, max] intensity clipping for display |
blending |
add_image |
"translucent" |
"additive" for multichannel overlay; "opaque" for solid |
opacity |
add_labels |
0.7 |
0–1 transparency of label layer over image |
face_color |
add_points |
"white" |
Point fill color (name, hex, or RGBA) |
size |
add_points |
10 |
Point radius in data coordinates (pixels) |
edge_width |
add_shapes |
1 |
Shape outline width in pixels |
show |
Viewer() |
True |
False for headless/offscreen mode |
ndisplay |
Viewer() |
2 |
3 for 3D OpenGL rendering mode |
canvas_only |
screenshot() |
False |
True to exclude the napari toolbar from export |
Common Workflows
Workflow 1: Review Cellpose Segmentation Quality
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen"
import napari
import numpy as np
from cellpose import models
from skimage import io
from skimage.measure import regionprops_table
# Segment with Cellpose
img = io.imread("cells.tif")
model = models.Cellpose(model_type="cyto3", gpu=False)
masks, _, _, diams = model.eval(img, diameter=0, channels=[0, 0])
# Visualize in napari (headless for export)
viewer = napari.Viewer(show=False)
viewer.add_image(img, name="raw", colormap="gray")
viewer.add_labels(masks, name=f"masks ({masks.max()} cells)", opacity=0.6)
# Add centroids
props = regionprops_table(masks, properties=["centroid"])
centroids = np.column_stack([props["centroid-0"], props["centroid-1"]])
viewer.add_points(centroids, name="centroids", size=6, face_color="yellow")
viewer.screenshot(path="segmentation_review.png", canvas_only=True)
viewer.close()
print(f"QC export: segmentation_review.png — {masks.max()} cells detected")
Workflow 2: Multi-channel FISH Image Analysis
import napari
import numpy as np
from skimage import io
# Load 4-channel FISH image: DAPI + 3 RNA probes
img = io.imread("fish_4channel.tif") # shape: (H, W, 4)
viewer = napari.Viewer()
channels = [
("DAPI", "blue", img[..., 0]),
("probe_A_cy3", "yellow", img[..., 1]),
("probe_B_cy5", "red", img[..., 2]),
("probe_C_gfp", "green", img[..., 3]),
]
for name, colormap, channel in channels:
viewer.add_image(channel, name=name, colormap=colormap,
blending="additive",
contrast_limits=[channel.min(), np.percentile(channel, 99.5)])
napari.run()
Common Recipes
Recipe 1: Export All Layers as Annotated Figure
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen"
import napari
import numpy as np
from skimage import io
import matplotlib.pyplot as plt
viewer = napari.Viewer(show=False)
img = io.imread("cells.tif")
masks = np.load("masks.npy")
viewer.add_image(img, name="raw", colormap="gray")
viewer.add_labels(masks, name="segmentation", opacity=0.5)
# Set camera zoom and position
viewer.camera.zoom = 1.5
viewer.camera.center = (img.shape[0] // 2, img.shape[1] // 2)
screenshot = viewer.screenshot(path="figure_panel.png", canvas_only=True)
viewer.close()
# Add scalebar with matplotlib
fig, ax = plt.subplots(figsize=(6, 6))
ax.imshow(screenshot)
ax.axis("off")
plt.tight_layout()
plt.savefig("figure_final.pdf", dpi=300, bbox_inches="tight")
print("Exported: figure_final.pdf")
Recipe 2: Batch Export Z-stack Projections
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen"
import napari
import numpy as np
from skimage import io
from pathlib import Path
output_dir = Path("projections")
output_dir.mkdir(exist_ok=True)
for img_path in sorted(Path("zstacks").glob("*.tif")):
zstack = io.imread(img_path) # shape: (Z, H, W)
max_proj = zstack.max(axis=0)
viewer = napari.Viewer(show=False)
viewer.add_image(max_proj, name="max_projection", colormap="gray")
viewer.screenshot(path=str(output_dir / f"{img_path.stem}_maxproj.png"), canvas_only=True)
viewer.close()
print(f"Exported: {img_path.stem}_maxproj.png")
print("All z-stack projections exported.")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" |
Missing display or Qt platform plugin | Set QT_QPA_PLATFORM=offscreen before importing napari; install libxcb-util-dev |
| napari window does not open | Running in SSH without X forwarding | Use viewer = napari.Viewer(show=False) and export via screenshot() |
| Slow rendering of large images | Image too large for GPU VRAM | Use viewer.add_image(img, multiscale=True) for pyramidal rendering |
| Labels layer shows wrong colors | Mask dtype overflow | Ensure masks are int32 not uint8 (overflow at 255 cells) |
napari.run() blocks Jupyter notebook |
Qt event loop conflict | Use %gui qt magic in Jupyter; or use viewer.show() without napari.run() |
| Screenshot is black/empty | Viewer not fully rendered before screenshot | Add viewer.update() or slight delay before screenshot() |
| Plugin not appearing in menu | Plugin not installed or wrong napari version | pip install napari-<plugin>; check napari version compatibility on napari-hub |
| 3D rendering slow | Complex geometry or large volume | Switch viewer.dims.ndisplay = 2; reduce z-stack depth |
References
- napari documentation — official API reference and tutorials
- napari GitHub: napari/napari — source code and plugin development guide
- Sofroniew N et al. (2022) "napari: a multi-dimensional image viewer for Python" — Zenodo. DOI:10.5281/zenodo.3555620
- napari-hub — plugin registry with 300+ community analysis plugins
skills/cell-biology/opencv-bioimage-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill opencv-bioimage-analysis -g -y
SKILL.md
Frontmatter
{
"name": "opencv-bioimage-analysis",
"license": "Apache-2.0",
"description": "Computer vision for bio-image preprocessing, feature detection, real-time microscopy. Color conversion, morphology, contour\/blob detection, template matching, optical flow on fluorescence\/brightfield. 10-100× faster than pure Python via C++. Use scikit-image for scientific morphometry\/regionprops; OpenCV for real-time, video, classical feature extraction."
}
OpenCV — Bio-image Computer Vision
Overview
OpenCV (cv2) provides optimized C++-backed image processing routines for preprocessing, segmentation, feature extraction, and video analysis of biological images. In life sciences, OpenCV is used for fluorescence image enhancement (background subtraction, CLAHE), morphological segmentation (watershed, contour detection), brightfield cell detection, and real-time microscopy stream processing. Unlike scikit-image (which emphasizes scientific measurement), OpenCV prioritizes computational speed and video support — making it ideal for preprocessing pipelines and real-time imaging applications.
When to Use
- Preprocessing fluorescence or brightfield images: background subtraction, CLAHE, Gaussian/median blur
- Detecting cell contours, blobs, or edges without deep learning (classical methods)
- Processing video streams from live-cell imaging microscopes in real-time
- Template matching for finding repeated structures (organelles, crystals, patterns)
- Applying morphological operations (erosion, dilation, opening, closing) for mask refinement
- Computing optical flow between video frames for cell tracking
- Use scikit-image instead for scientific morphometry, regionprops, and scientific image I/O (TIFF metadata)
- Use Cellpose or StarDist instead for deep-learning cell segmentation on fluorescence images
Prerequisites
- Python packages:
opencv-python,numpy,matplotlib - Optional:
opencv-contrib-pythonfor extra modules (SIFT, SURF, optical flow)
# Install OpenCV
pip install opencv-python
# Install with extra contributed modules (SIFT, SURF, etc.)
pip install opencv-contrib-python
# Verify
python -c "import cv2; print(cv2.__version__)"
# 4.10.0
Quick Start
import cv2
import numpy as np
# Read and display image info
img = cv2.imread("cells.tif", cv2.IMREAD_GRAYSCALE)
print(f"Shape: {img.shape}, dtype: {img.dtype}")
print(f"Min: {img.min()}, Max: {img.max()}")
# Apply Gaussian blur and threshold
blurred = cv2.GaussianBlur(img, (5, 5), 0)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"Cells detected (rough): {np.sum(binary > 0)} foreground pixels")
Core API
Module 1: Image I/O and Color Space Conversion
Read, write, and convert images between color spaces.
import cv2
import numpy as np
# Read image (GRAYSCALE, COLOR, or UNCHANGED for 16-bit)
img_gray = cv2.imread("cells.tif", cv2.IMREAD_GRAYSCALE) # uint8
img_color = cv2.imread("rgb.tif", cv2.IMREAD_COLOR) # BGR order!
img_16bit = cv2.imread("16bit.tif", cv2.IMREAD_UNCHANGED) # uint16
print(f"Grayscale shape: {img_gray.shape}, dtype: {img_gray.dtype}")
print(f"Color shape: {img_color.shape}")
# Color space conversions
img_rgb = cv2.cvtColor(img_color, cv2.COLOR_BGR2RGB) # BGR → RGB
img_hsv = cv2.cvtColor(img_color, cv2.COLOR_BGR2HSV) # BGR → HSV
img_gray2 = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY) # BGR → gray
# Write image
cv2.imwrite("output.png", img_gray)
cv2.imwrite("output_16bit.tif", img_16bit)
print("Images written.")
Module 2: Filtering and Enhancement
Apply filters and contrast enhancement for image preprocessing.
import cv2
import numpy as np
img = cv2.imread("cells.tif", cv2.IMREAD_GRAYSCALE)
# Gaussian blur (noise reduction)
blurred = cv2.GaussianBlur(img, (7, 7), sigmaX=1.5)
# Median blur (salt-and-pepper noise)
median = cv2.medianBlur(img, 5)
# CLAHE: Contrast Limited Adaptive Histogram Equalization (for microscopy)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
clahe_img = clahe.apply(img)
# Top-hat filter for bright spots on dark background
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, kernel)
print(f"CLAHE range: [{clahe_img.min()}, {clahe_img.max()}]")
cv2.imwrite("clahe_enhanced.tif", clahe_img)
Module 3: Thresholding and Binary Segmentation
Convert grayscale images to binary masks using various thresholding methods.
import cv2
import numpy as np
img = cv2.imread("nuclei.tif", cv2.IMREAD_GRAYSCALE)
# Otsu's thresholding (automatic threshold selection)
thresh_val, otsu_mask = cv2.threshold(img, 0, 255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"Otsu threshold: {thresh_val:.0f}")
# Adaptive thresholding (handles uneven illumination)
adaptive = cv2.adaptiveThreshold(
img, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
blockSize=11, # neighborhood size (odd)
C=2, # constant subtracted from mean
)
# For 16-bit images: normalize first
img_16 = cv2.imread("16bit_nuclei.tif", cv2.IMREAD_UNCHANGED)
img_8 = cv2.normalize(img_16, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U)
_, mask_16 = cv2.threshold(img_8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"Otsu mask foreground: {mask_16.sum() / 255} pixels")
Module 4: Contour Detection and Measurement
Find and measure cell contours from binary masks.
import cv2
import numpy as np
import pandas as pd
img = cv2.imread("cells.tif", cv2.IMREAD_GRAYSCALE)
blurred = cv2.GaussianBlur(img, (5, 5), 0)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# Remove small objects with morphological opening
kernel = np.ones((3, 3), np.uint8)
cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=2)
# Find contours
contours, hierarchy = cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print(f"Objects detected: {len(contours)}")
# Measure each contour
records = []
for i, cnt in enumerate(contours):
area = cv2.contourArea(cnt)
if area < 50: continue # skip tiny objects
perimeter = cv2.arcLength(cnt, True)
x, y, w, h = cv2.boundingRect(cnt)
(cx, cy), radius = cv2.minEnclosingCircle(cnt)
records.append({"cell_id": i, "area": area, "perimeter": perimeter,
"x": x, "y": y, "w": w, "h": h, "radius": radius})
df = pd.DataFrame(records)
print(f"Cells > 50 px²: {len(df)}")
print(df[["area", "perimeter", "radius"]].describe())
Module 5: Morphological Operations for Mask Refinement
Refine segmentation masks with morphological operations.
import cv2
import numpy as np
# Load binary mask (from thresholding or Cellpose)
mask = cv2.imread("rough_mask.png", cv2.IMREAD_GRAYSCALE)
_, mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
# Structural elements
ellipse = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
rect = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# Opening: remove small bright noise
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, ellipse, iterations=1)
# Closing: fill small holes inside cells
closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, ellipse, iterations=2)
# Dilation: expand cell boundaries slightly
dilated = cv2.dilate(closed, ellipse, iterations=1)
# Distance transform for watershed seed generation
dist = cv2.distanceTransform(closed, cv2.DIST_L2, 5)
_, seeds = cv2.threshold(dist, 0.5 * dist.max(), 255, 0)
seeds = seeds.astype(np.uint8)
print(f"Potential cell centers: {cv2.connectedComponents(seeds)[0] - 1}")
Module 6: Video Processing for Live-Cell Imaging
Process video streams from time-lapse microscopy.
import cv2
import numpy as np
# Process a time-lapse video file
cap = cv2.VideoCapture("timelapse.avi")
fps = cap.get(cv2.CAP_PROP_FPS)
n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
print(f"Video: {n_frames} frames at {fps} FPS")
# Background subtraction (remove static background)
bg_subtractor = cv2.createBackgroundSubtractorMOG2(
history=50, varThreshold=25, detectShadows=False
)
frame_counts = []
frame_idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret: break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
fg_mask = bg_subtractor.apply(gray)
# Count moving objects in this frame
contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
moving = [c for c in contours if cv2.contourArea(c) > 100]
frame_counts.append(len(moving))
frame_idx += 1
cap.release()
print(f"Processed {frame_idx} frames. Mean moving objects: {np.mean(frame_counts):.1f}")
Key Parameters
| Parameter | Module | Default | Effect |
|---|---|---|---|
sigmaX |
GaussianBlur |
auto from ksize | Gaussian standard deviation; larger = more smoothing |
clipLimit |
createCLAHE |
40.0 |
Maximum contrast amplification; 2.0–4.0 for microscopy |
tileGridSize |
createCLAHE |
(8,8) |
Tile size for local histogram equalization |
blockSize |
adaptiveThreshold |
required | Neighborhood size for adaptive threshold (must be odd, ≥ 3) |
C |
adaptiveThreshold |
required | Constant subtracted from mean; positive to subtract |
iterations |
morphologyEx |
1 |
Number of erosion/dilation cycles; higher = stronger effect |
history |
BackgroundSubtractorMOG2 |
500 |
Frames to model background; lower = faster adaptation |
varThreshold |
BackgroundSubtractorMOG2 |
16 |
Pixel variance threshold; higher = less sensitive |
minArea |
contour filter | — | Minimum cv2.contourArea(cnt) to keep; filter noise |
cv2.IMREAD_UNCHANGED |
imread |
— | Preserve bit-depth (16-bit, 32-bit); required for scientific images |
Common Workflows
Workflow 1: Fluorescence Nucleus Detection Pipeline
import cv2
import numpy as np
import pandas as pd
def detect_nuclei(image_path: str, min_area: int = 200) -> pd.DataFrame:
"""Detect DAPI-stained nuclei from a fluorescence image."""
img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
# Normalize 16-bit to 8-bit
if img.dtype == np.uint16:
img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U)
# Preprocess: CLAHE → Gaussian blur
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(img)
blurred = cv2.GaussianBlur(enhanced, (5, 5), 1.5)
# Segment: Otsu threshold → morphological opening
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)
# Find and measure contours
contours, _ = cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
records = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area: continue
M = cv2.moments(cnt)
if M["m00"] == 0: continue
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
records.append({"area": area, "cx": cx, "cy": cy,
"perimeter": cv2.arcLength(cnt, True)})
return pd.DataFrame(records)
df = detect_nuclei("dapi.tif", min_area=300)
print(f"Nuclei detected: {len(df)}")
print(df.describe())
Workflow 2: Batch Process Image Directory
import cv2
import numpy as np
import pandas as pd
from pathlib import Path
def process_image(path: str) -> dict:
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
return {}
blurred = cv2.GaussianBlur(img, (5, 5), 0)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cells = [c for c in contours if cv2.contourArea(c) > 200]
return {"file": Path(path).name, "cell_count": len(cells),
"mean_area": np.mean([cv2.contourArea(c) for c in cells]) if cells else 0}
results = [process_image(str(p)) for p in sorted(Path("images").glob("*.tif"))]
df = pd.DataFrame([r for r in results if r])
print(df)
df.to_csv("batch_results.csv", index=False)
print("Saved: batch_results.csv")
Common Recipes
Recipe 1: Annotate Detected Cells on Image
import cv2
import numpy as np
img = cv2.imread("cells.tif", cv2.IMREAD_GRAYSCALE)
img_color = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
blurred = cv2.GaussianBlur(img, (5, 5), 0)
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for i, cnt in enumerate(contours):
if cv2.contourArea(cnt) < 200: continue
# Draw contour outline
cv2.drawContours(img_color, [cnt], -1, (0, 255, 0), 2)
# Label with cell number
M = cv2.moments(cnt)
if M["m00"] > 0:
cx, cy = int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])
cv2.putText(img_color, str(i), (cx - 5, cy), cv2.FONT_HERSHEY_SIMPLEX,
0.4, (255, 255, 0), 1)
cv2.imwrite("annotated_cells.png", img_color)
print(f"Annotated {len(contours)} cells. Saved: annotated_cells.png")
Recipe 2: Background Subtraction with Rolling Ball
import cv2
import numpy as np
def rolling_ball_background(img: np.ndarray, radius: int = 50) -> np.ndarray:
"""Estimate and subtract background using a blur approximation."""
kernel_size = 2 * radius + 1
background = cv2.GaussianBlur(img, (kernel_size, kernel_size), radius / 3)
corrected = cv2.subtract(img, background)
return corrected
img = cv2.imread("uneven_fluorescence.tif", cv2.IMREAD_GRAYSCALE)
corrected = rolling_ball_background(img, radius=50)
cv2.imwrite("background_corrected.tif", corrected)
print(f"Background corrected. Range: [{corrected.min()}, {corrected.max()}]")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
imread returns None |
File not found or unsupported format | Use absolute path; verify with Path(path).exists(); for TIFF use cv2.IMREAD_UNCHANGED |
| 16-bit image shows as black | IMREAD_GRAYSCALE clips to uint8 |
Use cv2.IMREAD_UNCHANGED and normalize: cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX) |
| BGR vs RGB color mismatch | OpenCV uses BGR, matplotlib uses RGB | Convert: rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) before plt.imshow() |
| Contours split one cell into many | Binary mask has holes or noise | Apply cv2.MORPH_CLOSE before contour detection; increase Gaussian blur sigma |
GaussianBlur requires odd kernel |
Even kernel size provided | Always use odd kernel sizes: 3, 5, 7, 9; ksize=(5,5) not (4,4) |
| CLAHE makes image worse | clipLimit too high | Reduce clipLimit to 1.5–2.0; increase tileGridSize to (16,16) |
| Background subtraction removes cells | History too short for MOG2 | Increase history parameter; use static frame subtraction for microscopy |
| Performance slow on large images | Python loop over pixels | Use vectorized NumPy operations or CUDA-accelerated cv2.cuda module |
References
- OpenCV Python documentation — full Python API reference and tutorials
- OpenCV GitHub: opencv/opencv — source code and Python bindings
- Bradski G (2000) "The OpenCV Library" — Dr. Dobb's Journal of Software Tools 25(11):120-125
- PyImageSearch tutorials — applied OpenCV for computer vision in biological imaging
skills/cell-biology/pyimagej-fiji-bridge/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pyimagej-fiji-bridge -g -y
SKILL.md
Frontmatter
{
"name": "pyimagej-fiji-bridge",
"license": "Apache-2.0",
"description": "Python bridge to ImageJ2\/Fiji for macros, plugins (Bio-Formats, TrackMate, Analyze Particles), NumPy↔ImagePlus\/ImgLib2 exchange, and ImageJ Ops. Automates Fiji headlessly from Python. Use scikit-image for pure Python without Fiji plugins; napari for visualization."
}
PyImageJ — Python Bridge to ImageJ/Fiji
Overview
PyImageJ provides a Python interface to ImageJ2 and Fiji through PyJNIus and scyjava, embedding a full Java Virtual Machine inside a Python process. It enables bidirectional data exchange between NumPy arrays and ImageJ's ImagePlus/ImgLib2 data structures, so you can preprocess images in Python, pass them into Fiji plugins (Bio-Formats, TrackMate, Analyze Particles, Weka segmentation), and return results back to pandas DataFrames. The library supports headless operation for scripting and batch processing, as well as GUI mode for interactive Fiji sessions.
When to Use
- Running Fiji-specific plugins from Python: Bio-Formats multi-format I/O, TrackMate particle tracking, CLIJ2 GPU processing, or community Fiji update site plugins
- Automating ImageJ macro pipelines headlessly without opening the Fiji GUI, e.g., batch processing an entire experiment overnight
- Applying the ImageJ Ops framework (150+ image processing operations) with the full ImageJ type system
- Converting between NumPy arrays (SciPy ecosystem) and ImageJ hyperstacks (TZCYX channel order) for round-trip processing
- Parsing ImageJ Results tables and ROI Manager measurements into pandas DataFrames for downstream statistical analysis
- Executing existing
.ijmmacro files as part of a Python workflow without rewriting them - Use
scikit-imageinstead when you need pure Python processing without Fiji plugins — scikit-image is faster to install and avoids JVM overhead - Use
napariinstead for interactive multi-dimensional image visualization and annotation; PyImageJ does not replace a viewer
Prerequisites
- Python packages:
pyimagej,scyjava,numpy,pandas - Java: Java 8 or Java 11 (Java 17 is not supported); use conda for reliable Java management
- Fiji/ImageJ2: Downloaded automatically on first init, or specify a local Fiji installation path
- Environment: conda environment strongly recommended; pip-only installs often have JVM path issues
# Recommended: conda installation
conda create -n pyimagej -c conda-forge pyimagej openjdk=11
conda activate pyimagej
# Install additional dependencies
pip install pandas tifffile
# Verify
python -c "import imagej; ij = imagej.init('sc.fiji:fiji', mode='headless'); print(ij.getVersion())"
Quick Start
import imagej
import numpy as np
# Initialize Fiji in headless mode (downloads on first run, ~500 MB)
ij = imagej.init("sc.fiji:fiji", mode="headless")
print(f"ImageJ version: {ij.getVersion()}")
# Create a test image, process with Gaussian blur via Ops, convert back
arr = np.random.randint(0, 1000, (256, 256), dtype=np.uint16)
imp = ij.py.to_imageplus(arr)
blurred = ij.op().filter().gauss(imp.getProcessor(), 2.0)
result = ij.py.from_imageplus(imp)
print(f"Processed array shape: {result.shape}, dtype: {result.dtype}")
Core API
Module 1: Initialization
PyImageJ must be initialized once per Python session. The mode and endpoint determine which ImageJ distribution and GUI behavior to use.
import imagej
# Headless Fiji — most common for scripts and batch jobs
ij = imagej.init("sc.fiji:fiji", mode="headless")
# GUI mode — opens the Fiji window (requires a display)
ij = imagej.init("sc.fiji:fiji", mode="gui")
# Local Fiji installation — faster startup, no download
ij = imagej.init("/path/to/Fiji.app", mode="headless")
# Specific Fiji version
ij = imagej.init("sc.fiji:fiji:2.14.0", mode="headless")
# Bare ImageJ2 without Fiji plugins
ij = imagej.init("net.imagej:imagej", mode="headless")
print(f"ImageJ version: {ij.getVersion()}")
print(f"Headless: {ij.ui().isHeadless()}")
Module 2: Image I/O
Open and save images using ImageJ's I/O layer (which includes Bio-Formats for proprietary formats) and convert between ImageJ and NumPy representations.
import imagej
import numpy as np
ij = imagej.init("sc.fiji:fiji", mode="headless")
# Open any format Bio-Formats supports: CZI, LIF, ND2, ICS, TIFF, etc.
imp = ij.io().open("/data/experiment.czi")
print(f"Dimensions: {imp.getDimensions()}") # [W, H, C, Z, T]
print(f"nSlices: {imp.getNSlices()}, nFrames: {imp.getNFrames()}")
# Save image
ij.io().save(imp, "/data/output.tif")
print("Saved output.tif")
# NumPy ↔ ImageJ conversion
arr = np.zeros((100, 100), dtype=np.uint16)
arr[30:70, 30:70] = 1000 # bright square
# NumPy → ImagePlus
imp = ij.py.to_imageplus(arr)
print(f"ImagePlus: {imp.getWidth()}×{imp.getHeight()}, type={imp.getType()}")
# ImagePlus → NumPy (returns a view where possible)
arr_back = ij.py.from_imageplus(imp)
print(f"NumPy array: shape={arr_back.shape}, dtype={arr_back.dtype}")
# Multi-channel array: shape (C, H, W)
rgb = np.random.randint(0, 255, (3, 256, 256), dtype=np.uint8)
imp_rgb = ij.py.to_imageplus(rgb)
print(f"Channels: {imp_rgb.getNChannels()}")
Module 3: Macro Execution
Run ImageJ macro language (IJM) snippets or macro files. Macros execute inside the ImageJ environment and can call any built-in ImageJ command.
import imagej
ij = imagej.init("sc.fiji:fiji", mode="headless")
# Run an inline macro string
ij.macro.run("print('Hello from ImageJ macro');")
# Run a macro with options string (key=value pairs)
# Options string mirrors the dialog parameters of ImageJ commands
macro_code = """
run("Gaussian Blur...", "sigma=2");
run("Auto Threshold", "method=Otsu white");
"""
ij.macro.run(macro_code)
# Run a macro file from disk
ij.macro.runMacroFile("/scripts/my_analysis.ijm")
# Run macro that returns a value via getResult or output string
result = ij.macro.run("""
x = 42 * 2;
return x;
""")
print(f"Macro returned: {result}")
# Macro with current image: open → process → measure
ij.io().open("/data/cells.tif") # sets current active image
measure_macro = """
run("Set Measurements...", "area mean min integrated redirect=None decimal=3");
run("Analyze Particles...", "size=50-Infinity display clear summarize");
"""
ij.macro.run(measure_macro)
print("Analyze Particles complete; results in Results table")
Module 4: ImageJ Ops
ImageJ Ops is a framework of 150+ image processing operations with type-safe dispatch. Ops work on ImgLib2 Img objects and are the preferred way to call image processing algorithms programmatically.
import imagej
import numpy as np
ij = imagej.init("sc.fiji:fiji", mode="headless")
arr = np.random.randint(100, 900, (512, 512), dtype=np.uint16)
img = ij.py.to_java(arr) # converts to ImgLib2 RandomAccessibleInterval
# Gaussian blur
blurred = ij.op().filter().gauss(img, 2.0)
blurred_np = ij.py.from_java(blurred)
print(f"Blurred: {blurred_np.shape}")
# Otsu threshold → binary image
binary = ij.op().threshold().otsu(img)
binary_np = ij.py.from_java(binary)
print(f"Binary unique values: {np.unique(binary_np)}")
# Morphological operations
from jnius import autoclass
BitType = autoclass("net.imglib2.type.logic.BitType")
opened = ij.op().morphology().open(binary, [3, 3])
opened_np = ij.py.from_java(opened)
print(f"After opening: {opened_np.shape}")
# Statistics ops
mean_val = ij.op().stats().mean(img)
std_val = ij.op().stats().stdDev(img)
print(f"Mean intensity: {mean_val:.1f}, StdDev: {std_val:.1f}")
# Math ops: multiply image by scalar
scaled = ij.op().math().multiply(img, ij.py.to_java(2.0))
print(f"Scaled max: {ij.py.from_java(scaled).max()}")
Module 5: Plugin and Command Calls
SciJava commands are the primary way to invoke Fiji plugins programmatically. Commands accept a dict of named parameters mirroring the plugin dialog.
import imagej
ij = imagej.init("sc.fiji:fiji", mode="headless")
# Open a file using Bio-Formats opener command
future = ij.command().run(
"loci.plugins.LociImporter",
True,
{"id": "/data/image.lif", "open_files": True, "autoscale": True}
)
module = future.get()
imp = module.getOutput("imp")
print(f"Opened via Bio-Formats: {imp.getDimensions()}")
# Run Analyze Particles as a SciJava command
ij.io().open("/data/binary_mask.tif")
future = ij.command().run(
"ij.plugin.filter.ParticleAnalyzer",
True,
{
"minSize": 50.0,
"maxSize": float("inf"),
"options": 0, # SHOW_NONE
"measurements": 1, # AREA
}
)
future.get()
print("Analyze Particles command complete")
# Alternatively, run via macro string for simpler plugin invocation
ij.macro.run("""
run("Analyze Particles...", "size=50-Infinity display clear summarize");
""")
Module 6: Results Table and ROI Analysis
Retrieve measurement results from ImageJ's Results table and ROI Manager after running Analyze Particles or other measurement commands.
import imagej
import pandas as pd
ij = imagej.init("sc.fiji:fiji", mode="headless")
# After running Analyze Particles, read the Results table
def results_to_dataframe(ij) -> pd.DataFrame:
"""Convert ImageJ Results table to pandas DataFrame."""
rt = ij.ResultsTable.getResultsTable()
if rt is None or rt.size() == 0:
return pd.DataFrame()
headings = list(rt.getHeadings())
data = {col: [rt.getValue(col, i) for i in range(rt.size())]
for col in headings}
return pd.DataFrame(data)
# Run segmentation + measurement macro
ij.io().open("/data/cells.tif")
ij.macro.run("""
run("Gaussian Blur...", "sigma=1.5");
setAutoThreshold("Otsu dark");
run("Convert to Mask");
run("Analyze Particles...", "size=20-Infinity display clear");
""")
df = results_to_dataframe(ij)
print(f"Found {len(df)} objects")
print(df[["Area", "Mean", "IntDen"]].describe())
df.to_csv("particle_measurements.csv", index=False)
print("Saved particle_measurements.csv")
# Access the ROI Manager
def get_roi_manager(ij):
"""Return the ImageJ ROI Manager instance, creating if needed."""
RoiManager = ij.py.jclass("ij.plugin.frame.RoiManager")
rm = RoiManager.getInstance()
if rm is None:
rm = RoiManager(False) # headless=False means no GUI window
return rm
rm = get_roi_manager(ij)
roi_count = rm.getCount()
print(f"ROIs in manager: {roi_count}")
# Extract bounding boxes for all ROIs
rois = []
for i in range(roi_count):
roi = rm.getRoi(i)
bounds = roi.getBounds()
rois.append({"index": i, "x": bounds.x, "y": bounds.y,
"width": bounds.width, "height": bounds.height})
roi_df = pd.DataFrame(rois)
print(roi_df.head())
Common Workflows
Workflow 1: Automated Fluorescence Quantification
Goal: Open a multi-channel TIFF stack, apply Gaussian blur, threshold nuclei channel, run Analyze Particles, and export per-cell measurements as CSV.
import imagej
import pandas as pd
import numpy as np
from pathlib import Path
ij = imagej.init("sc.fiji:fiji", mode="headless")
def quantify_nuclei(tiff_path: str, output_csv: str,
channel: int = 1, sigma: float = 1.5,
min_size: int = 50) -> pd.DataFrame:
"""
Segment and measure nuclei in a fluorescence TIFF.
Parameters
----------
tiff_path : path to single- or multi-channel TIFF
output_csv : where to save results
channel : 1-based channel index for nuclear stain (e.g., DAPI)
sigma : Gaussian blur radius in pixels
min_size : minimum nucleus area in pixels
"""
# Step 1: Open image
imp = ij.io().open(tiff_path)
print(f"Loaded: {Path(tiff_path).name} dims={imp.getDimensions()}")
# Step 2: Extract channel if multi-channel
if imp.getNChannels() > 1:
imp.setC(channel)
# Step 3: Apply Gaussian blur and threshold via macro
ij.macro.run(f"""
selectWindow("{imp.getTitle()}");
run("Gaussian Blur...", "sigma={sigma}");
setAutoThreshold("Otsu dark");
run("Convert to Mask");
run("Fill Holes");
run("Watershed");
""")
# Step 4: Measure
ij.macro.run(f"""
run("Set Measurements...", "area mean min centroid integrated shape redirect=None decimal=3");
run("Analyze Particles...", "size={min_size}-Infinity display clear include summarize");
""")
# Step 5: Collect results
rt = ij.ResultsTable.getResultsTable()
if rt is None or rt.size() == 0:
print("No objects detected")
return pd.DataFrame()
headings = list(rt.getHeadings())
df = pd.DataFrame(
{col: [rt.getValue(col, i) for i in range(rt.size())]
for col in headings}
)
df["source_file"] = Path(tiff_path).stem
df.to_csv(output_csv, index=False)
print(f"Saved {len(df)} measurements → {output_csv}")
return df
df = quantify_nuclei(
tiff_path="/data/experiment_dapi.tif",
output_csv="nuclei_measurements.csv",
channel=1,
sigma=1.5,
min_size=50
)
print(df[["Area", "Mean", "Circ."]].describe())
Workflow 2: Batch Fiji Macro Processing
Goal: Process a folder of images with an existing Fiji .ijm macro file, collect the Results table from each image into a single DataFrame.
import imagej
import pandas as pd
from pathlib import Path
ij = imagej.init("sc.fiji:fiji", mode="headless")
def run_macro_on_image(ij, image_path: str, macro_file: str) -> pd.DataFrame:
"""Open one image, run a macro file, return its Results table."""
ij.io().open(image_path)
# Clear any previous results before running
ij.macro.run("run(\"Clear Results\");")
# Run macro file (macro must operate on the active image)
ij.macro.runMacroFile(macro_file)
rt = ij.ResultsTable.getResultsTable()
if rt is None or rt.size() == 0:
return pd.DataFrame()
headings = list(rt.getHeadings())
return pd.DataFrame(
{col: [rt.getValue(col, i) for i in range(rt.size())]
for col in headings}
)
input_dir = Path("/data/images")
macro_file = "/scripts/measure_cells.ijm"
output_csv = "batch_results.csv"
all_results = []
image_files = sorted(input_dir.glob("*.tif"))
for img_path in image_files:
print(f"Processing: {img_path.name}")
df = run_macro_on_image(ij, str(img_path), macro_file)
if not df.empty:
df["filename"] = img_path.name
all_results.append(df)
# Close all windows to free memory between images
ij.macro.run("close('*');")
if all_results:
combined = pd.concat(all_results, ignore_index=True)
combined.to_csv(output_csv, index=False)
print(f"Batch complete: {len(image_files)} images, "
f"{len(combined)} total measurements → {output_csv}")
else:
print("No results collected from any image")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
mode |
Initialization | "headless" |
"headless", "gui", "interactive" |
Controls whether Fiji GUI window opens; use "headless" for scripts |
endpoint |
Initialization | "sc.fiji:fiji" |
Maven coordinate or /path/to/Fiji.app |
Selects ImageJ2 distribution; sc.fiji:fiji includes all Fiji plugins |
sigma (Gaussian blur) |
Macro / Ops | 2.0 |
0.5–10.0 |
Spatial smoothing radius in pixels; higher reduces noise but blurs edges |
minSize (Analyze Particles) |
Plugin / Macro | 0 |
pixels² or 0–Infinity |
Smallest object area to include; eliminates noise particles |
method (Auto Threshold) |
Macro / Ops | "Otsu" |
"Otsu", "Triangle", "MaxEntropy", "Huang", "Li" |
Threshold algorithm; Otsu works well for bimodal histograms |
measurements bitmask |
Results / Macro | varies | OR combination of AREA=1, MEAN=2, CENTROID=4, etc. |
Selects which columns appear in the Results table |
| Java heap size | Initialization (env) | JVM default (~25% RAM) | set via JAVA_TOOL_OPTIONS |
Limits memory for large stacks; set -Xmx8g for big images |
Best Practices
-
Initialize once per session and reuse
ij: Starting a new JVM is expensive (5–15 seconds). Createijat module level or pass it as a parameter rather than callingimagej.init()inside a loop.# At module top level — initialized once import imagej ij = imagej.init("sc.fiji:fiji", mode="headless") def process(path): imp = ij.io().open(path) # reuse ij ... -
Use conda for Java management: pip-only installs frequently fail because
JAVA_HOMEis not set or the wrong JDK version is onPATH. A conda environment withopenjdk=11avoids 90% of JVM-not-found errors. -
Clear Results and ROI Manager between images in batch loops: ImageJ accumulates results across calls in the same session. Always call
run("Clear Results")andrm.reset()before each image to prevent row contamination.ij.macro.run("run('Clear Results');") rm = get_roi_manager(ij) rm.reset() -
Prefer
ij.macro.run()for simple commands overij.command().run(): Macro strings are shorter, easier to read, and use the same syntax as the Fiji Macro Recorder. Useij.command().run()only when you need programmatic access to command outputs (module return values). -
Convert to NumPy as late as possible:
ij.py.from_imageplus()copies data from the JVM to Python. For multi-step processing inside ImageJ, keep data in ImagePlus or ImgLib2 form and convert only at the end to minimize memory-copy overhead. -
Use
ij.py.to_java()/ij.py.from_java()for ImgLib2 Ops: Theto_imageplus()/from_imageplus()pair works with the classic ImageProcessor;to_java()/from_java()target the modern ImgLib2 type system required byij.op(). -
Set a Fiji update site plugins list during init for reproducibility: Specify a pinned Fiji version (
sc.fiji:fiji:2.14.0) rather thansc.fiji:fiji(latest) so your pipeline behavior does not change when Fiji releases new plugin updates.
Common Recipes
Recipe: TrackMate Headless Spot Detection
When to use: Run TrackMate particle tracking programmatically and retrieve detected spots as a DataFrame without opening the TrackMate GUI.
import imagej
import pandas as pd
ij = imagej.init("sc.fiji:fiji", mode="headless")
# TrackMate headless via macro (scripting interface)
trackmate_macro = """
run("TrackMate", "");
// For fully scripted TrackMate, use the Scripting Interface
// documented at https://imagej.net/plugins/trackmate/scripting
"""
# Scripted TrackMate via Jython-style Java interop
def run_trackmate_headless(ij, imp, radius=3.0, threshold=100.0):
"""Detect spots with LoG detector; return DataFrame of spot coordinates."""
from jnius import autoclass
# Import TrackMate classes
Model = autoclass("fiji.plugin.trackmate.Model")
Settings = autoclass("fiji.plugin.trackmate.Settings")
TrackMate = autoclass("fiji.plugin.trackmate.TrackMate")
LogDetectorFactory = autoclass(
"fiji.plugin.trackmate.detection.LogDetectorFactory")
model = Model()
settings = Settings(imp)
# Configure LoG spot detector
settings.detectorFactory = LogDetectorFactory()
settings.detectorSettings = {
"DO_SUBPIXEL_LOCALIZATION": True,
"RADIUS": radius,
"TARGET_CHANNEL": 1,
"THRESHOLD": threshold,
"DO_MEDIAN_FILTERING": False,
}
tm = TrackMate(model, settings)
tm.process()
# Extract spot table
spots = model.getSpots()
spots.setVisible(True)
records = []
for spot in spots.iterable(True):
records.append({
"id": spot.ID(),
"x": spot.getDoublePosition(0),
"y": spot.getDoublePosition(1),
"z": spot.getDoublePosition(2),
"frame": spot.getFeature("FRAME"),
"quality": spot.getFeature("QUALITY"),
})
return pd.DataFrame(records)
imp = ij.io().open("/data/timelapse.tif")
df = run_trackmate_headless(ij, imp, radius=3.0, threshold=50.0)
print(f"Detected {len(df)} spots across {df['frame'].nunique()} frames")
df.to_csv("spots.csv", index=False)
print(df.head())
Recipe: Convert ImageJ Hyperstack to NumPy 5D Array (TZCYX)
When to use: Import a multi-dimensional Fiji hyperstack into Python as a 5D NumPy array with the standard TZCYX axis order used by most scientific image analysis libraries.
import imagej
import numpy as np
ij = imagej.init("sc.fiji:fiji", mode="headless")
def hyperstack_to_numpy(ij, imp) -> np.ndarray:
"""
Convert an ImageJ hyperstack to a NumPy array with shape (T, Z, C, Y, X).
ImageJ internal order is C-Z-T (slowest to fastest in stack index).
This function reorders to the TZCYX convention used by tifffile, OME, etc.
"""
nC = imp.getNChannels()
nZ = imp.getNSlices()
nT = imp.getNFrames()
H = imp.getHeight()
W = imp.getWidth()
arr = np.zeros((nT, nZ, nC, H, W), dtype=np.uint16)
for t in range(1, nT + 1):
for z in range(1, nZ + 1):
for c in range(1, nC + 1):
idx = imp.getStackIndex(c, z, t)
imp.setSlice(idx)
arr[t-1, z-1, c-1] = ij.py.from_imageplus(imp)
return arr
imp = ij.io().open("/data/4d_experiment.tif")
print(f"ImageJ dims (W,H,C,Z,T): {imp.getDimensions()}")
stack = hyperstack_to_numpy(ij, imp)
print(f"NumPy TZCYX shape: {stack.shape}") # e.g., (10, 15, 2, 512, 512)
print(f"dtype: {stack.dtype}, max: {stack.max()}")
# Save as OME-TIFF with correct axis metadata
import tifffile
tifffile.imwrite(
"output_TZCYX.ome.tif",
stack,
imagej=True,
metadata={"axes": "TZCYX"},
photometric="minisblack"
)
print("Saved output_TZCYX.ome.tif")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
JVMNotFoundException on init |
JAVA_HOME not set or wrong JDK version |
Install via conda: conda install -c conda-forge openjdk=11; avoid Java 17 |
RuntimeError: Fiji download failed |
No internet or corporate proxy | Download Fiji manually from fiji.sc, then use imagej.init("/path/to/Fiji.app") |
java.lang.OutOfMemoryError on large stacks |
JVM default heap is too small | Set export JAVA_TOOL_OPTIONS="-Xmx8g" before importing imagej |
Macro run(...) silently does nothing |
No active image when macro expects one | Call ij.io().open(path) before running processing macros; check ij.WindowManager.getImageCount() |
| Results table empty after Analyze Particles | Threshold not applied, or mask not binary | Verify mask is 8-bit binary (0/255) with imp.getType() == 0; run Convert to Mask before Analyze Particles |
AttributeError: 'NoneType' object on ij.ResultsTable.getResultsTable() |
No measurements run yet in this session | Confirm Analyze Particles macro completed; run ij.macro.run("print(nResults);") to check count |
Plugin class not found (ClassNotFoundException) |
Plugin not in this Fiji installation | Add the Fiji update site (e.g., TrackMate) or use sc.fiji:fiji endpoint which includes all default plugins |
gui mode crashes with HeadlessException |
No display available (SSH/cluster) | Use mode="headless" for remote environments; GUI mode requires DISPLAY or X11 forwarding |
Related Skills
- scikit-image-processing — pure Python image processing without JVM; use when Fiji plugins are not needed
- napari-image-viewer — interactive multi-dimensional image viewer for Python; complement to PyImageJ for visualization
- trackpy-particle-tracking — Python-native Crocker-Grier SPT; alternative to TrackMate for simple 2D tracking
- cellpose-cell-segmentation — deep learning cell segmentation; can be run standalone or as a Fiji plugin
- omero-integration — OMERO server image management; PyImageJ can process images retrieved via omero-py
References
- PyImageJ documentation — official API reference, initialization guide, data conversion
- PyImageJ GitHub repository — source, issue tracker, notebooks, examples
- ImageJ Ops framework — Ops namespace reference and algorithm catalog
- TrackMate scripting guide — headless TrackMate Java API patterns
- Bio-Formats supported formats — list of proprietary microscopy formats openable via
ij.io().open() - ImageJ Macro Language reference — built-in functions for
ij.macro.run()strings
skills/cell-biology/scikit-image-processing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scikit-image-processing -g -y
SKILL.md
Frontmatter
{
"name": "scikit-image-processing",
"license": "BSD-3-Clause",
"description": "Python image processing for microscopy and bioimage analysis. Read\/write images, filter (Gaussian, median, LoG), segment (thresholding, watershed, active contours), measure region properties, detect features. SciPy\/NumPy ecosystem. Use OpenCV for real-time video; CellPose for DL cell segmentation; napari for visualization."
}
scikit-image — Scientific Image Processing
Overview
scikit-image is a Python library for image processing in the SciPy ecosystem. It provides algorithms for reading/writing images, filtering (noise reduction, edge detection), geometric transforms, segmentation (thresholding, watershed, active contours), object measurement (area, intensity, shape descriptors), and feature detection. Images are represented as NumPy arrays, enabling seamless integration with NumPy, SciPy, matplotlib, and pandas. Widely used for fluorescence microscopy, histology, and general bioimage analysis.
When to Use
- Preprocessing fluorescence microscopy images: background subtraction, denoising, illumination correction
- Segmenting cells, nuclei, or organelles using thresholding or watershed
- Measuring object properties: area, perimeter, intensity statistics, shape descriptors
- Applying morphological operations: erosion, dilation, opening, closing, fill holes
- Detecting keypoints or local features in biological images
- Converting between image formats and color spaces
- Use
OpenCVinstead for real-time video processing or GPU-accelerated operations - For deep-learning cell segmentation, use
CellPoseinstead (better accuracy for touching cells) - Use
napariinstead for interactive multi-dimensional image visualization and annotation - For whole-slide image tiling, use
PathMLorhistolabinstead
Prerequisites
- Python packages:
scikit-image,numpy,scipy,matplotlib - Input requirements: Images as files (TIFF, PNG, JPEG) or NumPy arrays; fluorescence images as 2D/3D grayscale arrays
- Environment: Python 3.9+
pip install scikit-image numpy scipy matplotlib
# For reading proprietary microscopy formats
pip install tifffile aicsimageio
# Verify
python -c "import skimage; print(skimage.__version__)"
Quick Start
from skimage import io, filters, measure
import numpy as np
# Load → denoise → threshold → measure
img = io.imread("cells.tif")
img_smooth = filters.gaussian(img, sigma=1.5)
threshold = filters.threshold_otsu(img_smooth)
binary = img_smooth > threshold
regions = measure.regionprops(measure.label(binary))
print(f"Found {len(regions)} objects")
print(f"Mean area: {np.mean([r.area for r in regions]):.1f} px²")
Core API
Module 1: Image I/O and Data Types
from skimage import io, img_as_float, img_as_uint
import numpy as np
# Read single image
img = io.imread("nuclei.tif")
print(f"Shape: {img.shape}, dtype: {img.dtype}") # (512, 512), uint16
# Read image collection from directory
from skimage import io as ski_io
images = ski_io.ImageCollection("data/*.tif")
print(f"Loaded {len(images)} images")
# Type conversions (critical for correct arithmetic)
img_f = img_as_float(img) # uint16 → float64, range [0, 1]
img_u8 = (img_f * 255).astype(np.uint8) # → 8-bit
# Save image
io.imsave("output.tif", img_u8)
# Multi-channel fluorescence (TIFF with CZYX or ZCYX dims)
import tifffile
stack = tifffile.imread("multichannel.tif") # shape: (C, Z, Y, X)
dapi = stack[0] # DAPI channel
gfp = stack[1] # GFP channel
print(f"DAPI: {dapi.shape}, GFP: {gfp.shape}")
# Maximum intensity projection along Z
mip = dapi.max(axis=0)
io.imsave("dapi_mip.tif", mip)
Module 2: Filters and Preprocessing
from skimage import filters, restoration
import numpy as np
# Gaussian blur (denoising, smoothing)
from skimage.filters import gaussian
smoothed = gaussian(img, sigma=2.0)
# Median filter (salt-and-pepper noise removal)
from skimage.filters import median
from skimage.morphology import disk
denoised = median(img, footprint=disk(3))
# Top-hat transform (background subtraction for uneven illumination)
from skimage.morphology import white_tophat, disk
background_removed = white_tophat(img, footprint=disk(50))
print(f"Background removed: range [{background_removed.min()}, {background_removed.max()}]")
# Edge detection
from skimage.filters import sobel, laplace, prewitt
edges_sobel = sobel(img_as_float(img))
edges_laplace = laplace(img_as_float(img))
# Difference of Gaussians (blob-like structure detection)
from skimage.filters import difference_of_gaussians
blob_enhanced = difference_of_gaussians(img_as_float(img), low_sigma=1, high_sigma=3)
# Contrast enhancement (CLAHE: local histogram equalization)
from skimage.exposure import equalize_adapthist
enhanced = equalize_adapthist(img_as_float(img), clip_limit=0.03)
Module 3: Thresholding and Segmentation
from skimage import filters, morphology, segmentation
from skimage.color import label2rgb
import numpy as np
# Automatic thresholding methods
from skimage.filters import (threshold_otsu, threshold_li,
threshold_triangle, threshold_yen)
img_f = img_as_float(img)
print(f"Otsu: {threshold_otsu(img_f):.3f}")
print(f"Li: {threshold_li(img_f):.3f}")
# Apply threshold and clean binary mask
binary = img_f > threshold_otsu(img_f)
binary_clean = morphology.remove_small_objects(binary, min_size=50)
binary_filled = morphology.remove_small_holes(binary_clean, area_threshold=100)
# Watershed segmentation (separate touching objects)
from skimage.segmentation import watershed
from skimage.feature import peak_local_max
from scipy import ndimage as ndi
# Distance transform → local maxima → watershed
distance = ndi.distance_transform_edt(binary_filled)
coords = peak_local_max(distance, min_distance=20, labels=binary_filled)
mask = np.zeros(distance.shape, dtype=bool)
mask[tuple(coords.T)] = True
markers = ndi.label(mask)[0]
labels = watershed(-distance, markers, mask=binary_filled)
print(f"Segmented objects: {labels.max()}")
overlay = label2rgb(labels, image=img_f, bg_label=0)
Module 4: Morphological Operations
from skimage.morphology import (erosion, dilation, opening, closing,
disk, ball, binary_erosion, binary_dilation)
# Erosion and dilation
eroded = erosion(binary, footprint=disk(3))
dilated = dilation(binary, footprint=disk(5))
# Opening: erosion then dilation (removes small objects, smooths edges)
opened = opening(binary, footprint=disk(3))
# Closing: dilation then erosion (fills small holes)
closed = closing(binary, footprint=disk(5))
# Skeletonization
from skimage.morphology import skeletonize
skeleton = skeletonize(binary)
print(f"Skeleton pixels: {skeleton.sum()}")
Module 5: Measurement and Region Properties
from skimage import measure
import pandas as pd
# Label connected components
labeled = measure.label(binary_filled)
# Extract region properties
props = measure.regionprops(labeled, intensity_image=img_as_float(img))
# Convert to DataFrame
data = []
for r in props:
data.append({
"label": r.label,
"area": r.area,
"perimeter": r.perimeter,
"eccentricity": r.eccentricity,
"mean_intensity": r.mean_intensity,
"max_intensity": r.max_intensity,
"centroid_y": r.centroid[0],
"centroid_x": r.centroid[1],
"bbox": r.bbox,
})
df = pd.DataFrame(data)
print(f"Objects: {len(df)}")
print(df[["area", "mean_intensity", "eccentricity"]].describe().round(2))
# Filter by property thresholds
cells = df[(df["area"] > 100) & (df["area"] < 5000) & (df["eccentricity"] < 0.9)]
print(f"Valid cells: {len(cells)}")
# Measure co-localization: fraction of channel-1 signal in channel-2 positive mask
from skimage.measure import regionprops_table
import numpy as np
# For multi-channel images
table = regionprops_table(
labeled, intensity_image=np.stack([dapi, gfp], axis=-1),
properties=["label", "area", "mean_intensity"]
)
Module 6: Feature Detection and Transforms
from skimage.feature import blob_log, blob_dog, corner_harris, corner_peaks
from skimage import transform
# Laplacian of Gaussian blob detection (nuclei, puncta)
blobs = blob_log(img_as_float(img), min_sigma=5, max_sigma=20,
num_sigma=5, threshold=0.05)
print(f"Blobs detected: {len(blobs)}")
# blobs columns: [y, x, sigma] where radius = sqrt(2) * sigma
# Difference of Gaussians (faster alternative)
blobs_dog = blob_dog(img_as_float(img), min_sigma=5, max_sigma=20, threshold=0.02)
# Geometric transforms
from skimage import transform
# Rescale
img_small = transform.rescale(img_as_float(img), 0.5)
# Rotate
img_rotated = transform.rotate(img_as_float(img), angle=15, resize=True)
# Affine registration (align two images)
from skimage.registration import phase_cross_correlation
shift, error, _ = phase_cross_correlation(ref_img, moving_img)
print(f"Alignment shift: {shift} px, error: {error:.4f}")
Key Concepts
Image Arrays and Conventions
scikit-image represents images as NumPy arrays. Shape conventions:
| Image Type | Shape | dtype |
|---|---|---|
| Grayscale 2D | (H, W) |
uint8, uint16, float64 |
| RGB color | (H, W, 3) |
uint8 |
| Multichannel | (H, W, C) |
any |
| Z-stack | (Z, H, W) |
any |
dtype matters: Most algorithms expect float64 in [0, 1]. Use img_as_float(img) before processing; convert back with img_as_uint(img) for saving.
Common Workflows
Workflow 1: Fluorescence Cell Segmentation and Measurement
Goal: Segment DAPI-stained nuclei and measure GFP fluorescence per nucleus.
from skimage import io, filters, morphology, measure, img_as_float
from skimage.segmentation import watershed
from skimage.feature import peak_local_max
from scipy import ndimage as ndi
import pandas as pd
import numpy as np
import tifffile
# Load 2-channel image (DAPI=ch0, GFP=ch1)
img = tifffile.imread("cells.tif")
dapi = img_as_float(img[0])
gfp = img_as_float(img[1])
# Segment nuclei from DAPI channel
dapi_smooth = filters.gaussian(dapi, sigma=2)
threshold = filters.threshold_otsu(dapi_smooth)
binary = dapi_smooth > threshold
binary = morphology.remove_small_objects(binary, min_size=200)
binary = morphology.remove_small_holes(binary, area_threshold=500)
# Watershed to separate touching nuclei
distance = ndi.distance_transform_edt(binary)
coords = peak_local_max(distance, min_distance=30, labels=binary)
mask = np.zeros_like(distance, dtype=bool)
mask[tuple(coords.T)] = True
markers = ndi.label(mask)[0]
labels = watershed(-distance, markers, mask=binary)
# Measure GFP per nucleus
props = measure.regionprops(labels, intensity_image=gfp)
df = pd.DataFrame([{
"nucleus_id": p.label,
"area_px2": p.area,
"gfp_mean": p.mean_intensity,
"gfp_max": p.max_intensity,
} for p in props])
df.to_csv("nucleus_measurements.csv", index=False)
print(f"Nuclei: {len(df)}, mean GFP: {df['gfp_mean'].mean():.3f}")
Workflow 2: Batch Image Processing
Goal: Apply the same preprocessing and measurement pipeline to a folder of images.
from pathlib import Path
from skimage import io, filters, measure, img_as_float, morphology
import pandas as pd
results = []
for img_path in sorted(Path("data/").glob("*.tif")):
img = img_as_float(io.imread(img_path))
if img.ndim == 3:
img = img.mean(axis=-1) # convert RGB to grayscale
# Preprocess
smooth = filters.gaussian(img, sigma=1.5)
thresh = filters.threshold_otsu(smooth)
binary = morphology.remove_small_objects(smooth > thresh, min_size=50)
# Measure
labeled = measure.label(binary)
props = measure.regionprops(labeled, intensity_image=img)
for p in props:
results.append({
"image": img_path.stem,
"object_id": p.label,
"area": p.area,
"mean_intensity": p.mean_intensity,
})
df = pd.DataFrame(results)
df.to_csv("batch_results.csv", index=False)
print(f"Processed {df['image'].nunique()} images, {len(df)} objects total")
Key Parameters
| Function | Parameter | Default | Range/Options | Effect |
|---|---|---|---|---|
gaussian |
sigma |
1.0 | 0.5–10+ | Smoothing kernel size; larger = more blur |
median |
footprint |
disk(1) |
disk(1–10) |
Median filter neighborhood |
threshold_otsu |
— | — | — | Returns automatic threshold (no tuning) |
remove_small_objects |
min_size |
64 | any integer | Remove objects smaller than N pixels |
watershed |
— | — | — | Uses marker positions from peak_local_max |
peak_local_max |
min_distance |
1 | 5–50 | Min separation between detected peaks (px) |
blob_log |
min_sigma / max_sigma |
1/50 | dependent | Expected blob radius range |
regionprops |
intensity_image |
None | array | Image for intensity measurements |
Best Practices
-
Always check dtype before processing: Operations like subtraction on
uint8silently clip to 0. Convert to float:img = img_as_float(img)as the first step. -
Visualize intermediate results: For every segmentation pipeline, plot the binary mask overlaid on the original before measuring. Silent segmentation errors are the most common failure mode.
-
Tune thresholds on representative samples: Otsu works well for bimodal histograms. For difficult images, compare Otsu/Li/Triangle with
try_all_threshold(img)from skimage. -
Use watershed for touching objects: Simple thresholding cannot separate touching nuclei. Always apply watershed with distance transform markers for densely packed cells.
-
Measure in physical units: Convert pixel measurements to microns using the pixel size from microscope metadata:
area_um2 = area_px * pixel_size_um**2. -
Validate with known samples: Before batch processing, verify the pipeline on 3–5 images with manually counted objects. Spot-check object counts against expectations.
Common Recipes
Recipe: Measure Spot Intensity in Fluorescence Images
from skimage import io, filters, measure, img_as_float
from skimage.feature import blob_log
import numpy as np
img = img_as_float(io.imread("spots.tif"))
blobs = blob_log(img, min_sigma=2, max_sigma=8, num_sigma=5, threshold=0.05)
intensities = []
for y, x, sigma in blobs:
r = int(np.ceil(np.sqrt(2) * sigma))
region = img[max(0,int(y-r)):int(y+r), max(0,int(x-r)):int(x+r)]
intensities.append({"y": y, "x": x, "radius": r, "mean_intensity": region.mean()})
import pandas as pd
df = pd.DataFrame(intensities)
print(f"Spots: {len(df)}, mean intensity: {df['mean_intensity'].mean():.4f}")
Recipe: Binary Mask from Multiple Channels
from skimage import filters, morphology, img_as_float
import numpy as np
# Segment objects positive in BOTH channels
ch1 = img_as_float(dapi)
ch2 = img_as_float(gfp)
mask1 = ch1 > filters.threshold_otsu(ch1)
mask2 = ch2 > filters.threshold_otsu(ch2)
combined_mask = mask1 & mask2 # objects in both channels
combined_mask = morphology.binary_closing(combined_mask)
print(f"Co-positive pixels: {combined_mask.sum()}")
Recipe: Save Labeled Overlay as Publication Figure
from skimage.color import label2rgb
from skimage import io, img_as_ubyte
import matplotlib.pyplot as plt
overlay = label2rgb(labels, image=img_as_float(dapi), bg_label=0, alpha=0.5)
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
axes[0].imshow(dapi, cmap="gray")
axes[0].set_title(f"DAPI (raw)")
axes[1].imshow(overlay)
axes[1].set_title(f"Segmented ({labels.max()} nuclei)")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.savefig("segmentation_overlay.png", dpi=300, bbox_inches="tight")
print("Saved segmentation_overlay.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
OverflowError in arithmetic |
Integer overflow from uint8/uint16 ops | Convert to float first: img = img_as_float(img) |
| Otsu threshold finds one class | Bimodal distribution absent | Try threshold_li() or try_all_threshold(img) for comparison |
| Watershed over-segments | Markers too close together | Increase min_distance in peak_local_max; smooth distance map |
| All objects merged in binary | Threshold too low | Check plt.hist(img.ravel()) for histogram; manually adjust |
regionprops missing intensity stats |
intensity_image not provided |
Pass: regionprops(labels, intensity_image=img) |
| 3D images processed as 2D | Z-stack not detected | Check shape: img.shape; process per slice or use 3D functions |
| Tiny noise objects in binary | Threshold too aggressive | Apply morphology.remove_small_objects(binary, min_size=50) |
Related Skills
- pathml — whole-slide image processing using scikit-image under the hood
- matplotlib-scientific-plotting — visualizing segmentation results and measurement distributions
- histolab-wsi-processing — scikit-image-compatible preprocessing for H&E WSI tiles
References
- scikit-image documentation — API reference and tutorials
- GitHub: scikit-image/scikit-image — source code and examples
- van der Walt et al. (2014) "scikit-image: image processing in Python" — PeerJ 2:e453
- scikit-image examples gallery — annotated code examples by category
skills/cell-biology/trackpy-particle-tracking/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill trackpy-particle-tracking -g -y
SKILL.md
Frontmatter
{
"name": "trackpy-particle-tracking",
"license": "BSD-3-Clause",
"description": "Python library for single-particle tracking (SPT) in video microscopy via the Crocker-Grier algorithm. Locate particles (fluorescent spots, colloids, vesicles, cells) per frame, link into trajectories, filter short tracks, and compute MSD for diffusion analysis. 2D\/3D with subpixel accuracy; reads TIF stacks, AVI, image series via pims. Use for quantitative SPT and diffusion coefficient extraction from fluorescence or brightfield video."
}
trackpy
Overview
trackpy is a Python library for single-particle tracking (SPT) in video microscopy. It implements the Crocker-Grier algorithm to locate bright spots in each frame with subpixel precision, then links those positions across frames into continuous trajectories. From trajectories, trackpy computes mean squared displacement (MSD), diffusion coefficients, and motion classifications (confined, normal, directed). It handles 2D fluorescence videos, 3D confocal z-stacks, and large image sequences via memory-efficient streaming through the pims image reader library.
When to Use
- You have a fluorescence microscopy video of labeled particles (quantum dots, fluorescent beads, vesicles, receptors) and need to extract individual trajectories and diffusion coefficients.
- You want to measure particle mobility: compute MSD curves and distinguish Brownian diffusion, directed motion, or confined motion from single-particle tracks.
- You are analyzing colloid dynamics, lipid membrane diffusion, intracellular cargo transport, or virus-cell interactions where you need per-particle trajectory data.
- You need 3D tracking from confocal z-stack time series to capture out-of-plane motion of particles or organelles.
- You want to apply drift correction to remove stage drift before computing intrinsic particle motion statistics.
- You need ensemble MSD averaged across hundreds of tracks to extract population-level diffusion behavior with statistical power.
- Use
TrackMate(Fiji/ImageJ plugin) instead when you need a graphical interface, manual curation of tracks, or integration with biological object segmenters (Cellpose, StarDist). - Use
napariwithnapari-trackpyinstead when you want interactive visualization and manual editing of trajectories alongside image data.
Prerequisites
- Python packages:
trackpy,pims,pandas,numpy,matplotlib,scipy - Data requirements: Grayscale or single-channel image sequence (TIF stack, AVI, or directory of PNG/TIF frames); particles should appear as bright Gaussian spots on a darker background (or use
invert=Truefor dark spots on bright background) - Environment: Works in Jupyter notebooks and scripts;
pimshandles most microscopy formats; for ND2 or CZI files installpims-nd2oraicsimageio
pip install trackpy pims pandas numpy matplotlib scipy
# For reading multi-channel or proprietary formats:
pip install pims[bioformats] # Bioformats via JPype
pip install aicsimageio # ND2, CZI, LIF via AICSImageIO
Quick Start
import trackpy as tp
import pims
# Load a TIF image stack (T frames × Y × X)
frames = pims.open("particles.tif") # shape: (T, Y, X)
# Locate particles in all frames
f = tp.batch(frames, diameter=11, minmass=500)
print(f"Found {len(f)} particle detections across {f['frame'].nunique()} frames")
# Link into trajectories
t = tp.link(f, search_range=5, memory=3)
# Remove short-lived tracks (fewer than 10 frames)
t = tp.filter_stubs(t, threshold=10)
print(f"Retained {t['particle'].nunique()} trajectories")
# Compute ensemble MSD
imsd = tp.imsd(t, mpp=0.16, fps=10) # mpp: microns per pixel, fps: frames per second
print(imsd.head())
Core API
Module 1: tp.locate() — Single-Frame Particle Detection
tp.locate() finds bright circular features in one image frame using a bandpass filter followed by local maximum detection. It returns a DataFrame with subpixel x/y positions, integrated mass, signal, and eccentricity for each detected particle.
import trackpy as tp
import pims
import matplotlib.pyplot as plt
frames = pims.open("particles.tif")
frame0 = frames[0] # single 2D array
# Locate particles: diameter must be odd integer, roughly matching spot size in pixels
f0 = tp.locate(frame0, diameter=11, minmass=300, maxsize=None, separation=None)
print(f"Detected {len(f0)} particles in frame 0")
print(f0[['x', 'y', 'mass', 'size', 'ecc']].head())
# x, y: subpixel centroid; mass: integrated brightness; size: Gaussian width; ecc: eccentricity (0=circular)
# Diagnostic plot: annotate detected particles on the raw frame
fig, ax = plt.subplots(figsize=(8, 8))
tp.annotate(f0, frame0, ax=ax, imshow_style={"cmap": "gray"})
ax.set_title(f"Frame 0: {len(f0)} particles detected")
plt.tight_layout()
plt.savefig("locate_diagnostic.png", dpi=150)
print("Saved locate_diagnostic.png")
Module 2: tp.batch() — Multi-Frame Detection
tp.batch() applies tp.locate() to every frame in an image sequence and concatenates results into a single DataFrame with a frame column. It accepts any pims-compatible image reader or a list of 2D arrays.
import trackpy as tp
import pims
frames = pims.open("particles.tif")
# Locate particles across all frames (same parameters as tp.locate)
f = tp.batch(frames, diameter=11, minmass=300, processes=1)
# processes=1 uses serial processing; set processes="auto" for multicore (requires joblib)
print(f"Total detections: {len(f)}")
print(f"Frames with data: {f['frame'].nunique()} / {len(frames)}")
print(f"Mean particles per frame: {len(f)/f['frame'].nunique():.1f}")
print(f.groupby('frame').size().describe())
# Mass histogram: use to choose minmass cutoff
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 4))
f['mass'].hist(bins=40, ax=ax)
ax.axvline(300, color='red', linestyle='--', label='minmass=300')
ax.set_xlabel("Integrated mass")
ax.set_ylabel("Count")
ax.set_title("Mass distribution of detections")
ax.legend()
plt.tight_layout()
plt.savefig("mass_histogram.png", dpi=150)
print("Saved mass_histogram.png — use to refine minmass cutoff")
Module 3: tp.link() — Trajectory Linking
tp.link() connects particle detections across frames into trajectories by solving a bipartite assignment problem (Hungarian algorithm). It adds a particle column (integer trajectory ID) to the positions DataFrame. search_range (pixels) is the maximum displacement between frames; memory allows a particle to disappear for up to N frames before being dropped.
import trackpy as tp
import pims
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=300)
# Link: search_range in pixels; memory handles brief disappearances (blinking, out-of-focus)
t = tp.link(f, search_range=5, memory=3)
print(f"Number of unique trajectories: {t['particle'].nunique()}")
print(f"Trajectory length distribution:")
print(t.groupby('particle').size().describe())
# Visualize all trajectories overlaid on the first frame
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 8))
tp.plot_traj(t, superimpose=frames[0], ax=ax)
ax.set_title(f"{t['particle'].nunique()} trajectories")
plt.tight_layout()
plt.savefig("trajectories.png", dpi=150)
print("Saved trajectories.png")
Module 4: tp.filter_stubs() — Short-Track Removal
tp.filter_stubs() removes trajectories shorter than a given number of frames. Short tracks arise from noise detections, particles entering/leaving the field of view, or linking errors. Removing them improves MSD reliability because short tracks contribute high-variance MSD estimates at long lag times.
import trackpy as tp
import pims
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=300)
t = tp.link(f, search_range=5, memory=3)
before = t['particle'].nunique()
t_filt = tp.filter_stubs(t, threshold=10) # keep only tracks with ≥10 frames
after = t_filt['particle'].nunique()
print(f"Tracks before filtering: {before}")
print(f"Tracks after filtering (≥10 frames): {after}")
print(f"Removed {before - after} short tracks ({100*(before-after)/before:.1f}%)")
Module 5: MSD Analysis — tp.imsd() and tp.emsd()
tp.imsd() computes per-particle mean squared displacement as a function of lag time, returning a DataFrame (lag time as index, particle ID as columns). tp.emsd() computes the ensemble-averaged MSD across all particles. Both require the physical scale (mpp, microns per pixel) and frame rate (fps).
import trackpy as tp
import pims
import matplotlib.pyplot as plt
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=300)
t = tp.link(f, search_range=5, memory=3)
t = tp.filter_stubs(t, threshold=10)
mpp = 0.16 # microns per pixel (from microscope calibration)
fps = 10.0 # frames per second
# Individual MSD curves (one column per particle)
imsd = tp.imsd(t, mpp=mpp, fps=fps, max_lagtime=100)
print(f"IMSD shape: {imsd.shape}") # (lag times) × (particles)
# Ensemble MSD
emsd = tp.emsd(t, mpp=mpp, fps=fps, max_lagtime=100)
print(f"EMSD at lag 1 s: {emsd.iloc[0]:.4f} µm²")
# Plot ensemble MSD and fit diffusion coefficient
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import linregress
mpp = 0.16
fps = 10.0
# Fit MSD = 4*D*t (2D Brownian) over first 10 lag times
lag_s = emsd.index.values[:10] # lag times in seconds
msd_vals = emsd.values[:10]
slope, intercept, r, p, se = linregress(lag_s, msd_vals)
D = slope / 4 # diffusion coefficient in µm²/s
print(f"Diffusion coefficient D = {D:.4f} µm²/s (R²={r**2:.3f})")
fig, ax = plt.subplots(figsize=(6, 5))
ax.plot(emsd.index, emsd.values, 'o-', label='Ensemble MSD')
ax.plot(lag_s, slope * lag_s + intercept, 'r--', label=f'Fit: D={D:.4f} µm²/s')
ax.set_xlabel("Lag time (s)")
ax.set_ylabel("MSD (µm²)")
ax.set_title("Ensemble Mean Squared Displacement")
ax.legend()
plt.tight_layout()
plt.savefig("emsd.png", dpi=150)
print("Saved emsd.png")
Module 6: Motion Analysis — Characterize and Drift Correction
tp.motion.characterize() computes per-trajectory statistics (mean velocity, net displacement, straightness). tp.subtract_drift() removes bulk stage drift from trajectories before MSD analysis.
import trackpy as tp
import pims
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=300)
t = tp.link(f, search_range=5, memory=3)
t = tp.filter_stubs(t, threshold=10)
# Estimate and subtract drift (bulk movement of the sample/stage)
drift = tp.compute_drift(t)
print("Drift (first 5 frames):")
print(drift.head())
t_corrected = tp.subtract_drift(t.copy(), drift)
print(f"Drift subtracted from {t_corrected['particle'].nunique()} trajectories")
import trackpy as tp
# Characterize individual trajectories (requires tp.motion module)
from trackpy import motion
# Per-particle summary statistics
char = motion.characterize(t, mpp=0.16, fps=10.0)
print(char.columns.tolist())
# Columns: 'alpha' (anomalous exponent), 'D_app' (apparent diffusion), 'r^2' (fit quality)
print(char[['alpha', 'D_app']].describe())
# alpha ~ 1.0: Brownian; alpha < 1: confined/subdiffusion; alpha > 1: directed/superdiffusion
Common Workflows
Workflow 1: Full 2D Tracking Pipeline with MSD and Diffusion Coefficient
Goal: Load a fluorescence video, locate and link particles across all frames, filter short tracks, compute MSD, and extract diffusion coefficients.
import trackpy as tp
import pims
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import linregress
# ── 1. Load image sequence ──────────────────────────────────────────────────
frames = pims.open("fluorescence_video.tif") # (T, Y, X) grayscale TIF stack
print(f"Loaded {len(frames)} frames, frame shape: {frames.frame_shape}")
# ── 2. Tune detection on a single frame ─────────────────────────────────────
f0 = tp.locate(frames[0], diameter=11, minmass=400)
print(f"Frame 0: {len(f0)} particles detected")
# Adjust diameter (odd integer ≥ spot size) and minmass until count looks right
# ── 3. Batch detect across all frames ───────────────────────────────────────
f = tp.batch(frames, diameter=11, minmass=400, processes=1)
print(f"Total detections: {len(f)} across {f['frame'].nunique()} frames")
# ── 4. Link into trajectories ────────────────────────────────────────────────
t = tp.link(f, search_range=6, memory=3)
print(f"Unique trajectories before filtering: {t['particle'].nunique()}")
# ── 5. Remove short trajectories ─────────────────────────────────────────────
t = tp.filter_stubs(t, threshold=15)
print(f"Trajectories after filtering (≥15 frames): {t['particle'].nunique()}")
# ── 6. Subtract stage drift ───────────────────────────────────────────────────
drift = tp.compute_drift(t)
t = tp.subtract_drift(t.copy(), drift)
# ── 7. Compute MSD ────────────────────────────────────────────────────────────
mpp = 0.16 # µm/pixel — from microscope calibration
fps = 10.0 # frames per second
emsd = tp.emsd(t, mpp=mpp, fps=fps, max_lagtime=50)
imsd = tp.imsd(t, mpp=mpp, fps=fps, max_lagtime=50)
# ── 8. Fit diffusion coefficient from linear regime (first 10 points) ─────────
n_fit = 10
lag_s = emsd.index.values[:n_fit]
msd_v = emsd.values[:n_fit]
slope, intercept, r, _, _ = linregress(lag_s, msd_v)
D = slope / 4 # MSD = 4Dt for 2D Brownian
print(f"Diffusion coefficient D = {D:.4f} µm²/s (R²={r**2:.3f})")
# ── 9. Plot ────────────────────────────────────────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Individual + ensemble MSD
axes[0].plot(imsd.index, imsd.values, alpha=0.2, color='steelblue', linewidth=0.8)
axes[0].plot(emsd.index, emsd.values, 'k-', linewidth=2, label='Ensemble MSD')
axes[0].plot(lag_s, slope * lag_s + intercept, 'r--', label=f'D={D:.4f} µm²/s')
axes[0].set_xlabel("Lag time (s)")
axes[0].set_ylabel("MSD (µm²)")
axes[0].set_title("MSD: individual (blue) + ensemble (black)")
axes[0].legend()
# Trajectory overlay
tp.plot_traj(t, superimpose=frames[0], ax=axes[1])
axes[1].set_title(f"{t['particle'].nunique()} trajectories")
plt.tight_layout()
plt.savefig("tracking_results.png", dpi=150, bbox_inches="tight")
print("Saved tracking_results.png")
# ── 10. Export trajectories ───────────────────────────────────────────────────
t.to_csv("trajectories.csv", index=False)
emsd.to_csv("ensemble_msd.csv")
print("Exported trajectories.csv and ensemble_msd.csv")
Workflow 2: 3D Particle Tracking from Confocal Z-Stacks
Goal: Track particles in 3D from a time-series of confocal z-stacks (T × Z × Y × X), link in 3D, and compute 3D MSD.
import trackpy as tp
import pims
import numpy as np
import matplotlib.pyplot as plt
# ── 1. Load 4D image stack (T × Z × Y × X) ─────────────────────────────────
# pims opens multi-page TIF; reshape into (T, Z, Y, X) as needed
raw = pims.open("confocal_3d_timeseries.tif")
# Assume each "frame" in pims is one Z-slice; reshape to (T, Z, Y, X)
T, n_z = 50, 20 # adjust to match acquisition
frames_4d = np.array(raw).reshape(T, n_z, raw.frame_shape[0], raw.frame_shape[1])
print(f"4D stack shape: {frames_4d.shape}") # (T, Z, Y, X)
# ── 2. Detect in 3D (tp.locate works on 3D arrays) ─────────────────────────
# For 3D, pass a single 3D volume; diameter can be (z_diam, y_diam, x_diam)
f0_3d = tp.locate(frames_4d[0], diameter=(7, 11, 11), minmass=2000)
print(f"3D detections in t=0: {len(f0_3d)}")
print(f0_3d[['x', 'y', 'z', 'mass']].head())
# ── 3. Batch detect across all time points ──────────────────────────────────
detections = []
for t_idx in range(T):
frame_3d = frames_4d[t_idx] # Z × Y × X
detected = tp.locate(frame_3d, diameter=(7, 11, 11), minmass=2000)
detected['frame'] = t_idx
detections.append(detected)
import pandas as pd
f3d = pd.concat(detections, ignore_index=True)
print(f"Total 3D detections: {len(f3d)}")
# ── 4. Link in 3D ────────────────────────────────────────────────────────────
# search_range in pixels; use a 3-tuple (z, y, x) for anisotropic voxels
t3d = tp.link(f3d, search_range=(3, 6, 6), memory=2)
t3d = tp.filter_stubs(t3d, threshold=10)
print(f"3D trajectories: {t3d['particle'].nunique()}")
# ── 5. Compute 3D MSD ────────────────────────────────────────────────────────
mpp_xy = 0.16 # µm/pixel in x, y
mpp_z = 0.30 # µm/pixel in z (z-step size)
fps = 1.0 # z-stack volume rate
# Scale z coordinates to µm
t3d_um = t3d.copy()
t3d_um['x'] *= mpp_xy
t3d_um['y'] *= mpp_xy
t3d_um['z'] *= mpp_z
emsd_3d = tp.emsd(t3d_um, mpp=1.0, fps=fps, max_lagtime=20) # mpp=1 since already in µm
print(f"3D ensemble MSD (lag=1 s): {emsd_3d.iloc[0]:.4f} µm²")
# ── 6. Fit 3D diffusion coefficient (MSD = 6Dt for 3D) ─────────────────────
from scipy.stats import linregress
lag_s = emsd_3d.index.values[:8]
slope, _, r, _, _ = linregress(lag_s, emsd_3d.values[:8])
D_3d = slope / 6
print(f"3D Diffusion coefficient D = {D_3d:.4f} µm²/s (R²={r**2:.3f})")
t3d.to_csv("trajectories_3d.csv", index=False)
print("Saved trajectories_3d.csv")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
diameter |
locate, batch |
required | odd integer ≥ 3 (or tuple for 3D) | Approximate particle diameter in pixels; must be odd. Too small: split detections. Too large: merged detections |
minmass |
locate, batch |
100 |
0 to ∞ |
Minimum integrated brightness; primary filter against noise. Start at 0, plot mass histogram, set to separate noise peak |
search_range |
link |
required | 1–50 pixels |
Max displacement between frames. Set to ~1.5× max expected per-frame movement |
memory |
link |
0 |
0–10 frames |
Frames a particle may be absent before track is broken; useful for blinking fluorophores |
threshold |
filter_stubs |
1 |
integer ≥ 1 | Minimum track length in frames; short tracks have unreliable MSD |
max_lagtime |
imsd, emsd |
100 |
integer | Maximum lag time in frames for MSD calculation; use ~10–20% of total frames for reliability |
mpp |
imsd, emsd |
1 |
float > 0 | Microns per pixel; converts pixel units to physical units (µm) |
fps |
imsd, emsd |
1 |
float > 0 | Frames per second; converts frame lag to seconds |
separation |
locate, batch |
diameter+1 |
integer | Minimum center-to-center distance between features; prevents double-counting dense particles |
invert |
locate, batch |
False |
True, False |
Set True for dark particles on bright background (transmitted light imaging) |
Best Practices
-
Always tune
diameterandminmasson a single frame first: Runtp.locate()on one representative frame and usetp.annotate()to visually check detections before committing totp.batch(). Over-detection wastes time; under-detection misses particles.f0 = tp.locate(frames[0], diameter=11, minmass=200) tp.annotate(f0, frames[0]) # visual check in Jupyter -
Set
search_rangeconservatively: Too large a search range causes spurious links between unrelated particles in dense samples. Estimate typical per-frame displacement fromtp.locate()output scatter before linking. -
Subtract drift before computing MSD: Stage drift inflates MSD, causing overestimation of D. Always call
tp.compute_drift()+tp.subtract_drift()beforetp.emsd(). -
Use only the linear regime for diffusion coefficient fitting: MSD curves become noisy at long lag times (few track pairs contribute). Fit only the first 10–20% of available lag times. Use log-log slope to detect non-Brownian behavior before fitting.
-
Do not mix
mppunits between locate and MSD steps:tp.locate()returns positions in pixels.mppis applied only intp.imsd()/tp.emsd(). Avoid rescaling positions manually before linking, as this breaks the pixel-unit search_range. -
For 3D tracking with anisotropic voxels: Pass
diameterandsearch_rangeas tuples matching(z, y, x)axis order. The z-step is usually 2-5× coarser than xy pixel size; set the z component ofdiameterandsearch_rangeaccordingly.
Common Recipes
Recipe: Drift Correction and Corrected MSD Comparison
When to use: Compare raw vs drift-corrected MSD to assess stage drift contribution.
import trackpy as tp
import pims
import matplotlib.pyplot as plt
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=400, processes=1)
t = tp.link(f, search_range=6, memory=3)
t = tp.filter_stubs(t, threshold=15)
mpp, fps = 0.16, 10.0
# MSD without drift correction
emsd_raw = tp.emsd(t, mpp=mpp, fps=fps, max_lagtime=50)
# Subtract drift
drift = tp.compute_drift(t)
t_corr = tp.subtract_drift(t.copy(), drift)
emsd_corr = tp.emsd(t_corr, mpp=mpp, fps=fps, max_lagtime=50)
fig, ax = plt.subplots(figsize=(6, 5))
ax.loglog(emsd_raw.index, emsd_raw.values, 'r--', label='Raw MSD')
ax.loglog(emsd_corr.index, emsd_corr.values, 'b-', label='Drift-corrected MSD')
ax.set_xlabel("Lag time (s)")
ax.set_ylabel("MSD (µm²)")
ax.set_title("Effect of drift correction on MSD")
ax.legend()
plt.tight_layout()
plt.savefig("drift_correction_comparison.png", dpi=150)
print("Saved drift_correction_comparison.png")
Recipe: Classify Particles by Diffusion Regime
When to use: Separate particle population into confined, normal (Brownian), and directed motion based on log-log MSD slope (anomalous exponent alpha).
import trackpy as tp
import pims
import numpy as np
import pandas as pd
from scipy.stats import linregress
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=400, processes=1)
t = tp.link(f, search_range=6, memory=3)
t = tp.filter_stubs(t, threshold=20)
drift = tp.compute_drift(t)
t = tp.subtract_drift(t.copy(), drift)
mpp, fps = 0.16, 10.0
imsd = tp.imsd(t, mpp=mpp, fps=fps, max_lagtime=30)
# Fit log-log slope (anomalous exponent alpha) for each particle
results = []
for pid in imsd.columns:
curve = imsd[pid].dropna()
if len(curve) < 5:
continue
log_lag = np.log(curve.index.values)
log_msd = np.log(curve.values)
slope, intercept, r, _, _ = linregress(log_lag[:10], log_msd[:10])
D_app = np.exp(intercept) / 4 # apparent D from intercept
results.append({'particle': pid, 'alpha': slope, 'D_app': D_app, 'r2': r**2})
df_char = pd.DataFrame(results)
# Classify by alpha
df_char['regime'] = pd.cut(
df_char['alpha'],
bins=[-np.inf, 0.7, 1.3, np.inf],
labels=['confined', 'brownian', 'directed']
)
print(df_char['regime'].value_counts())
print(f"\nMean D by regime:\n{df_char.groupby('regime')['D_app'].mean()}")
df_char.to_csv("particle_classification.csv", index=False)
print("Saved particle_classification.csv")
Recipe: Filter by Eccentricity to Remove Aggregates
When to use: Exclude non-circular detections (doublets, aggregates, debris) that pass the mass threshold but are elongated.
import trackpy as tp
import pims
frames = pims.open("particles.tif")
f = tp.batch(frames, diameter=11, minmass=400, processes=1)
# Eccentricity: 0 = perfect circle, 1 = line
# Remove elongated features (likely aggregates or debris)
f_round = f[f['ecc'] < 0.3]
print(f"Before ecc filter: {len(f)} detections")
print(f"After ecc filter (ecc<0.3): {len(f_round)} detections")
print(f"Removed: {len(f)-len(f_round)} elongated features")
t = tp.link(f_round, search_range=6, memory=3)
t = tp.filter_stubs(t, threshold=10)
print(f"Trajectories after eccentricity filtering: {t['particle'].nunique()}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Too many spurious detections | minmass too low or diameter mismatched to spot size |
Plot mass histogram; raise minmass to the valley between noise and signal peaks. Verify diameter matches actual spot width in pixels |
| Few or zero detections | minmass too high, or particles are dim / out of focus |
Lower minmass; check image contrast; apply background subtraction before locate |
| Very short trajectories (all stubs filtered out) | search_range too small for particle velocity, or memory=0 with blinking |
Increase search_range to 1.5–2× max per-frame displacement; set memory=2 or 3 for blinking dyes |
| MSD curves are noisy or non-monotonic at long lag times | Too few tracks or fitting too many lag points | Use only first 10–20% of lag times for fitting; ensure at least 50+ trajectories for ensemble MSD |
| Drift correction makes MSD worse | Too few immobile reference particles; drift estimated from mobile particles | Include fiducial beads or immobile particles; use tp.compute_drift() only on particles known to be immobile |
MemoryError during tp.batch() |
All frames loaded into RAM at once | Use pims lazy reader (default); set processes=1; process frames in chunks using a loop over tp.locate() |
3D locate returns 2D positions only |
Passed a 2D frame instead of a 3D volume | Confirm input array has 3 dimensions (Z, Y, X); check frames_4d[t_idx].ndim == 3 |
| Linked trajectories fragment into many short segments | Particles moving faster than search_range between frames |
Increase search_range; increase memory; consider sub-sampling frames if frame rate is very high |
References
- trackpy documentation — official API reference, tutorials, and notebooks
- trackpy GitHub repository — source code, issue tracker, example notebooks
- Crocker, J.C. & Grier, D.G. (1996). Methods of Digital Video Microscopy for Colloidal Studies. J. Colloid Interface Sci. 179, 298–310 — original algorithm paper
- pims documentation — image sequence reader library that integrates with trackpy
- trackpy walkthrough notebook — step-by-step tutorial for 2D tracking
skills/data-visualization/matplotlib-scientific-plotting/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill matplotlib-scientific-plotting -g -y
SKILL.md
Frontmatter
{
"name": "matplotlib-scientific-plotting",
"license": "PSF-based",
"description": "Low-level Python plotting for scientific figures: publication-quality line, scatter, bar, heatmap, contour, 3D; multi-panel layouts; fine control of every element. PNG\/PDF\/SVG export. Use seaborn for quick stats, plotly for interactive."
}
matplotlib
Overview
Matplotlib is Python's foundational visualization library for creating static, animated, and interactive plots. It provides both a MATLAB-style pyplot interface and an object-oriented API for full control over figures, axes, and artists. Essential for generating publication-quality scientific figures.
When to Use
- Creating publication-quality plots with precise control over every element (fonts, ticks, colors, spacing)
- Building multi-panel figures with complex subplot layouts for papers
- Generating standard scientific plot types: line, scatter, bar, histogram, heatmap, box, violin, contour
- Exporting figures to vector formats (PDF, SVG) for journal submission
- Creating 3D surface, scatter, or wireframe plots
- Customizing colormaps and color schemes for accessibility (colorblind-friendly)
- Integrating plots with NumPy arrays and pandas DataFrames
- For quick statistical visualizations (distributions, regressions), use
seaborninstead - For interactive/web-based plots with hover and zoom, use
plotlyinstead
Prerequisites
- Python packages:
matplotlib,numpy - Optional:
pandas(for DataFrame plotting),seaborn(for style presets) - Environment: Works in scripts, Jupyter notebooks (
%matplotlib inline), and GUI apps
pip install matplotlib numpy
Quick Start
import matplotlib.pyplot as plt
import numpy as np
# Publication-ready figure template: set size, plot, label, save as PDF
fig, ax = plt.subplots(figsize=(6, 4)) # single-column journal width ≈ 6 cm → set here in inches
x = np.linspace(0, 2 * np.pi, 200)
ax.plot(x, np.sin(x), color="steelblue", lw=1.5, label="sin(x)")
ax.plot(x, np.cos(x), color="coral", lw=1.5, label="cos(x)", linestyle="--")
ax.set_xlabel("x (radians)")
ax.set_ylabel("Amplitude")
ax.set_title("Sine and Cosine Waves")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False) # clean axis style
plt.tight_layout()
plt.savefig("quickstart.pdf", bbox_inches="tight", dpi=300)
print("Saved quickstart.pdf")
Core API
Module 1: Figure and Axes Creation
The fundamental objects: Figure (canvas) and Axes (plotting area).
import matplotlib.pyplot as plt
import numpy as np
# Single plot (recommended: OO interface)
fig, ax = plt.subplots(figsize=(8, 5))
x = np.linspace(0, 2 * np.pi, 100)
ax.plot(x, np.sin(x), label="sin(x)")
ax.plot(x, np.cos(x), label="cos(x)")
ax.set_xlabel("x"); ax.set_ylabel("y")
ax.set_title("Trigonometric Functions")
ax.legend(); ax.grid(True, alpha=0.3)
plt.savefig("basic_plot.png", dpi=300, bbox_inches="tight")
print("Saved basic_plot.png")
# Multi-panel subplots
fig, axes = plt.subplots(2, 2, figsize=(10, 8), constrained_layout=True)
axes[0, 0].plot(x, np.sin(x)); axes[0, 0].set_title("sin(x)")
axes[0, 1].scatter(x[::5], np.cos(x[::5])); axes[0, 1].set_title("cos(x)")
axes[1, 0].bar(["A", "B", "C"], [3, 7, 5]); axes[1, 0].set_title("Bar")
axes[1, 1].hist(np.random.randn(500), bins=30); axes[1, 1].set_title("Histogram")
plt.savefig("subplots.png", dpi=300, bbox_inches="tight")
print("Saved subplots.png with 4 panels")
Module 2: Plot Types
Standard scientific chart types.
import matplotlib.pyplot as plt
import numpy as np
fig, axes = plt.subplots(2, 3, figsize=(15, 9), constrained_layout=True)
# Line plot — trends over time
x = np.linspace(0, 10, 50)
axes[0, 0].plot(x, np.exp(-x/3) * np.sin(x), "b-", linewidth=2)
axes[0, 0].set_title("Line Plot")
# Scatter plot — correlations
np.random.seed(42)
axes[0, 1].scatter(np.random.randn(100), np.random.randn(100), alpha=0.6, c=np.random.rand(100), cmap="viridis")
axes[0, 1].set_title("Scatter Plot")
# Bar chart — categorical comparisons
categories = ["Gene A", "Gene B", "Gene C", "Gene D"]
axes[0, 2].bar(categories, [4.2, 7.1, 3.5, 6.8], color="steelblue", edgecolor="black")
axes[0, 2].set_title("Bar Chart")
# Histogram — distributions
axes[1, 0].hist(np.random.randn(1000), bins=40, edgecolor="black", alpha=0.7)
axes[1, 0].set_title("Histogram")
# Box plot — statistical distributions
data = [np.random.randn(50) + i for i in range(4)]
axes[1, 1].boxplot(data, labels=["Ctrl", "Drug A", "Drug B", "Drug C"])
axes[1, 1].set_title("Box Plot")
# Heatmap — matrix data
matrix = np.random.rand(8, 8)
im = axes[1, 2].imshow(matrix, cmap="coolwarm", aspect="auto")
plt.colorbar(im, ax=axes[1, 2])
axes[1, 2].set_title("Heatmap")
plt.savefig("plot_types.png", dpi=300, bbox_inches="tight")
print("Saved 6 plot types to plot_types.png")
Module 3: Styling and Customization
Colors, fonts, styles, annotations.
import matplotlib.pyplot as plt
import numpy as np
# Use style sheets
plt.style.use("seaborn-v0_8-whitegrid")
# Custom rcParams for publication
plt.rcParams.update({
"font.size": 12, "axes.labelsize": 14,
"axes.titlesize": 16, "xtick.labelsize": 10,
"ytick.labelsize": 10, "legend.fontsize": 11,
})
fig, ax = plt.subplots(figsize=(8, 5))
x = np.linspace(0, 5, 100)
ax.plot(x, np.exp(-x), "r--", linewidth=2, label="Exponential decay")
ax.fill_between(x, np.exp(-x) - 0.1, np.exp(-x) + 0.1, alpha=0.2, color="red")
# Annotations
ax.annotate("Half-life", xy=(0.693, 0.5), xytext=(2, 0.7),
arrowprops=dict(arrowstyle="->", color="black"),
fontsize=12, fontweight="bold")
ax.set_xlabel("Time (s)"); ax.set_ylabel("Signal")
ax.legend()
plt.savefig("styled_plot.png", dpi=300, bbox_inches="tight")
print("Saved styled_plot.png")
Module 4: Advanced Layouts
Mosaic layouts, GridSpec, insets.
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
import numpy as np
# Mosaic layout — named axes
fig, axes = plt.subplot_mosaic(
[["main", "right"], ["main", "bottom_right"]],
figsize=(10, 7), constrained_layout=True,
gridspec_kw={"width_ratios": [2, 1]}
)
x = np.linspace(0, 10, 200)
axes["main"].plot(x, np.sin(x) * np.exp(-x/5), "b-", linewidth=2)
axes["main"].set_title("Main Panel")
axes["right"].hist(np.random.randn(300), bins=20, orientation="horizontal")
axes["right"].set_title("Distribution")
axes["bottom_right"].bar(["A", "B"], [3, 5])
axes["bottom_right"].set_title("Summary")
plt.savefig("mosaic_layout.png", dpi=300, bbox_inches="tight")
print("Saved mosaic_layout.png")
Module 5: 3D Visualization
Surface, scatter, and wireframe plots.
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np
fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection="3d")
# Surface plot
u = np.linspace(0, 2 * np.pi, 50)
v = np.linspace(0, np.pi, 50)
X = np.outer(np.cos(u), np.sin(v))
Y = np.outer(np.sin(u), np.sin(v))
Z = np.outer(np.ones_like(u), np.cos(v))
ax.plot_surface(X, Y, Z, cmap="viridis", alpha=0.8)
ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z")
ax.set_title("3D Surface Plot")
plt.savefig("surface_3d.png", dpi=300, bbox_inches="tight")
print("Saved surface_3d.png")
Module 6: Export and Saving
Output to various formats with publication settings.
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([1, 2, 3], [1, 4, 9], "ko-")
ax.set_title("Export Example")
# High-res PNG for presentations
fig.savefig("figure.png", dpi=300, bbox_inches="tight", facecolor="white")
# Vector PDF for journal submission
fig.savefig("figure.pdf", bbox_inches="tight")
# SVG for web
fig.savefig("figure.svg", bbox_inches="tight")
# Transparent background
fig.savefig("figure_transparent.png", dpi=300, bbox_inches="tight", transparent=True)
plt.close(fig) # Free memory
print("Exported to PNG, PDF, SVG, and transparent PNG")
Common Workflows
Workflow 1: Multi-Panel Figure for Publication
Goal: Create a 4-panel figure combining different plot types for a paper.
import matplotlib.pyplot as plt
import numpy as np
np.random.seed(42)
fig, axes = plt.subplots(2, 2, figsize=(10, 8), constrained_layout=True)
# Panel A: Time series
t = np.linspace(0, 24, 100)
axes[0, 0].plot(t, 50 + 10 * np.sin(t * np.pi / 12), "b-", linewidth=2)
axes[0, 0].set_xlabel("Time (h)"); axes[0, 0].set_ylabel("Expression")
axes[0, 0].set_title("A", loc="left", fontweight="bold")
# Panel B: Volcano plot
fc = np.random.randn(500)
pval = -np.log10(np.random.uniform(0.0001, 1, 500))
colors = ["red" if abs(f) > 1 and p > 2 else "grey" for f, p in zip(fc, pval)]
axes[0, 1].scatter(fc, pval, c=colors, s=10, alpha=0.7)
axes[0, 1].axhline(2, ls="--", color="black", alpha=0.5)
axes[0, 1].set_xlabel("log₂ FC"); axes[0, 1].set_ylabel("-log₁₀ p-value")
axes[0, 1].set_title("B", loc="left", fontweight="bold")
# Panel C: Bar chart with error bars
means = [3.2, 5.1, 4.7, 6.3]
sems = [0.4, 0.6, 0.3, 0.5]
axes[1, 0].bar(["Ctrl", "Drug A", "Drug B", "Combo"], means, yerr=sems,
capsize=5, color="steelblue", edgecolor="black")
axes[1, 0].set_ylabel("Response"); axes[1, 0].set_title("C", loc="left", fontweight="bold")
# Panel D: Heatmap
data = np.random.randn(6, 4)
im = axes[1, 1].imshow(data, cmap="RdBu_r", aspect="auto")
plt.colorbar(im, ax=axes[1, 1])
axes[1, 1].set_title("D", loc="left", fontweight="bold")
fig.savefig("publication_figure.pdf", bbox_inches="tight")
print("Saved publication_figure.pdf (4 panels)")
Workflow 2: Statistical Comparison Plot
Goal: Bar chart with individual data points and significance annotations.
import matplotlib.pyplot as plt
import numpy as np
np.random.seed(42)
groups = {"Control": np.random.normal(5, 1.2, 20),
"Treatment A": np.random.normal(7, 1.5, 20),
"Treatment B": np.random.normal(6, 1.0, 20)}
fig, ax = plt.subplots(figsize=(6, 5))
positions = range(len(groups))
for i, (name, data) in enumerate(groups.items()):
ax.bar(i, np.mean(data), yerr=np.std(data)/np.sqrt(len(data)),
capsize=5, color=["#4C72B0", "#DD8452", "#55A868"][i],
edgecolor="black", alpha=0.8, width=0.6)
# Overlay individual data points
ax.scatter(np.full_like(data, i) + np.random.uniform(-0.15, 0.15, len(data)),
data, color="black", s=15, alpha=0.5, zorder=5)
ax.set_xticks(positions); ax.set_xticklabels(groups.keys())
ax.set_ylabel("Measurement")
# Add significance bracket
y_max = max(max(d) for d in groups.values()) + 1
ax.plot([0, 0, 1, 1], [y_max, y_max + 0.2, y_max + 0.2, y_max], "k-", linewidth=1)
ax.text(0.5, y_max + 0.3, "**", ha="center", fontsize=14)
fig.savefig("comparison_plot.png", dpi=300, bbox_inches="tight")
print("Saved comparison_plot.png")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
figsize |
Figure creation | (6.4, 4.8) |
(w, h) in inches |
Figure dimensions |
dpi |
savefig |
100 |
72-600 |
Resolution: 300 for print, 150 for web |
bbox_inches |
savefig |
None |
"tight", None |
Crop whitespace around figure |
constrained_layout |
subplots |
False |
True/False |
Auto-adjust spacing to prevent overlap |
cmap |
Heatmap/scatter | "viridis" |
"viridis", "coolwarm", "RdBu_r", etc. |
Colormap for data mapping |
alpha |
All plot types | 1.0 |
0.0-1.0 |
Transparency (0=invisible, 1=opaque) |
linewidth |
Line plots | 1.5 |
0.5-5.0 |
Line thickness in points |
s |
Scatter | 20 |
1-500 |
Marker size in points² |
bins |
Histogram | 10 |
5-100 or array |
Number of histogram bins |
projection |
add_subplot |
None |
"3d", "polar" |
Axes projection type |
Best Practices
-
Always use the OO interface (
fig, ax = plt.subplots()) for production code. Reserveplt.plot()for quick interactive exploration only -
Use
constrained_layout=Trueto prevent overlapping labels and titles:fig, ax = plt.subplots(figsize=(8, 5), constrained_layout=True) -
Choose accessible colormaps: Use
viridis,cividis, orplasma(perceptually uniform, colorblind-safe). Avoidjetandrainbow -
Close figures after saving to prevent memory leaks in loops:
plt.close(fig) # After savefig -
Set DPI appropriately: 300 for print/journal, 150 for web/slides, 72 for screen-only
-
Use
rasterized=Truefor large datasets to reduce PDF/SVG file size:ax.scatter(x, y, rasterized=True) # Vector labels + rasterized data -
Label panels consistently: Use bold letters (A, B, C, D) at top-left of each subplot for multi-panel figures
Common Recipes
Recipe: Custom Color Palette
When to use: Consistent colors across multiple figures in a paper.
import matplotlib.pyplot as plt
# Define a custom palette
palette = {"control": "#4C72B0", "treatment": "#DD8452", "combo": "#55A868"}
fig, ax = plt.subplots()
for group, color in palette.items():
ax.bar(group, [5, 7, 6][list(palette.keys()).index(group)], color=color)
plt.savefig("custom_palette.png", dpi=300, bbox_inches="tight")
Recipe: Twin Y-Axes
When to use: Plotting two variables with different scales on the same figure.
import matplotlib.pyplot as plt
import numpy as np
fig, ax1 = plt.subplots(figsize=(8, 5))
x = np.arange(10)
ax1.bar(x, np.random.randint(10, 100, 10), alpha=0.7, color="steelblue", label="Count")
ax1.set_ylabel("Count", color="steelblue")
ax2 = ax1.twinx()
ax2.plot(x, np.cumsum(np.random.rand(10)), "r-o", linewidth=2, label="Cumulative")
ax2.set_ylabel("Cumulative", color="red")
fig.legend(loc="upper left", bbox_to_anchor=(0.15, 0.95))
plt.savefig("twin_axes.png", dpi=300, bbox_inches="tight")
Recipe: Inset Zoom Plot
When to use: Showing a zoomed-in region of a larger plot.
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(8, 5))
x = np.linspace(0, 10, 500)
y = np.sin(x) * np.exp(-x / 5)
ax.plot(x, y, "b-", linewidth=2)
# Inset
axins = ax.inset_axes([0.5, 0.5, 0.4, 0.4])
axins.plot(x, y, "b-", linewidth=2)
axins.set_xlim(1, 3); axins.set_ylim(0.2, 0.8)
ax.indicate_inset_zoom(axins, edgecolor="black")
plt.savefig("inset_zoom.png", dpi=300, bbox_inches="tight")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Overlapping labels/titles | No layout management | Add constrained_layout=True to plt.subplots() |
UserWarning: tight_layout |
Incompatible with constrained_layout | Use only one: constrained_layout OR tight_layout(), not both |
| Blurry figures in Jupyter | Low default DPI | Set %config InlineBackend.figure_format = 'retina' |
| Memory grows in loops | Figures not closed | Add plt.close(fig) after each savefig() |
| Font not found warning | Missing system font | Use plt.rcParams["font.sans-serif"] = ["DejaVu Sans"] |
| Large PDF/SVG file size | Many data points in vector | Use rasterized=True on heavy artists |
| 3D plot rotation stuck | Interactive backend issue | Use %matplotlib widget in Jupyter or plt.show() in scripts |
| Colorbar wrong size | Default sizing doesn't match axes | Use fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) |
References
- matplotlib documentation — official docs
- matplotlib gallery — examples by plot type
- matplotlib cheatsheets — quick reference (PDF)
- Hunter, J.D. (2007). Matplotlib: A 2D graphics environment. Computing in Science & Engineering 9(3):90-95.
skills/data-visualization/plotly-interactive-plots/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill plotly-interactive-plots -g -y
SKILL.md
Frontmatter
{
"name": "plotly-interactive-plots",
"license": "MIT",
"description": "Interactive scientific visualization with Plotly. Two APIs: plotly.express (px) for one-liner DataFrame plots, plotly.graph_objects (go) for trace-level control. 40+ chart types with hover, zoom, pan, animation. Exports HTML or static PNG\/SVG\/PDF via kaleido. Use for volcano plots with gene hover, dose-response dashboards, expression heatmaps, 3D molecular views. Use seaborn for stats; matplotlib for publication figures."
}
Plotly Interactive Plots
Overview
Plotly is a Python library for producing interactive, web-ready figures backed by HTML and JavaScript. It exposes two complementary APIs: plotly.express (px) provides a high-level, DataFrame-oriented interface for generating common chart types in one line, while plotly.graph_objects (go) offers fine-grained control over every trace, axis, and layout property. Figures are fully interactive by default — supporting hover tooltips, zoom, pan, and click events — and can be embedded in web pages, Jupyter notebooks, or built into web applications using the Dash framework.
When to Use
- You need hover tooltips that display gene names, p-values, or sample metadata without cluttering the static figure.
- You are building a multi-panel interactive dashboard for dose-response curves, patient cohorts, or multi-condition comparisons.
- You want to share figures as self-contained HTML files that non-programmers can explore in a browser.
- You need 3D scatter or surface plots for structural biology, conformational landscapes, or PCA of high-dimensional data.
- You are creating heatmaps of gene expression or correlation matrices where users need to zoom into specific gene clusters.
- You require animation frames to show time-series or treatment-response trajectories.
- Use
seaborninstead when you need automatic statistical aggregation (confidence intervals, regression fits) with minimal code. - Use
matplotlibwhen you need fine-grained control over every axis element for print-ready publication figures at exact journal specifications.
Prerequisites
- Python packages:
plotly,kaleido(static image export),pandas,numpy - Data requirements: pandas DataFrames or NumPy arrays; long-form (tidy) data works best with
px - Environment: Jupyter Lab/Notebook (inline rendering), or save as HTML for browser display
pip install plotly kaleido pandas numpy
For Jupyter Lab inline rendering (if not automatic):
pip install "jupyterlab>=3" ipywidgets
Quick Start
import plotly.express as px
import pandas as pd
# Gene expression scatter with hover info
df = pd.DataFrame({
"log2FC": [-3.1, 0.2, 1.8, 2.5, -0.5, 4.1],
"neg_log10_padj": [8.2, 0.4, 2.1, 6.8, 0.1, 9.3],
"gene": ["BRCA1", "MYC", "TP53", "EGFR", "CDKN1A", "KRAS"],
"significance": ["sig", "ns", "ns", "sig", "ns", "sig"],
})
fig = px.scatter(
df, x="log2FC", y="neg_log10_padj",
color="significance", hover_name="gene",
title="Volcano Plot — Treatment vs Control",
)
fig.show()
Core API
Module 1: px Scatter and Line — Relational Plots
px.scatter() and px.line() map DataFrame columns to visual encodings (color, symbol, size) and automatically populate hover tooltips from hover_data.
import plotly.express as px
import pandas as pd
import numpy as np
# Dose-response scatter: color by drug, symbol by cell line
np.random.seed(42)
df = pd.DataFrame({
"dose_uM": np.tile([0.01, 0.1, 1, 10, 100], 4),
"viability": np.clip(np.random.normal(
[100, 90, 70, 40, 10] * 4, 5), 0, 110),
"drug": ["DrugA"] * 5 + ["DrugA"] * 5 + ["DrugB"] * 5 + ["DrugB"] * 5,
"cell_line": ["HCT116"] * 10 + ["MCF7"] * 10,
"replicate": np.tile([1, 2, 3, 4, 5], 4),
})
fig = px.scatter(
df, x="dose_uM", y="viability",
color="drug", symbol="cell_line",
log_x=True,
hover_data={"replicate": True, "dose_uM": ":.2f"},
labels={"viability": "Cell Viability (%)", "dose_uM": "Dose (µM)"},
title="Dose-Response by Drug and Cell Line",
)
fig.show()
print(f"Figure has {len(fig.data)} traces")
# Time-course gene expression line plot
time_df = pd.DataFrame({
"hour": list(range(0, 25, 4)) * 3,
"expression": [1.0, 1.8, 3.2, 4.5, 3.8, 2.1, 1.2,
1.0, 2.5, 5.1, 6.8, 5.5, 3.2, 1.8,
1.0, 1.1, 1.0, 1.2, 1.1, 1.0, 0.9],
"gene": ["MYC"] * 7 + ["EGFR"] * 7 + ["GAPDH"] * 7,
})
fig = px.line(
time_df, x="hour", y="expression",
color="gene", markers=True,
labels={"expression": "Relative Expression (log2)", "hour": "Time (h)"},
title="Time-Course Gene Expression",
)
fig.update_traces(line=dict(width=2.5), marker=dict(size=8))
fig.show()
Module 2: px Statistical Plots — Distributions and Categories
px.box(), px.violin(), px.histogram(), and px.strip() produce publication-ready distribution summaries with built-in grouping.
import plotly.express as px
import pandas as pd
import numpy as np
# Violin + strip overlay: expression by cell type
np.random.seed(7)
n = 60
cell_data = pd.DataFrame({
"expression": np.concatenate([
np.random.normal(4.2, 0.8, n),
np.random.normal(6.5, 1.2, n),
np.random.normal(2.8, 0.6, n),
]),
"cell_type": ["T cell"] * n + ["B cell"] * n + ["NK cell"] * n,
"patient_id": np.tile([f"P{i:02d}" for i in range(1, 11)], 18),
})
fig = px.violin(
cell_data, x="cell_type", y="expression",
color="cell_type", box=True, points="all",
hover_data=["patient_id"],
labels={"expression": "CD3E Expression (log2 CPM)"},
title="CD3E Expression Across Cell Types",
)
fig.update_traces(jitter=0.3, pointpos=-1.5)
fig.show()
print(f"Cells per type: {cell_data.groupby('cell_type').size().to_dict()}")
# Histogram with rug: distribution of fold changes
fc_df = pd.DataFrame({
"log2FC": np.concatenate([
np.random.normal(0.1, 0.8, 500), # not DE genes
np.random.normal(2.5, 0.4, 50), # upregulated
np.random.normal(-2.3, 0.4, 40), # downregulated
]),
"category": ["background"] * 500 + ["up"] * 50 + ["down"] * 40,
})
fig = px.histogram(
fc_df, x="log2FC", color="category",
nbins=60, barmode="overlay", opacity=0.7,
marginal="rug",
labels={"log2FC": "log2 Fold Change", "count": "Gene Count"},
title="Distribution of Fold Changes (DESeq2 Results)",
color_discrete_map={"background": "gray", "up": "crimson", "down": "steelblue"},
)
fig.show()
Module 3: px Heatmap and Matrix — Gene Expression and Correlations
px.imshow() renders 2D arrays or DataFrames as color-encoded matrices, ideal for expression heatmaps and correlation matrices.
import plotly.express as px
import pandas as pd
import numpy as np
# Gene expression heatmap (genes × samples)
np.random.seed(12)
genes = [f"Gene_{g}" for g in ["BRCA1", "TP53", "EGFR", "MYC", "KRAS",
"CDKN1A", "RB1", "PTEN", "VHL", "APC"]]
samples = [f"S{i:02d}" for i in range(1, 9)]
expr_matrix = pd.DataFrame(
np.random.normal(0, 1.5, (10, 8)) +
np.array([2, -1, 3, -2, 1, -3, 0, 2, -1, 3]).reshape(-1, 1),
index=genes, columns=samples,
)
fig = px.imshow(
expr_matrix,
color_continuous_scale="RdBu_r",
color_continuous_midpoint=0,
aspect="auto",
labels={"color": "log2 Expression (z-score)"},
title="Gene Expression Heatmap",
)
fig.update_xaxes(side="top")
fig.update_layout(width=600, height=500)
fig.show()
print(f"Heatmap shape: {expr_matrix.shape} (genes × samples)")
# Correlation matrix heatmap
from itertools import combinations
markers = ["IL6", "TNF", "CXCL10", "IFNg", "IL10", "IL1B", "CCL2", "IL17A"]
np.random.seed(3)
raw = np.random.multivariate_normal(
mean=np.zeros(8),
cov=np.eye(8) * 0.3 + 0.7,
size=80,
)
corr_df = pd.DataFrame(raw, columns=markers).corr()
fig = px.imshow(
corr_df,
color_continuous_scale="RdBu_r",
color_continuous_midpoint=0,
zmin=-1, zmax=1,
text_auto=".2f",
title="Cytokine Correlation Matrix (n=80 patients)",
)
fig.update_traces(textfont_size=10)
fig.show()
Module 4: go Graph Objects — Full Trace Control
plotly.graph_objects provides fine-grained access to every trace property: marker symbols, error bars, fill areas, and multi-trace layouts. Essential when px lacks the flexibility you need.
import plotly.graph_objects as go
import numpy as np
# Volcano plot built from scratch with go.Scatter
np.random.seed(99)
n_genes = 5000
log2fc = np.random.normal(0, 1.2, n_genes)
pval = np.random.uniform(0, 1, n_genes) ** 2 # skew toward low p-values
neg_log10_p = -np.log10(pval + 1e-300)
gene_names = [f"Gene_{i:04d}" for i in range(n_genes)]
# Classify genes
sig_mask = (np.abs(log2fc) > 1.5) & (neg_log10_p > 3)
up_mask = sig_mask & (log2fc > 0)
down_mask = sig_mask & (log2fc < 0)
ns_mask = ~sig_mask
fig = go.Figure()
# Non-significant background
fig.add_trace(go.Scatter(
x=log2fc[ns_mask], y=neg_log10_p[ns_mask],
mode="markers",
name="Not significant",
marker=dict(color="lightgray", size=4, opacity=0.5),
text=[gene_names[i] for i in np.where(ns_mask)[0]],
hovertemplate="<b>%{text}</b><br>log2FC: %{x:.2f}<br>-log10(p): %{y:.2f}<extra></extra>",
))
# Upregulated
fig.add_trace(go.Scatter(
x=log2fc[up_mask], y=neg_log10_p[up_mask],
mode="markers",
name=f"Up ({up_mask.sum()} genes)",
marker=dict(color="crimson", size=7, opacity=0.8),
text=[gene_names[i] for i in np.where(up_mask)[0]],
hovertemplate="<b>%{text}</b><br>log2FC: %{x:.2f}<br>-log10(p): %{y:.2f}<extra></extra>",
))
# Downregulated
fig.add_trace(go.Scatter(
x=log2fc[down_mask], y=neg_log10_p[down_mask],
mode="markers",
name=f"Down ({down_mask.sum()} genes)",
marker=dict(color="steelblue", size=7, opacity=0.8),
text=[gene_names[i] for i in np.where(down_mask)[0]],
hovertemplate="<b>%{text}</b><br>log2FC: %{x:.2f}<br>-log10(p): %{y:.2f}<extra></extra>",
))
# Threshold lines
fig.add_hline(y=3, line_dash="dash", line_color="black", line_width=1)
fig.add_vline(x=1.5, line_dash="dash", line_color="black", line_width=1)
fig.add_vline(x=-1.5, line_dash="dash", line_color="black", line_width=1)
fig.update_layout(
title="Volcano Plot (Treatment vs Control, n=5000 genes)",
xaxis_title="log2 Fold Change",
yaxis_title="-log10(adjusted p-value)",
legend=dict(x=0.01, y=0.99),
width=750, height=550,
)
fig.show()
print(f"Up: {up_mask.sum()}, Down: {down_mask.sum()}, NS: {ns_mask.sum()}")
# Bar chart with error bars: mean ± SEM per treatment group
groups = ["Vehicle", "DrugA 1µM", "DrugA 10µM", "DrugB 1µM", "DrugB 10µM"]
means = [100.0, 82.3, 54.7, 91.2, 68.5]
sems = [3.2, 4.1, 3.8, 3.5, 4.7]
fig = go.Figure(go.Bar(
x=groups, y=means,
error_y=dict(type="data", array=sems, visible=True),
marker_color=["gray", "lightsalmon", "crimson", "lightblue", "steelblue"],
hovertemplate="%{x}<br>Mean: %{y:.1f}%<br>SEM: ±%{error_y.array:.1f}%<extra></extra>",
))
fig.update_layout(
title="Cell Viability by Treatment (Mean ± SEM, n=6)",
yaxis_title="Viability (%)", yaxis_range=[0, 120],
xaxis_title="Treatment Group",
showlegend=False,
)
fig.show()
Module 5: 3D and Specialized Charts
Plotly supports 3D scatter, surface plots, parallel coordinates, and treemaps — chart types unavailable in seaborn or standard matplotlib.
import plotly.express as px
import numpy as np
import pandas as pd
# 3D PCA scatter: cell clusters in embedding space
np.random.seed(42)
n_per_cluster = 80
cluster_centers = {"T cell": [3, 2, 1], "B cell": [-3, 1, 2], "Monocyte": [0, -3, -1]}
records = []
for ctype, center in cluster_centers.items():
coords = np.random.normal(center, 0.8, (n_per_cluster, 3))
for row in coords:
records.append({
"PC1": row[0], "PC2": row[1], "PC3": row[2],
"cell_type": ctype,
"score": np.random.uniform(0.5, 1.0),
})
pca_df = pd.DataFrame(records)
fig = px.scatter_3d(
pca_df, x="PC1", y="PC2", z="PC3",
color="cell_type", size="score", opacity=0.7,
hover_data={"score": ":.3f"},
title="3D PCA — Single-Cell Transcriptomics",
)
fig.update_traces(marker=dict(sizeref=0.04))
fig.show()
print(f"Total cells: {len(pca_df)}, clusters: {pca_df['cell_type'].nunique()}")
import plotly.graph_objects as go
import numpy as np
import pandas as pd
# Parallel coordinates: multi-parameter drug screen
np.random.seed(5)
n_compounds = 200
drug_df = pd.DataFrame({
"MW": np.random.normal(380, 60, n_compounds),
"logP": np.random.uniform(-1, 6, n_compounds),
"HBA": np.random.randint(2, 10, n_compounds),
"HBD": np.random.randint(0, 6, n_compounds),
"IC50_nM": np.random.lognormal(4, 1.5, n_compounds),
"selectivity": np.random.uniform(1, 100, n_compounds),
})
fig = px.parallel_coordinates(
drug_df,
color="IC50_nM",
color_continuous_scale="RdYlGn_r",
dimensions=["MW", "logP", "HBA", "HBD", "IC50_nM", "selectivity"],
labels={
"MW": "MW (Da)", "logP": "logP",
"HBA": "H-Bond Acceptors", "HBD": "H-Bond Donors",
"IC50_nM": "IC50 (nM)", "selectivity": "Selectivity Index",
},
title="Drug Candidate Properties — Parallel Coordinates",
)
fig.show()
Module 6: Subplots and Export
make_subplots() creates multi-panel layouts with shared axes, mixed chart types, and independent traces per panel. fig.write_html() exports interactive figures; fig.write_image() exports static files via kaleido.
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import numpy as np
# Two-panel: raw data + summary statistics
np.random.seed(77)
doses = [0.01, 0.1, 1, 10, 100]
drugs = {"DrugA": {"EC50": 1.0, "hill": 1.5}, "DrugB": {"EC50": 8.0, "hill": 0.9}}
def hill_curve(dose, ec50, hill, top=100, bottom=0):
return bottom + (top - bottom) / (1 + (ec50 / dose) ** hill)
fig = make_subplots(
rows=1, cols=2,
subplot_titles=["Dose-Response Curves", "IC50 Comparison"],
shared_yaxes=False,
)
colors = {"DrugA": "crimson", "DrugB": "steelblue"}
ic50_values = []
for drug, params in drugs.items():
# Smooth fit curve
x_fit = np.logspace(-2, 2, 200)
y_fit = hill_curve(x_fit, params["EC50"], params["hill"])
fig.add_trace(go.Scatter(
x=x_fit, y=y_fit, mode="lines",
name=f"{drug} fit", line=dict(color=colors[drug], width=2.5),
), row=1, col=1)
# Noisy data points
y_data = [hill_curve(d, params["EC50"], params["hill"]) +
np.random.normal(0, 4) for d in doses]
fig.add_trace(go.Scatter(
x=doses, y=y_data, mode="markers",
name=f"{drug} data", marker=dict(color=colors[drug], size=9),
showlegend=False,
), row=1, col=1)
ic50_values.append(params["EC50"])
# Bar chart of IC50 values
fig.add_trace(go.Bar(
x=list(drugs.keys()), y=ic50_values,
marker_color=list(colors.values()),
showlegend=False,
hovertemplate="%{x}<br>IC50: %{y:.2f} µM<extra></extra>",
), row=1, col=2)
fig.update_xaxes(type="log", title_text="Dose (µM)", row=1, col=1)
fig.update_yaxes(title_text="Viability (%)", row=1, col=1)
fig.update_xaxes(title_text="Drug", row=1, col=2)
fig.update_yaxes(title_text="IC50 (µM)", row=1, col=2)
fig.update_layout(title="Dose-Response Dashboard", height=450, width=850)
fig.show()
print(f"Subplots: {len(fig.data)} traces across 2 panels")
# Export to HTML (interactive) and PNG (static)
# Requires: pip install kaleido
fig.write_html("dose_response_dashboard.html")
print("Saved: dose_response_dashboard.html (interactive, shareable)")
fig.write_image("dose_response_dashboard.png", width=1200, height=600, scale=2)
print("Saved: dose_response_dashboard.png (300 DPI equivalent with scale=2)")
fig.write_image("dose_response_dashboard.svg")
print("Saved: dose_response_dashboard.svg (vector, editable in Inkscape/Illustrator)")
Common Workflows
Workflow 1: Interactive Volcano Plot with Gene Annotations
Goal: Build a fully annotated volcano plot from DESeq2 results, with gene-name hover tooltips, threshold lines, and highlighted hit labels for sharing as HTML.
import plotly.graph_objects as go
import pandas as pd
import numpy as np
# Simulate DESeq2 output (replace with pd.read_csv("deseq2_results.csv"))
np.random.seed(42)
n = 3000
df = pd.DataFrame({
"gene": [f"GENE_{i:04d}" for i in range(n)],
"log2FC": np.random.normal(0, 1.0, n),
"padj": np.clip(np.random.exponential(0.1, n), 1e-20, 1.0),
"baseMean": np.random.lognormal(5, 1.5, n),
})
# Inject some hits
df.loc[:20, "log2FC"] = np.random.uniform(2.5, 5, 21)
df.loc[:20, "padj"] = np.random.uniform(1e-15, 1e-5, 21)
df.loc[21:35, "log2FC"] = np.random.uniform(-4, -2, 15)
df.loc[21:35, "padj"] = np.random.uniform(1e-12, 1e-4, 15)
df["neg_log10_padj"] = -np.log10(df["padj"].clip(1e-300))
# Classify
FC_THRESH, P_THRESH = 1.5, 2.0 # |log2FC| > 1.5, -log10(padj) > 2
df["category"] = "NS"
df.loc[(df["log2FC"] > FC_THRESH) & (df["neg_log10_padj"] > P_THRESH), "category"] = "Up"
df.loc[(df["log2FC"] < -FC_THRESH) & (df["neg_log10_padj"] > P_THRESH), "category"] = "Down"
color_map = {"NS": "lightgray", "Up": "crimson", "Down": "steelblue"}
size_map = {"NS": 4, "Up": 7, "Down": 7}
opacity_map = {"NS": 0.4, "Up": 0.85, "Down": 0.85}
fig = go.Figure()
for cat in ["NS", "Up", "Down"]:
sub = df[df["category"] == cat]
fig.add_trace(go.Scatter(
x=sub["log2FC"], y=sub["neg_log10_padj"],
mode="markers",
name=f"{cat} (n={len(sub)})",
marker=dict(
color=color_map[cat],
size=size_map[cat],
opacity=opacity_map[cat],
),
customdata=sub[["gene", "padj", "baseMean"]].values,
hovertemplate=(
"<b>%{customdata[0]}</b><br>"
"log2FC: %{x:.3f}<br>"
"padj: %{customdata[1]:.2e}<br>"
"baseMean: %{customdata[2]:.1f}<extra></extra>"
),
))
# Threshold lines
fig.add_hline(y=P_THRESH, line_dash="dot", line_color="black", line_width=1.2,
annotation_text=f"padj=0.01", annotation_position="right")
fig.add_vline(x=FC_THRESH, line_dash="dot", line_color="black", line_width=1.2)
fig.add_vline(x=-FC_THRESH, line_dash="dot", line_color="black", line_width=1.2)
# Label top 5 upregulated hits by significance
top_up = df[df["category"] == "Up"].nlargest(5, "neg_log10_padj")
for _, row in top_up.iterrows():
fig.add_annotation(
x=row["log2FC"], y=row["neg_log10_padj"],
text=row["gene"], showarrow=True,
arrowhead=2, arrowsize=1, arrowcolor="crimson",
font=dict(size=9, color="crimson"),
xshift=8, yshift=5,
)
fig.update_layout(
title="Volcano Plot — Treatment vs Control (DESeq2)",
xaxis_title="log2 Fold Change",
yaxis_title="-log10(adjusted p-value)",
legend=dict(x=0.01, y=0.99, bordercolor="lightgray", borderwidth=1),
width=800, height=560,
plot_bgcolor="white",
)
fig.update_xaxes(showgrid=True, gridcolor="lightgray", zeroline=True, zerolinecolor="darkgray")
fig.update_yaxes(showgrid=True, gridcolor="lightgray")
fig.write_html("volcano_interactive.html")
print(f"Up: {(df.category=='Up').sum()}, Down: {(df.category=='Down').sum()}")
print("Saved: volcano_interactive.html")
Workflow 2: Multi-Panel Dose-Response Dashboard with make_subplots
Goal: Display dose-response curves for multiple drugs across cell lines in a grid layout with a shared color scale and consistent formatting.
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import numpy as np
import pandas as pd
# Simulated IC50 data for 3 drugs × 3 cell lines
np.random.seed(10)
drugs = ["DrugA", "DrugB", "DrugC"]
cell_lines = ["HCT116", "MCF7", "A549"]
doses = np.logspace(-2, 2, 7) # 0.01 to 100 µM
def hill(x, ec50, hill_n, top=100, bottom=0):
return bottom + (top - bottom) / (1 + (ec50 / x) ** hill_n)
ec50_table = {
("DrugA", "HCT116"): 0.5, ("DrugA", "MCF7"): 2.0, ("DrugA", "A549"): 8.0,
("DrugB", "HCT116"): 5.0, ("DrugB", "MCF7"): 0.8, ("DrugB", "A549"): 15.0,
("DrugC", "HCT116"): 12.0, ("DrugC", "MCF7"): 6.0, ("DrugC", "A549"): 1.2,
}
palette = px_colors = ["#EF553B", "#636EFA", "#00CC96", "#AB63FA", "#FFA15A",
"#19D3F3", "#FF6692", "#B6E880", "#FF97FF"]
fig = make_subplots(
rows=len(drugs), cols=len(cell_lines),
subplot_titles=[f"{d} / {c}" for d in drugs for c in cell_lines],
shared_xaxes=True, shared_yaxes=True,
vertical_spacing=0.08, horizontal_spacing=0.04,
)
for r, drug in enumerate(drugs, start=1):
for c, cell_line in enumerate(cell_lines, start=1):
ec50 = ec50_table[(drug, cell_line)]
x_fit = np.logspace(-2, 2, 200)
y_fit = hill(x_fit, ec50, hill_n=1.5)
# Noisy replicate data
y_data = np.array([hill(d, ec50, 1.5) + np.random.normal(0, 5) for d in doses])
color = palette[(r - 1) * len(cell_lines) + (c - 1)]
show_legend = (c == 1 and r == 1)
fig.add_trace(go.Scatter(
x=x_fit, y=y_fit, mode="lines",
line=dict(color=color, width=2),
name=f"{drug}/{cell_line}",
showlegend=False,
hovertemplate=f"{drug} in {cell_line}<br>Dose: %{{x:.2f}} µM<br>Viability: %{{y:.1f}}%<extra></extra>",
), row=r, col=c)
fig.add_trace(go.Scatter(
x=doses, y=np.clip(y_data, 0, 110), mode="markers",
marker=dict(color=color, size=7, opacity=0.8),
showlegend=False,
hovertemplate=f"Measured<br>Dose: %{{x:.2f}} µM<br>Viability: %{{y:.1f}}%<extra></extra>",
), row=r, col=c)
# IC50 annotation
fig.add_annotation(
x=np.log10(ec50), y=50,
text=f"IC50={ec50:.1f}µM",
font=dict(size=8), showarrow=False,
xref=f"x{(r-1)*len(cell_lines)+c if (r-1)*len(cell_lines)+c > 1 else ''}",
yref=f"y{(r-1)*len(cell_lines)+c if (r-1)*len(cell_lines)+c > 1 else ''}",
)
# Apply log scale to all x-axes
for i in range(1, len(drugs) * len(cell_lines) + 1):
axis_key = f"xaxis{i if i > 1 else ''}"
fig.layout[axis_key].update(type="log", title_text="Dose (µM)" if i > 6 else "")
for i in range(1, len(drugs) * len(cell_lines) + 1):
axis_key = f"yaxis{i if i > 1 else ''}"
fig.layout[axis_key].update(range=[-5, 115],
title_text="Viability (%)" if i in [1, 4, 7] else "")
fig.update_layout(
title="Dose-Response Dashboard — 3 Drugs × 3 Cell Lines",
height=700, width=900,
)
fig.write_html("dose_response_dashboard.html")
print("Saved: dose_response_dashboard.html")
print(f"Grid: {len(drugs)} drugs × {len(cell_lines)} cell lines = {len(drugs)*len(cell_lines)} panels")
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
color |
px.* |
None |
Column name | Maps a DataFrame column to trace color; auto-assigns palette |
hover_data |
px.* |
{} |
Dict or list of column names | Extra columns shown in hover tooltip |
log_x / log_y |
px.* |
False |
True, False |
Apply log10 scale to x or y axis |
facet_col / facet_row |
px.* |
None |
Column name | Split into subplot grid by a categorical variable |
color_continuous_scale |
px.imshow, px.scatter |
"plasma" |
"RdBu_r", "Viridis", "Hot", etc. |
Colormap for continuous color mapping |
color_continuous_midpoint |
px.imshow |
None |
Any numeric | Centers the diverging colormap at this value (use 0 for z-scores) |
barmode |
px.histogram, px.bar |
"relative" |
"relative", "overlay", "group" |
How multiple bar traces are displayed |
opacity |
go.Scatter, px.* |
1.0 |
0.0–1.0 |
Point/bar transparency |
size / sizeref |
go.Scatter |
6 / auto |
Positive numeric | Marker size; sizeref normalizes sizes across traces |
line_dash |
fig.add_hline, go.Scatter |
"solid" |
"solid", "dash", "dot", "dashdot" |
Line style for reference lines and traces |
shared_xaxes / shared_yaxes |
make_subplots |
False |
True, False, "rows", "cols" |
Link axes across subplot panels |
scale |
fig.write_image |
1 |
1–4 |
Resolution multiplier for PNG export (use 2 for ~150 DPI) |
Best Practices
-
Prefer
pxfor DataFrame data, fall back togofor multi-trace composition. Usepx.scatter()and its siblings for 80% of plots. Switch togowhen you need traces with different types in the same figure (e.g., scatter + filled area) or need fine-grained per-trace control.# Correct: px for simple grouped plots fig = px.box(df, x="treatment", y="expression", color="genotype") # Correct: go when px cannot express the structure fig = go.Figure() fig.add_trace(go.Scatter(x=x, y=y_upper, fill="tonexty", ...)) fig.add_trace(go.Scatter(x=x, y=y_lower, ...)) -
Always include
hovertemplatefor scientific figures. The default tooltip shows raw coordinates without units or gene names. A custom template withcustomdataprovides full biological context.fig.add_trace(go.Scatter( customdata=df[["gene", "padj"]].values, hovertemplate="<b>%{customdata[0]}</b><br>padj: %{customdata[1]:.2e}<extra></extra>", )) -
Export HTML for sharing, PNG/SVG for journals.
fig.write_html()produces a self-contained file with no external dependencies. Usescale=2or higher withwrite_image()to achieve sufficient resolution for print. -
Don't use
fig.show()in batch scripts. In non-interactive contexts (CI, HPC, cron jobs),fig.show()may open a browser window or fail. Usewrite_html()orwrite_image()exclusively. -
Use
color_continuous_midpoint=0for diverging palettes on z-score data. Without it, the midpoint color defaults to the data midpoint, not zero, misrepresenting symmetric fold changes or correlations.fig = px.imshow(corr_matrix, color_continuous_scale="RdBu_r", color_continuous_midpoint=0, zmin=-1, zmax=1) -
Set
plot_bgcolor="white"for publication figures. Plotly defaults to a light-gray grid background. White background with subtle gridlines is cleaner for most scientific contexts.fig.update_layout(plot_bgcolor="white") fig.update_xaxes(showgrid=True, gridcolor="lightgray") fig.update_yaxes(showgrid=True, gridcolor="lightgray")
Common Recipes
Recipe: Dropdown Menu to Toggle Between Conditions
When to use: Overlay multiple conditions in one figure with a dropdown button to show/hide individual traces cleanly.
import plotly.graph_objects as go
import numpy as np
conditions = ["Untreated", "DrugA", "DrugB"]
colors = ["gray", "crimson", "steelblue"]
np.random.seed(1)
x = np.linspace(0, 24, 49)
fig = go.Figure()
for i, (cond, color) in enumerate(zip(conditions, colors)):
y = np.sin(x / 4 + i * 0.5) * (1 - i * 0.2) + np.random.normal(0, 0.05, len(x))
fig.add_trace(go.Scatter(
x=x, y=y, mode="lines+markers",
name=cond, line=dict(color=color, width=2),
visible=(i == 0), # only first trace visible initially
))
# One button per condition (shows only that trace)
buttons = []
for i, cond in enumerate(conditions):
visibility = [j == i for j in range(len(conditions))]
buttons.append(dict(label=cond, method="update",
args=[{"visible": visibility}, {"title": f"Gene Expression — {cond}"}]))
# "Show All" button
buttons.append(dict(label="Show All", method="update",
args=[{"visible": [True] * len(conditions)}, {"title": "Gene Expression — All Conditions"}]))
fig.update_layout(
updatemenus=[dict(type="dropdown", x=0.01, y=1.15, showactive=True, buttons=buttons)],
title="Gene Expression — Untreated",
xaxis_title="Time (h)", yaxis_title="Relative Expression",
)
fig.show()
Recipe: Annotating Specific Hits with Arrows
When to use: Label outliers, drug hits, or significant genes directly on the figure without cluttering non-annotated points.
import plotly.graph_objects as go
import numpy as np
import pandas as pd
np.random.seed(33)
df = pd.DataFrame({
"x": np.random.normal(0, 1.5, 300),
"y": np.random.normal(0, 1.5, 300),
"gene": [f"G{i:03d}" for i in range(300)],
})
# Inject top hits
hits = pd.DataFrame({
"x": [3.2, -2.8, 2.5, -3.5],
"y": [4.1, 3.8, -3.2, -2.9],
"gene": ["BRCA1", "TP53", "EGFR", "KRAS"],
})
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df["x"], y=df["y"], mode="markers",
marker=dict(color="lightgray", size=5, opacity=0.6),
text=df["gene"],
hovertemplate="<b>%{text}</b><br>x: %{x:.2f}, y: %{y:.2f}<extra></extra>",
name="Background",
))
fig.add_trace(go.Scatter(
x=hits["x"], y=hits["y"], mode="markers",
marker=dict(color="crimson", size=10, symbol="diamond"),
text=hits["gene"],
hovertemplate="<b>%{text}</b> [HIT]<br>x: %{x:.2f}, y: %{y:.2f}<extra></extra>",
name="Hits",
))
for _, row in hits.iterrows():
fig.add_annotation(
x=row["x"], y=row["y"],
text=f"<b>{row['gene']}</b>",
showarrow=True, arrowhead=2, arrowwidth=1.5,
arrowcolor="crimson", font=dict(size=11, color="crimson"),
ax=25, ay=-30, # arrow offset in pixels
bgcolor="rgba(255,255,255,0.7)", bordercolor="crimson", borderwidth=1,
)
fig.update_layout(
title="Hit Identification with Arrow Annotations",
xaxis_title="Score A", yaxis_title="Score B",
plot_bgcolor="white",
)
fig.show()
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: kaleido is required for static image export |
kaleido not installed |
pip install kaleido; verify with import kaleido |
| Blank figure in Jupyter Notebook | Renderer not configured | Run import plotly.io as pio; pio.renderers.default = "notebook" or upgrade JupyterLab to ≥3 |
fig.show() opens blank browser tab |
No data in figure or offline renderer issue | Check len(fig.data) > 0; use pio.renderers.default = "browser" |
| Hover tooltips show wrong values | customdata index mismatch in hovertemplate |
Verify customdata column order matches %{customdata[N]} indices in template |
| Colors not assigned consistently across traces | px re-orders palette when category counts differ |
Use color_discrete_map={"Cat1": "#color1", ...} to pin colors explicitly |
write_image produces blurry PNG |
Default scale=1 is too low for print |
Use fig.write_image("fig.png", scale=2) for 150 DPI or scale=4 for 300 DPI |
Subplots x-axes not all log-scaled after shared_xaxes=True |
Shared axis only synchronizes range, not type | Iterate over all xaxis keys in fig.layout and set type="log" explicitly |
| Large datasets slow to render in browser | Too many individual points in a single scatter trace | Downsample background noise points; keep labeled hits as a separate, smaller trace |
fig.update_layout does not apply to subplot axes |
Multi-panel figures use indexed axes (xaxis2, xaxis3) |
Use fig.update_xaxes() (applies to all) or target fig.layout["xaxis2"] explicitly |
Related Skills
- seaborn-statistical-plots — use for statistical aggregation (confidence intervals, regression), publication-quality static figures with minimal code, and when matplotlib-level output is required
- matplotlib-scientific-plotting — use for full control over every figure element, custom layouts, embedded text rendering, and journal-specification figure preparation
- pydeseq2-differential-expression — volcano plot outputs from DESeq2 results are a primary input for the interactive volcano workflow above
- scanpy-scrna-seq — Scanpy's UMAP embeddings can be visualized interactively in 3D with
px.scatter_3d
References
- Plotly Python Documentation — official API reference, examples gallery, and getting-started guides
- Plotly Express API Reference — complete
pxfunction signatures and parameters - Plotly Graph Objects Reference — full
gotrace and layout attribute reference - Plotly GitHub Repository — source code, issue tracker, changelog
- Kaleido Static Image Export — dependency for
write_image()PNG/SVG/PDF export
skills/data-visualization/scientific-visualization/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-visualization -g -y
SKILL.md
Frontmatter
{
"name": "scientific-visualization",
"license": "CC-BY-4.0",
"description": "Guide for choosing and creating scientific visualizations for publications and talks. Covers chart-type selection by data structure, color theory for accessibility\/print, figure composition, journal formatting (Nature, Cell, ACS), and common pitfalls. Consult when visualizing data or preparing submission figures."
}
scientific-visualization
Overview
Effective scientific visualization communicates data clearly, honestly, and accessibly. Poor chart choices, misleading axes, or inaccessible color palettes can obscure findings or introduce bias. This guide covers the full workflow of scientific figure preparation: from selecting the right chart type for your data structure through color theory, accessibility, and journal submission formatting requirements.
Key Concepts
Chart Type and Data Type Alignment
Every chart type is optimized for a specific data structure. Mismatches (e.g., pie charts for continuous distributions, bar charts for time series) hide structure and distort perception.
| Data Type | Recommended Chart | Avoid |
|---|---|---|
| Continuous distribution (1 group) | Histogram, violin plot, ridge plot | Bar chart with mean only |
| Continuous distribution (2–5 groups) | Violin + boxplot overlay, beeswarm | Grouped bar chart |
| Two continuous variables, correlation | Scatter plot, hexbin (large N) | Line chart without temporal order |
| Categorical counts / proportions | Bar chart (horizontal for long labels) | Pie chart (>4 categories) |
| Change over time (continuous) | Line chart | Bar chart |
| Change over time (sparse events) | Step chart, event raster | Connected scatter |
| Part-to-whole (≤5 parts) | Stacked bar, waffle chart | 3D pie chart |
| High-dimensional (>5 variables) | Heatmap (clustered), parallel coordinates | 3D scatter |
| Spatial data | Map, spatial heatmap | Bubble chart |
| Survival / time-to-event | Kaplan-Meier curve | Bar chart of median survival |
Color Theory for Science
Color encodes information. Misused color introduces artifacts and fails readers with color vision deficiency (CVD; ~8% of males).
Sequential palettes encode ordered numeric data from low to high (e.g., expression level, concentration). Use perceptually uniform palettes: viridis, magma, cividis. These also print in grayscale.
Diverging palettes encode data with a meaningful midpoint (e.g., fold-change centered at 0, correlation from -1 to +1). Use RdBu, coolwarm, or vlag. Always ensure the midpoint maps to white/neutral.
Qualitative palettes encode unordered categories. Use Okabe-Ito (CVD-safe), tab10 (matplotlib default), or ColorBrewer qualitative palettes. Limit to ≤8 distinguishable colors; use shape or pattern as redundant encoding beyond that.
Color don'ts:
- Rainbow/jet colormap: not perceptually uniform; creates false contours
- Red vs. green encoding: fails deuteranopia (~6% males)
- Saturated color for background or large areas
Figure Composition and Layout
Scientific figures are typically multi-panel. Panel layout and labeling affect how readers parse information.
- Panel labels: Bold uppercase letters (A, B, C) in the top-left corner; use 8–12 pt in the figure, larger in the caption reference.
- Alignment: Align panel edges on a grid. Unaligned panels signal lack of attention to detail.
- White space: Leave adequate margins; crowded panels reduce readability.
- Figure size: Design for the target column width — single column (~85 mm / 3.35 in), 1.5 column (~114 mm / 4.5 in), or double column (~170 mm / 6.7 in) for Nature-family journals.
- Font: Sans-serif (Arial, Helvetica) at 6–8 pt minimum in the final figure at publication resolution.
Journal Formatting Requirements
Major journals specify exact figure requirements for submission. Violating these causes desk-rejection delays.
| Journal/Style | Max Width | Resolution | Color Mode | Font | File Format |
|---|---|---|---|---|---|
| Nature family | 89 mm (1-col), 183 mm (2-col) | 300 dpi (photos), 600 dpi (line art) | RGB or CMYK | Arial 5–7 pt | PDF, TIFF, EPS |
| Cell/iScience | 85 mm (1-col), 170 mm (2-col) | 300 dpi raster, 600 dpi halftone | RGB | Helvetica 6–8 pt | PDF, EPS, TIFF |
| ACS journals | 3.25 in (1-col), 7 in (2-col) | 600 dpi (color), 1200 dpi (b&w line art) | RGB (screen), CMYK (print) | Arial/Helvetica 4.5–7 pt | TIFF, EPS, PDF |
| PLOS ONE | No strict width | 300 dpi (raster), 600–1200 dpi (line art) | RGB | Any | TIFF, EPS, PDF |
Decision Framework
Use this tree to select the right visualization for your analysis goal:
What is the primary message of this figure?
|
+-- Show a distribution or spread of values
| +-- One group --> Histogram or violin plot
| +-- 2-5 groups --> Violin + jitter (show all points if N < 100)
| +-- Many groups --> Ridge plot (joy plot)
|
+-- Compare quantities between categories
| +-- Few categories (2-5) --> Bar chart with error bars + individual points
| +-- Many categories (>8) --> Lollipop chart or dot plot (horizontal)
| +-- Paired measurements --> Slopegraph or paired dot plot
|
+-- Show a relationship between two continuous variables
| +-- N < 1000 --> Scatter plot
| +-- N > 1000 --> Hexbin or 2D density plot
| +-- Time ordered --> Line chart
|
+-- Show composition or part-to-whole
| +-- 2-4 parts --> Stacked bar or waffle chart
| +-- Over time --> Stacked area chart
| +-- Avoid pie chart unless <= 3 parts and proportions are obvious
|
+-- Show high-dimensional data
| +-- Genes x samples --> Clustered heatmap (seaborn.clustermap)
| +-- Embeddings (UMAP, PCA) --> Scatter colored by metadata
| +-- Feature importance --> Horizontal bar chart (sorted)
|
+-- Show spatial or geographic data
| +-- Microscopy --> Image overlay with colorbar
| +-- Geographic --> Choropleth map
| Analysis Goal | Chart Type | Library | Key Consideration |
|---|---|---|---|
| Gene expression across groups | Violin + jitter | seaborn, plotnine |
Show all points if N < 50; never bar+SEM only |
| Differential expression | Volcano plot | matplotlib |
Log2FC on x-axis, -log10(p) on y-axis |
| Clustering results | UMAP scatter | scanpy, matplotlib |
One plot per annotation variable |
| Correlation matrix | Clustered heatmap | seaborn.clustermap |
Use diverging palette centered at 0 |
| Protein structure | Ribbon diagram | PyMOL, ChimeraX | Not covered here — use dedicated molecular graphics tools |
| Survival analysis | Kaplan-Meier | lifelines |
Include confidence bands and at-risk table |
| Time course | Line chart with CI | matplotlib |
Show uncertainty; connect group means, not individual points |
Best Practices
-
Show the data, not just summaries: For N < 100, overlay individual data points on violin or box plots using jitter or beeswarm. Bar charts with only mean ± SEM conceal distribution shape, outliers, and bimodality.
-
Choose CVD-safe color palettes by default: Use Okabe-Ito or
viridis/cividisfor sequential data. Test your figure with a CVD simulator (e.g., Coblis) before submission. -
Design at final publication size from the start: Set your figure canvas to the exact column width of the target journal (e.g., 89 mm for Nature single-column). Rescaling after the fact makes fonts too small or too large, and changes aspect ratios.
-
Label axes with units and use descriptive titles: Every axis must have a label with units in parentheses (e.g., "Expression level (log2 CPM)"). Avoid cryptic abbreviations without legend entries.
-
Use vector formats for line art and text: Save figures as PDF or SVG when they contain text and lines. Rasterize only when submitting to a journal that requires TIFF. Vector figures scale without pixelation and remain editable.
-
Match statistical annotations to the test performed: If you annotate significance stars (*), state in the caption which test was used, the exact p-value, and the sample size. "n.s." should still report the p-value.
-
Avoid dual y-axes: Two different y-axes on one plot are almost always misleading — the apparent relationship depends on scale choices. Use two separate panels instead.
Common Pitfalls
-
Bar chart for continuous distributions (dynamite plot)
- What goes wrong: Mean ± SEM bars hide whether data is normally distributed, multimodal, or has outliers. Two datasets with identical bars can have completely different distributions.
- How to avoid: Use violin plots, box-and-whisker with jitter, or beeswarm plots. Show all data points when N < 50.
-
Truncated or broken y-axis that exaggerates differences
- What goes wrong: Starting the y-axis at a non-zero value makes small differences appear large and misleads readers about effect size.
- How to avoid: Start y-axis at zero for ratio/count data. If zooming in is genuinely needed, use an inset or a separate panel with a clear label indicating the truncation.
-
Using rainbow/jet colormap for heatmaps
- What goes wrong: Jet is not perceptually uniform — it creates false contour lines and fails under CVD and grayscale printing.
- How to avoid: Use
viridis,magma, orinfernofor sequential;RdBuorcoolwarmfor diverging data. These are the defaults in seaborn >= 0.12.
-
Overlapping data points without jitter or transparency
- What goes wrong: Points stack on top of each other (overplotting), making dense regions appear sparse and hiding the true data density.
- How to avoid: Add jitter (
seaborn.stripplot(jitter=True)), use transparency (alpha=0.3), or switch to a hexbin / 2D density plot for large N.
-
P-value annotations without effect size or sample size
- What goes wrong: A statistically significant result (p < 0.05) with N = 10,000 may be scientifically trivial. Stars alone are uninformative.
- How to avoid: Report exact p-values, effect sizes (Cohen's d, odds ratio), and sample sizes in the figure caption or directly on the plot.
-
Figure text too small at publication size
- What goes wrong: Designing at screen size and then shrinking to journal column width results in 3–4 pt text that fails minimum readability standards.
- How to avoid: Design at final print size from the start. Check that axis labels are ≥ 6 pt at the final dimensions.
-
Inconsistent style across panels in a multi-panel figure
- What goes wrong: Different fonts, line widths, or color schemes across panels make the figure look unprofessional and harder to compare.
- How to avoid: Define a shared
matplotlib.rcParamsstyle dictionary at the top of your figure script and apply it to all panels.
Workflow
-
Define the message first
- Write one sentence describing what you want the reader to take away from this figure.
- Choose the chart type that most directly communicates that message using the Decision Framework above.
-
Prepare the data
- Aggregate, tidy, and validate your data before plotting.
- Check for outliers that should be investigated, not silently dropped.
-
Prototype at screen resolution
- Build a draft figure in your preferred library (matplotlib, seaborn, ggplot2, plotnine).
- Focus on getting data and chart type right; ignore styling at this stage.
-
Apply journal-specific styling
- Set canvas size to target journal column width.
- Set font family and minimum size.
- Choose CVD-safe palette.
- Standardize line widths (0.5–1.0 pt for axes and ticks, 1.0–2.0 pt for data lines).
-
Add annotations and labels
- Panel labels (A, B, C) in bold.
- Axis labels with units.
- Statistical annotations with test name and exact p-value.
- Legend with descriptive entries.
-
Export at correct resolution
- Vector: PDF or SVG for line art and text.
- Raster: TIFF at 300 dpi (halftone/photos) or 600 dpi (line art).
- Use
plt.savefig("fig1.pdf", bbox_inches="tight")in matplotlib.
-
Accessibility check
- Simulate CVD using an online tool or GIMP color blindness filter.
- Print in grayscale and verify panels remain distinguishable.
Protocol Guidelines
- For matplotlib/seaborn: Set
rcParamsat the top of every figure script; usefig.set_size_inches()to enforce journal dimensions; export withdpi=300minimum. - For R/ggplot2: Use
theme_classic()or a custom theme; setggsave(width=..., units="mm", dpi=300). - For multi-panel assembly: Use
matplotlib.gridspec,patchworklib(Python), orcowplot/patchwork(R) for aligned panel grids. - For interactive figures: Plotly or Bokeh for HTML outputs; Vega-Lite for web embedding. Always provide a static fallback for publication.
- For poster presentations: Scale all fonts up by 1.5–2×; use larger markers (pt size 8–12) and thicker lines (2–3 pt); prefer high-contrast, dark background palettes.
Further Reading
- Fundamentals of Data Visualization — Claus O. Wilke (open access) — comprehensive guide covering chart selection, color, and figure design
- Ten Simple Rules for Better Figures (Rougier et al., 2014, PLOS Computational Biology) — practical peer-reviewed guidelines
- ColorBrewer 2.0 — browser tool for selecting print-safe, CVD-safe color palettes
- Coblis CVD Simulator — test figure accessibility under 8 types of color vision deficiency
- Nature Methods — Points of View column — monthly visualization advice from Bang Wong and colleagues
- ACS Publications Figure Preparation Guide — journal-specific technical requirements
Related Skills
matplotlib-figures— Python implementation of publication-quality figures with matplotlib and seaborndata-visualization— general Python plotting recipesbiostatistics— statistical test selection to accompany figure annotations
skills/data-visualization/seaborn-statistical-plots/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill seaborn-statistical-plots -g -y
SKILL.md
Frontmatter
{
"name": "seaborn-statistical-plots",
"license": "BSD-3-Clause",
"description": "Statistical visualization on matplotlib with native pandas support. Auto aggregation, CIs, grouping for distributions (histplot, kdeplot), categorical (boxplot, violinplot), relational (scatterplot, lineplot), regression (regplot, lmplot), matrix (heatmap, clustermap), grids (pairplot, FacetGrid). Use for quick statistical summaries; matplotlib for fine control; plotly for interactive HTML."
}
Seaborn — Statistical Plots
Overview
Seaborn is a Python library for statistical data visualization built on top of matplotlib. It works directly with pandas DataFrames, automatically handles grouping by categorical variables, computes confidence intervals and kernel density estimates, and produces attractive publication-ready figures with minimal configuration. Seaborn separates axes-level functions (embeddable in custom layouts) from figure-level functions (with built-in faceting), enabling both quick exploratory analysis and structured multi-panel figures.
When to Use
- Comparing gene expression, protein abundance, or measurement distributions across experimental conditions (treatment vs. control, cell lines, time points)
- Generating grouped box plots, violin plots, or strip plots to show both summary statistics and individual data points simultaneously
- Visualizing pairwise correlations in multi-gene or multi-feature datasets as annotated heatmaps
- Plotting regression fits with confidence bands between continuous variables (e.g., cell viability vs. drug concentration)
- Faceting a single plot type across multiple sample subsets, tissue types, or experimental batches in one call
- Rapid exploratory analysis of a new dataset using
pairplotto survey all pairwise relationships at once - Use
matplotlibdirectly when you need pixel-level control over figure elements, complex mixed-type layouts, or non-statistical custom plots - Use
plotlywhen the output must be interactive (hover tooltips, zoom, pan) or embedded in a web application
Prerequisites
- Python packages:
seaborn>=0.13,matplotlib,pandas,numpy - Data requirements: Pandas DataFrame in long-form (tidy) format; each observation is a row, each variable is a column
- Environment: Standard Python environment; no GPU or special hardware required
pip install "seaborn>=0.13" matplotlib pandas numpy scipy
Quick Start
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
# Simulate gene expression across conditions
rng = np.random.default_rng(42)
df = pd.DataFrame({
"gene": ["BRCA1"] * 60 + ["TP53"] * 60,
"condition": ["control", "treated"] * 60,
"log2_expr": np.concatenate([
rng.normal(5.2, 0.8, 60),
rng.normal(6.1, 0.9, 60),
])
})
sns.set_theme(style="ticks", context="notebook")
sns.boxplot(data=df, x="gene", y="log2_expr", hue="condition", palette="Set2")
plt.ylabel("log2 Expression")
plt.title("Gene Expression by Condition")
plt.tight_layout()
plt.savefig("quickstart_boxplot.png", dpi=150)
print("Saved quickstart_boxplot.png")
Core API
1. Distribution Plots
Visualize univariate distributions and compare them across groups. histplot bins data; kdeplot fits a smooth density estimate; displot is the figure-level wrapper that adds faceting.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
n = 200
df = pd.DataFrame({
"log2_tpm": np.concatenate([rng.normal(4.5, 1.1, n), rng.normal(6.0, 1.3, n)]),
"sample": ["tumor"] * n + ["normal"] * n,
})
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# Histogram with density normalization and stacked hue groups
sns.histplot(data=df, x="log2_tpm", hue="sample", stat="density",
multiple="stack", bins=30, ax=axes[0])
axes[0].set_title("Histogram (stacked)")
# KDE with fill — bandwidth controlled by bw_adjust
sns.kdeplot(data=df, x="log2_tpm", hue="sample", fill=True,
bw_adjust=0.8, alpha=0.4, ax=axes[1])
axes[1].set_title("KDE (filled)")
# ECDF — useful for comparing cumulative distributions
sns.ecdfplot(data=df, x="log2_tpm", hue="sample", ax=axes[2])
axes[2].set_title("ECDF")
plt.tight_layout()
plt.savefig("distributions.png", dpi=150)
print("Saved distributions.png")
# Bivariate KDE: joint distribution of two continuous variables
rng = np.random.default_rng(1)
df2 = pd.DataFrame({
"log2_rna": rng.normal(5.5, 1.2, 300),
"log2_prot": rng.normal(4.8, 1.0, 300) + 0.6 * rng.normal(5.5, 1.2, 300),
})
sns.kdeplot(data=df2, x="log2_rna", y="log2_prot",
fill=True, levels=8, thresh=0.05, cmap="Blues")
plt.xlabel("log2 RNA (TPM)")
plt.ylabel("log2 Protein (iBAQ)")
plt.title("RNA–Protein Correlation Density")
plt.tight_layout()
plt.savefig("bivariate_kde.png", dpi=150)
print("Saved bivariate_kde.png")
2. Categorical Plots
Compare distributions or aggregated statistics across categorical groups. Axes-level functions (boxplot, violinplot, stripplot, swarmplot, barplot) accept an ax= parameter for embedding in custom layouts.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(2)
conditions = ["DMSO", "Drug A 1uM", "Drug A 10uM", "Drug B 1uM", "Drug B 10uM"]
df = pd.DataFrame({
"condition": np.repeat(conditions, 30),
"viability": np.concatenate([
rng.normal(100, 5, 30),
rng.normal(92, 7, 30),
rng.normal(65, 10, 30),
rng.normal(88, 8, 30),
rng.normal(45, 12, 30),
])
})
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Box plot — shows quartiles and outliers
sns.boxplot(data=df, x="condition", y="viability",
palette="husl", width=0.5, ax=axes[0])
axes[0].set_xticklabels(axes[0].get_xticklabels(), rotation=30, ha="right")
axes[0].set_title("Box Plot")
# Violin — KDE shape + inner quartile lines
sns.violinplot(data=df, x="condition", y="viability",
inner="quart", palette="muted", ax=axes[1])
axes[1].set_xticklabels(axes[1].get_xticklabels(), rotation=30, ha="right")
axes[1].set_title("Violin Plot")
# Strip plot overlaid on box — shows all individual points
sns.boxplot(data=df, x="condition", y="viability",
palette="pastel", width=0.5, ax=axes[2])
sns.stripplot(data=df, x="condition", y="viability",
color="black", alpha=0.4, size=3, jitter=True, ax=axes[2])
axes[2].set_xticklabels(axes[2].get_xticklabels(), rotation=30, ha="right")
axes[2].set_title("Box + Strip")
plt.tight_layout()
plt.savefig("categorical.png", dpi=150)
print("Saved categorical.png")
# Bar plot with mean ± 95% CI and individual points (swarm)
fig, ax = plt.subplots(figsize=(8, 5))
sns.barplot(data=df, x="condition", y="viability",
estimator="mean", errorbar="ci", palette="Set3", ax=ax)
sns.swarmplot(data=df, x="condition", y="viability",
color="black", size=3, alpha=0.5, ax=ax)
ax.set_ylabel("Cell Viability (%)")
ax.set_xticklabels(ax.get_xticklabels(), rotation=30, ha="right")
plt.tight_layout()
plt.savefig("barswarm.png", dpi=150)
print("Saved barswarm.png")
3. Relational Plots
Visualize relationships between continuous variables. scatterplot and lineplot are axes-level; relplot is the figure-level wrapper that supports col and row faceting.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(3)
n = 150
df = pd.DataFrame({
"molecular_weight": rng.uniform(200, 800, n),
"logP": rng.uniform(-2, 6, n),
"pIC50": rng.normal(6.5, 1.2, n),
"target_class": rng.choice(["kinase", "GPCR", "protease"], n),
"pass_lipinski": rng.choice(["yes", "no"], n, p=[0.7, 0.3]),
})
# Scatter with hue (categorical color) + size (continuous) + style (marker)
sns.scatterplot(data=df, x="molecular_weight", y="pIC50",
hue="target_class", size="logP", style="pass_lipinski",
sizes=(30, 120), alpha=0.7)
plt.xlabel("Molecular Weight (Da)")
plt.ylabel("pIC50")
plt.title("Compound Bioactivity by Target Class")
plt.tight_layout()
plt.savefig("relational_scatter.png", dpi=150)
print("Saved relational_scatter.png")
# Line plot with automatic mean aggregation and SD error band across replicates
timepoints = [0, 1, 2, 4, 8, 24]
groups = ["untreated", "low_dose", "high_dose"]
rows = []
for grp, base in zip(groups, [100.0, 95.0, 80.0]):
for tp in timepoints:
for _ in range(5): # 5 replicates
rows.append({"timepoint_h": tp, "group": grp,
"confluency": base * np.exp(-0.02 * tp * (1 + rng.normal(0, 0.1)))})
time_df = pd.DataFrame(rows)
sns.lineplot(data=time_df, x="timepoint_h", y="confluency",
hue="group", style="group", errorbar="sd", markers=True, dashes=False)
plt.xlabel("Time (h)")
plt.ylabel("Confluency (%)")
plt.title("Cell Growth Inhibition (mean ± SD, n=5)")
plt.tight_layout()
plt.savefig("lineplot.png", dpi=150)
print("Saved lineplot.png")
4. Regression Plots
Fit linear (or polynomial/lowess) models and visualize them with confidence bands. regplot is axes-level; lmplot is figure-level with faceting support.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(4)
n = 120
tumor_size = rng.uniform(0.5, 6.0, n)
survival_months = 40 - 5 * tumor_size + rng.normal(0, 4, n)
grade = rng.choice(["low", "high"], n, p=[0.5, 0.5])
df = pd.DataFrame({"tumor_size_cm": tumor_size,
"survival_months": survival_months,
"grade": grade})
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# Linear regression with 95% CI band
sns.regplot(data=df, x="tumor_size_cm", y="survival_months",
ci=95, scatter_kws={"alpha": 0.4, "s": 25}, ax=axes[0])
axes[0].set_title("Linear Regression (95% CI)")
# Residuals plot — check for homoscedasticity
sns.residplot(data=df, x="tumor_size_cm", y="survival_months",
scatter_kws={"alpha": 0.4, "s": 25}, ax=axes[1])
axes[1].axhline(0, color="red", linestyle="--", linewidth=1)
axes[1].set_title("Residuals vs Fitted")
plt.tight_layout()
plt.savefig("regression.png", dpi=150)
print("Saved regression.png")
# lmplot — figure-level: separate regression lines per grade (hue) + facets
g = sns.lmplot(data=df, x="tumor_size_cm", y="survival_months",
hue="grade", col="grade", ci=95,
scatter_kws={"alpha": 0.4}, height=4, aspect=1.1)
g.set_axis_labels("Tumor Size (cm)", "Survival (months)")
g.set_titles("{col_name} grade")
g.savefig("lmplot_faceted.png", dpi=150)
print("Saved lmplot_faceted.png")
5. Matrix Plots
Visualize rectangular data as color-encoded matrices. heatmap is axes-level; clustermap is figure-level and applies hierarchical clustering to rows and columns.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(5)
genes = [f"GENE{i}" for i in range(1, 9)]
samples = [f"S{i}" for i in range(1, 7)]
# Simulate log2 fold-change matrix (rows=genes, cols=samples)
lfc = pd.DataFrame(
rng.normal(0, 1.5, size=(8, 6)),
index=genes, columns=samples
)
# Inject a pattern: first 3 genes up in samples 1-3, down in 4-6
lfc.iloc[:3, :3] += 2.5
lfc.iloc[:3, 3:] -= 2.5
# Correlation heatmap of numeric features
df_num = pd.DataFrame(
rng.standard_normal((80, 5)),
columns=["GeneA", "GeneB", "GeneC", "GeneD", "GeneE"]
)
df_num["GeneB"] = df_num["GeneA"] * 0.85 + rng.normal(0, 0.3, 80)
corr = df_num.corr()
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm",
center=0, square=True, linewidths=0.5, ax=axes[0])
axes[0].set_title("Pearson Correlation Heatmap")
sns.heatmap(lfc, cmap="RdBu_r", center=0, annot=True, fmt=".1f",
linewidths=0.3, cbar_kws={"label": "log2FC"}, ax=axes[1])
axes[1].set_title("log2 Fold Change Matrix")
plt.tight_layout()
plt.savefig("heatmaps.png", dpi=150)
print("Saved heatmaps.png")
# Clustermap with hierarchical clustering and row/column color annotations
rng = np.random.default_rng(6)
n_genes, n_samples = 30, 16
expr = pd.DataFrame(
rng.lognormal(mean=2.0, sigma=1.2, size=(n_genes, n_samples)),
index=[f"GENE{i:03d}" for i in range(n_genes)],
columns=[f"{'T' if i < 8 else 'N'}{i:02d}" for i in range(n_samples)]
)
# Column annotation colors (tumor vs normal)
col_colors = ["#D32F2F" if c.startswith("T") else "#1976D2" for c in expr.columns]
g = sns.clustermap(
np.log2(expr + 1),
cmap="viridis",
standard_scale=0, # z-score across rows (genes)
method="ward",
metric="euclidean",
col_colors=col_colors,
figsize=(12, 10),
linewidths=0,
cbar_pos=(0.02, 0.8, 0.03, 0.15),
cbar_kws={"label": "Row z-score"},
)
g.ax_heatmap.set_xlabel("Sample")
g.ax_heatmap.set_ylabel("Gene")
plt.savefig("clustermap.png", dpi=150, bbox_inches="tight")
print("Saved clustermap.png")
6. Multi-Variable Grids
Survey all pairwise relationships with pairplot or display a bivariate distribution with marginals using jointplot. For fully custom grid layouts, use FacetGrid directly.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n = 60
df = pd.DataFrame({
"cell_area": rng.normal(350, 60, n * 3),
"nucleus_area": rng.normal(90, 15, n * 3),
"mean_intensity": rng.exponential(500, n * 3),
"aspect_ratio": np.abs(rng.normal(1.3, 0.3, n * 3)),
"cell_type": (["HeLa"] * n + ["MCF7"] * n + ["A549"] * n),
})
# Pairplot — matrix of pairwise scatter + KDE on diagonal
g = sns.pairplot(df, hue="cell_type", corner=True,
diag_kind="kde", plot_kws={"alpha": 0.5, "s": 20})
g.savefig("pairplot.png", dpi=150)
print("Saved pairplot.png")
# Jointplot — bivariate KDE with marginal histograms
g = sns.jointplot(data=df, x="cell_area", y="nucleus_area",
hue="cell_type", kind="scatter",
marginal_kws={"fill": True, "alpha": 0.3})
g.set_axis_labels("Cell Area (µm²)", "Nucleus Area (µm²)")
g.savefig("jointplot.png", dpi=150)
print("Saved jointplot.png")
# FacetGrid — custom layout: KDE of mean_intensity per cell type
g = sns.FacetGrid(df, col="cell_type", height=3.5, aspect=1.1,
sharey=False)
g.map(sns.histplot, "mean_intensity", bins=20, kde=True, color="steelblue")
g.set_axis_labels("Mean Intensity (AU)", "Count")
g.set_titles("{col_name}")
g.tight_layout()
g.savefig("facetgrid_intensity.png", dpi=150)
print("Saved facetgrid_intensity.png")
Key Concepts
Figure-Level vs Axes-Level Functions
Seaborn has two tiers of functions with different return types and composability:
| Feature | Axes-Level | Figure-Level |
|---|---|---|
| Examples | scatterplot, histplot, boxplot, heatmap, regplot |
relplot, displot, catplot, lmplot |
| Returns | matplotlib.axes.Axes |
FacetGrid / JointGrid / PairGrid |
| Faceting | Manual (create subplots yourself) | Built-in (col=, row= params) |
| Sizing | figsize= on parent figure |
height= + aspect= per facet panel |
| Placement | ax= parameter |
Cannot be placed in an existing axes |
| Saving | plt.savefig(...) |
g.savefig(...) |
| Use when | Combining different plot types in one figure | Quick multi-panel exploratory views |
# Axes-level: place in a pre-allocated subplot grid
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sns.violinplot(data=df, x="cell_type", y="cell_area", ax=axes[0])
sns.scatterplot(data=df, x="cell_area", y="nucleus_area", hue="cell_type", ax=axes[1])
Long-Form vs Wide-Form Data
Seaborn semantic mappings (hue, size, style) require long-form (tidy) data where each variable is a column and each observation is a row. Some functions (heatmap, clustermap, lineplot) also accept wide-form.
# Wide-form: unsuitable for hue/style mappings
# sample_A sample_B sample_C
# 0 5.1 6.2 4.8
# Long-form (preferred): melt wide → long
wide = pd.DataFrame({"sampleA": [5.1, 4.3], "sampleB": [6.2, 5.9]})
long = wide.melt(var_name="sample", value_name="log2_expr")
# → columns: sample, log2_expr
Common Workflows
Workflow 1: Differential Expression Scatter with Significance Thresholds
Goal: Visualize log2 fold-change vs -log10 p-value (volcano-style) with significance annotations, colored by regulation status, and labeled top hits.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 500
lfc = rng.normal(0, 1.5, n)
pvals = 10 ** (-rng.exponential(1.5, n)) # skewed toward low significance
pvals = np.clip(pvals, 1e-20, 1.0)
genes = [f"GENE{i:04d}" for i in range(n)]
df_de = pd.DataFrame({"gene": genes, "log2fc": lfc, "pvalue": pvals})
df_de["neg_log10_p"] = -np.log10(df_de["pvalue"])
# Classify regulation status
lfc_thresh = 1.0
padj_thresh = 0.05
df_de["sig"] = "NS"
df_de.loc[(df_de["log2fc"] > lfc_thresh) & (df_de["pvalue"] < padj_thresh), "sig"] = "Up"
df_de.loc[(df_de["log2fc"] < -lfc_thresh) & (df_de["pvalue"] < padj_thresh), "sig"] = "Down"
palette = {"NS": "#AAAAAA", "Up": "#D32F2F", "Down": "#1976D2"}
sns.set_theme(style="ticks", context="paper", font_scale=1.1)
fig, ax = plt.subplots(figsize=(8, 6))
sns.scatterplot(data=df_de, x="log2fc", y="neg_log10_p",
hue="sig", palette=palette,
alpha=0.6, s=18, linewidth=0, ax=ax)
# Threshold lines
ax.axhline(-np.log10(padj_thresh), color="black", linestyle="--", linewidth=0.8)
ax.axvline( lfc_thresh, color="black", linestyle="--", linewidth=0.8)
ax.axvline(-lfc_thresh, color="black", linestyle="--", linewidth=0.8)
# Label top 5 most significant genes per direction
for direction in ["Up", "Down"]:
top = df_de[df_de["sig"] == direction].nlargest(5, "neg_log10_p")
for _, row in top.iterrows():
ax.text(row["log2fc"], row["neg_log10_p"] + 0.3, row["gene"],
fontsize=6, ha="center", va="bottom",
color=palette[direction])
# Annotation counts
n_up = (df_de["sig"] == "Up").sum()
n_down = (df_de["sig"] == "Down").sum()
ax.set_title(f"Volcano Plot | Up: {n_up} Down: {n_down}")
ax.set_xlabel("log2 Fold Change")
ax.set_ylabel("-log10 p-value")
sns.despine(trim=True)
plt.tight_layout()
plt.savefig("volcano_plot.png", dpi=300, bbox_inches="tight")
print(f"Volcano: {n_up} up, {n_down} down — saved volcano_plot.png")
Workflow 2: Multi-Condition Comparison with Grouped Violin + Strip Plots
Goal: Compare gene expression (or any continuous measurement) across multiple treatments and time points, showing full distributions plus individual replicates.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(99)
genes = ["BRCA1", "TP53", "EGFR"]
treats = ["DMSO", "Drug A", "Drug B"]
timepoints = ["6h", "24h", "48h"]
rows = []
for gene in genes:
base_expr = {"BRCA1": 7.5, "TP53": 6.2, "EGFR": 8.1}[gene]
for treat in treats:
treat_shift = {"DMSO": 0.0, "Drug A": -0.8, "Drug B": 0.6}[treat]
for tp in timepoints:
tp_shift = {"6h": 0.0, "24h": 0.3, "48h": 0.6}[tp]
for _ in range(12):
rows.append({
"gene": gene,
"treatment": treat,
"timepoint": tp,
"log2_expr": base_expr + treat_shift + tp_shift + rng.normal(0, 0.5),
})
df_mc = pd.DataFrame(rows)
sns.set_theme(style="whitegrid", context="paper", font_scale=1.0)
g = sns.catplot(
data=df_mc,
x="timepoint", y="log2_expr",
hue="treatment",
col="gene",
kind="violin",
inner="quart",
dodge=True,
palette="Set2",
height=4, aspect=0.9,
col_order=genes,
order=timepoints,
)
# Overlay individual points
for ax in g.axes.flat:
gene_label = ax.get_title()
gene_name = gene_label.split(" = ")[-1] if " = " in gene_label else gene_label
subset = df_mc[df_mc["gene"] == gene_name]
sns.stripplot(
data=subset,
x="timepoint", y="log2_expr",
hue="treatment",
dodge=True,
jitter=True,
size=2.5,
alpha=0.4,
palette="dark:black",
order=timepoints,
legend=False,
ax=ax,
)
g.set_axis_labels("Timepoint", "log2 Expression")
g.set_titles("{col_name}")
g.add_legend(title="Treatment")
sns.despine(trim=True)
g.tight_layout()
g.savefig("multigroup_violin.png", dpi=300, bbox_inches="tight")
print("Saved multigroup_violin.png")
Workflow 3: Pairwise Feature Exploration for Cell Morphology
Goal: Quickly survey pairwise relationships in a multi-feature cell morphology dataset using pairplot, then examine one key pair with a jointplot.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(12)
n_per_type = 80
df_morph = pd.DataFrame({
"cell_area_um2": np.concatenate([rng.normal(320, 50, n_per_type),
rng.normal(420, 70, n_per_type),
rng.normal(280, 40, n_per_type)]),
"nucleus_area_um2": np.concatenate([rng.normal(85, 12, n_per_type),
rng.normal(110, 18, n_per_type),
rng.normal(75, 10, n_per_type)]),
"eccentricity": np.abs(np.concatenate([rng.normal(0.6, 0.12, n_per_type),
rng.normal(0.8, 0.10, n_per_type),
rng.normal(0.5, 0.09, n_per_type)])),
"mean_dapi": np.concatenate([rng.exponential(400, n_per_type),
rng.exponential(600, n_per_type),
rng.exponential(350, n_per_type)]),
"cell_line": ["HeLa"] * n_per_type + ["MCF7"] * n_per_type + ["U2OS"] * n_per_type,
})
# 1. Pairplot survey
g = sns.pairplot(df_morph, hue="cell_line", corner=True,
diag_kind="kde", plot_kws={"alpha": 0.5, "s": 15},
palette="Dark2")
g.savefig("morphology_pairplot.png", dpi=150)
print("Saved morphology_pairplot.png")
# 2. Focused jointplot for the most informative pair
g2 = sns.jointplot(data=df_morph, x="cell_area_um2", y="nucleus_area_um2",
hue="cell_line", kind="scatter",
marginal_kws={"fill": True, "alpha": 0.25},
palette="Dark2", alpha=0.6)
g2.set_axis_labels("Cell Area (µm²)", "Nucleus Area (µm²)")
g2.savefig("morphology_jointplot.png", dpi=150)
print("Saved morphology_jointplot.png")
Key Parameters
| Parameter | Function(s) | Default | Range / Options | Effect |
|---|---|---|---|---|
hue |
All plot functions | None |
Column name (categorical or continuous) | Color-encodes a variable; triggers automatic legend |
style |
scatterplot, lineplot |
None |
Categorical column name | Encodes variable with marker shape or line dash pattern |
size |
scatterplot, lineplot |
None |
Categorical or continuous column | Encodes variable via point or line size |
col / row |
Figure-level only (relplot, displot, catplot, lmplot) |
None |
Categorical column name | Creates one subplot panel per unique value |
col_wrap |
Figure-level only | None |
int | Wraps columns onto a new row after N panels |
estimator |
barplot, pointplot |
"mean" |
"mean", "median", any callable |
Aggregation function applied within each category |
errorbar |
barplot, lineplot, pointplot |
("ci", 95) |
"ci", "sd", "se", "pi", None |
Error bar type displayed around the estimate |
stat |
histplot |
"count" |
"count", "frequency", "density", "probability" |
Normalization applied to histogram bar heights |
bw_adjust |
kdeplot, violinplot |
1.0 |
0.1–3.0 |
KDE bandwidth multiplier; lower=spikier, higher=smoother |
multiple |
histplot, kdeplot |
"layer" |
"layer", "stack", "dodge", "fill" |
How overlapping hue groups are drawn |
inner |
violinplot |
"box" |
"box", "quart", "point", "stick", None |
Interior annotation inside the violin body |
standard_scale |
clustermap |
None |
0 (rows), 1 (columns) |
Z-score normalization axis before clustering |
dodge |
boxplot, violinplot, stripplot |
Varies | True, False |
Separate hue-grouped elements along the axis |
context |
set_theme() |
"notebook" |
"paper", "notebook", "talk", "poster" |
Scales font and line widths for output medium |
Best Practices
-
Prefer long-form DataFrames with named columns: Seaborn's semantic mapping (
hue,style,size) reads variable names directly from column names. Passing raw arrays loses axis labels and legends. Usepd.melt()to convert wide-form data. -
Call
set_theme()once at the top of a script: This sets the global style, context, and palette for all subsequent plots, ensuring consistency. Reset to defaults withsns.set_theme().sns.set_theme(style="ticks", context="paper", font_scale=1.1, rc={"axes.spines.right": False, "axes.spines.top": False}) -
Use axes-level functions for mixed-type custom layouts: Figure-level functions (
relplot,catplot) create their own figure and cannot be placed in an existingAxes. When combining different plot types (e.g., scatter + violin + heatmap), allocate aplt.subplots()grid and use axes-level functions withax=. -
Use colorblind-safe palettes:
sns.set_palette("colorblind")orpalette="colorblind"produces a palette distinguishable by readers with common color vision deficiencies. For diverging data, use"RdBu_r"or"coolwarm"withcenter=0. -
Overlay individual data points on summary plots: Violin and bar plots hide distribution shape and sample size. Overlaying a
stripplotorswarmplotwithalpha=0.4and smallsizeconveys data density without obscuring the summary statistic. -
Size figure-level plots with
heightandaspect, notfigsize: Figure-level functions ignorefigsize. Useheight=(inches per panel) andaspect=(width-to-height ratio per panel). For axes-level, setfigsizeon theplt.subplots()call. -
Anti-pattern — calling
plt.savefig()on a figure-level grid: Figure-level functions return aFacetGrid/JointGridobject. Save it withg.savefig("out.png", dpi=300, bbox_inches="tight"), notplt.savefig(), which may capture a blank figure.
Common Recipes
Recipe: Publication-Ready Figure with Custom Palette and 300 DPI Export
When to use: Preparing a multi-panel figure for journal submission or a slide deck.
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
sns.set_theme(style="ticks", context="paper", font_scale=1.2,
rc={"pdf.fonttype": 42, "ps.fonttype": 42})
rng = np.random.default_rng(7)
df = pd.DataFrame({
"condition": np.repeat(["Control", "Treated"], 40),
"ki67_pct": np.concatenate([rng.normal(18, 4, 40), rng.normal(32, 6, 40)]),
"apoptosis": np.concatenate([rng.normal(5, 1.5, 40), rng.normal(12, 2.5, 40)]),
})
custom_palette = {"Control": "#4575B4", "Treated": "#D73027"}
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
# Panel A
sns.boxplot(data=df, x="condition", y="ki67_pct",
palette=custom_palette, width=0.45, linewidth=1.2, ax=axes[0])
sns.stripplot(data=df, x="condition", y="ki67_pct",
color="black", alpha=0.35, size=3, jitter=True, ax=axes[0])
axes[0].set_ylabel("Ki67 Positive Cells (%)")
axes[0].set_xlabel("")
axes[0].set_title("A", loc="left", fontweight="bold")
# Panel B
sns.boxplot(data=df, x="condition", y="apoptosis",
palette=custom_palette, width=0.45, linewidth=1.2, ax=axes[1])
sns.stripplot(data=df, x="condition", y="apoptosis",
color="black", alpha=0.35, size=3, jitter=True, ax=axes[1])
axes[1].set_ylabel("Apoptotic Cells (%)")
axes[1].set_xlabel("")
axes[1].set_title("B", loc="left", fontweight="bold")
sns.despine(trim=True)
plt.tight_layout()
plt.savefig("figure1.pdf", dpi=300, bbox_inches="tight")
plt.savefig("figure1.png", dpi=300, bbox_inches="tight")
print("Saved figure1.pdf and figure1.png at 300 DPI")
Recipe: Clustered Heatmap with Row and Column Color Annotations
When to use: Displaying a gene expression matrix with sample group annotations and hierarchical clustering to reveal co-expression modules.
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
rng = np.random.default_rng(21)
n_genes, n_samples = 40, 20
conditions = ["tumor"] * 10 + ["normal"] * 10
# Simulate expression: 3 co-expression modules
expr = pd.DataFrame(
rng.lognormal(2.5, 0.8, (n_genes, n_samples)),
index=[f"GENE{i:03d}" for i in range(n_genes)],
columns=[f"{c[0].upper()}{i:02d}" for i, c in enumerate(conditions)],
)
# Module 1: genes 0-13 up in tumor
expr.iloc[:14, :10] *= 3.0
# Module 2: genes 14-27 down in tumor
expr.iloc[14:28, :10] *= 0.3
# Module 3: genes 28-39 unchanged
log_expr = np.log2(expr + 1)
# Column colors: tumor=red, normal=blue
cond_pal = {"tumor": "#C62828", "normal": "#1565C0"}
col_colors = [cond_pal[c] for c in conditions]
# Row colors: module membership
module_pal = {"up": "#EF9A9A", "down": "#90CAF9", "stable": "#C8E6C9"}
row_modules = (["up"] * 14) + (["down"] * 14) + (["stable"] * 12)
row_colors = [module_pal[m] for m in row_modules]
g = sns.clustermap(
log_expr,
cmap="RdYlBu_r",
center=log_expr.values.mean(),
standard_scale=0, # z-score per gene (row)
method="ward",
metric="euclidean",
col_colors=col_colors,
row_colors=row_colors,
figsize=(14, 12),
linewidths=0,
cbar_pos=(0.02, 0.85, 0.03, 0.12),
cbar_kws={"label": "Row z-score"},
dendrogram_ratio=(0.12, 0.08),
)
g.ax_heatmap.set_xlabel("Sample", fontsize=10)
g.ax_heatmap.set_ylabel("Gene", fontsize=10)
g.ax_heatmap.set_title("Gene Expression Clustermap", fontsize=12, pad=80)
# Manual legend for column/row annotations
legend_handles = [
mpatches.Patch(color="#C62828", label="Tumor"),
mpatches.Patch(color="#1565C0", label="Normal"),
mpatches.Patch(color="#EF9A9A", label="Up in tumor"),
mpatches.Patch(color="#90CAF9", label="Down in tumor"),
mpatches.Patch(color="#C8E6C9", label="Stable"),
]
g.ax_heatmap.legend(handles=legend_handles, bbox_to_anchor=(1.25, 1.05),
loc="upper left", frameon=False, fontsize=9)
plt.savefig("clustermap_annotated.png", dpi=300, bbox_inches="tight")
print("Saved clustermap_annotated.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Legend placed outside plot, clipped in saved file | Figure-level functions place the legend outside by default | Add bbox_inches="tight" to savefig(): g.savefig("out.png", dpi=300, bbox_inches="tight") |
TypeError: FacetGrid.savefig() or blank figure saved |
Called plt.savefig() on a figure-level grid that owns its own figure |
Use g.savefig(...) instead of plt.savefig(...) |
| Overlapping x-axis category labels | Long label strings overlap at default rotation | Add plt.xticks(rotation=45, ha="right") and plt.tight_layout() after the plot call |
ValueError: Could not interpret value ... for parameter 'hue' |
Data is in wide-form; hue mapping requires long-form | Convert with df.melt(id_vars=[...], var_name="sample", value_name="expr") |
| KDE bandwidth too smooth (loses bimodality) | Default bw_adjust=1.0 over-smooths small datasets |
Lower to bw_adjust=0.5; confirm peaks with histplot |
clustermap ignores figsize |
Figure-level functions do not accept figsize as a kwarg in older seaborn |
Pass figsize as a direct argument: sns.clustermap(..., figsize=(12, 10)) |
| Violin plot is a thin line (no shape) | Too few observations for KDE estimation | Switch to kind="box" or kind="strip"; or use cut=0 to restrict KDE to data range |
| Colors not distinguishable for many groups | Default palette repeats with >6 categories | Use sns.color_palette("husl", n_colors=N) or "tab20" for up to 20 distinct colors |
Figure-level function ignores ax= parameter |
Axes-level distinction: figure-level functions create their own figure | Use the corresponding axes-level function (scatterplot, histplot, etc.) with ax= |
Related Skills
- matplotlib-scientific-plotting — low-level figure building, custom annotations, non-statistical plot types, and multi-panel layouts that mix seaborn with raw matplotlib
- plotly-interactive-plots — interactive charts with hover, zoom, and HTML/Dash export
- pydeseq2-differential-expression — produces the log2FC and p-values that feed into volcano-style scatter plots
- scikit-image-processing — generates cell morphology measurements visualized with seaborn categorical/distribution plots
- scientific-visualization — decision guide for selecting the right chart type and color scheme before coding
References
- Seaborn official documentation — API reference, tutorial, and gallery
- Seaborn example gallery — visual index of all plot types
- Seaborn GitHub — source code and issue tracker
- Waskom ML (2021). "seaborn: statistical data visualization." Journal of Open Source Software, 6(60), 3021. https://doi.org/10.21105/joss.03021
skills/data-visualization/statistical-significance-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill statistical-significance-annotation -g -y
SKILL.md
Frontmatter
{
"name": "statistical-significance-annotation",
"license": "CC-BY-4.0",
"description": "Guide for annotating statistical significance (p-value asterisks) on comparison plots. Covers standard notation (ns, *, **, ***, ****), matplotlib bracket+asterisk implementation, and use with seaborn box\/violin\/bar plots. Use when preparing publication-ready figures with significance markers."
}
Statistical Significance Annotation on Plots
Overview
Statistical significance annotations (asterisk notation) are visual markers placed on comparison plots to indicate the results of hypothesis tests between groups. They consist of brackets connecting two groups and asterisk symbols denoting the p-value range. Proper annotation ensures that the visual claims in a figure match the quantitative evidence, making plots publication-ready and scientifically rigorous. This guide covers the standard conventions, when and how to annotate, and a reusable matplotlib implementation.
Key Concepts
Standard Asterisk Notation
The widely adopted convention maps p-value ranges to asterisk symbols:
| Symbol | P-value Range | Meaning |
|---|---|---|
| ns | p > 0.05 | Not significant |
| * | p <= 0.05 | Significant |
| ** | p <= 0.01 | Highly significant |
| *** | p <= 0.001 | Very highly significant |
| **** | p <= 0.0001 | Extremely significant |
The conversion function:
def pvalue_to_asterisk(p: float) -> str:
"""Convert a p-value to standard asterisk notation."""
if p <= 0.0001:
return "****"
elif p <= 0.001:
return "***"
elif p <= 0.01:
return "**"
elif p <= 0.05:
return "*"
else:
return "ns"
Adjusted vs Raw P-values
- Single comparison (one t-test): Use raw p-value.
- Multiple comparisons (pairwise tests across 3+ groups, multiple genes): Use adjusted p-values (FDR/Benjamini-Hochberg or Bonferroni). Annotating with raw p-values inflates significance.
- Pre-computed results (DESeq2
padj, ANOVA post-hoc): Use the adjusted values already provided.
Comparison Selection
Not every pair of groups needs annotation. Select comparisons that:
- Directly support the claim made in the analysis text
- Are biologically meaningful (e.g., treatment vs control, not control-A vs control-B)
- Are limited in number to keep the figure readable (typically 1-5 per panel)
Decision Framework
Does the plot compare groups?
├── No (scatter, heatmap, PCA, line trend) → Do NOT annotate
└── Yes (box, violin, bar, strip)
├── Does the analysis claim significance? → Annotate the claimed comparisons
├── Exploratory (no specific claim) → Annotate vs control only, or skip
└── Too many groups (>6 pairwise) → Annotate key comparisons only
| Scenario | Annotate? | Which pairs |
|---|---|---|
| DEG box plot: treatment vs control | Yes | Treatment vs Control |
| Multi-group ANOVA with post-hoc | Yes | Significant post-hoc pairs only |
| Gene expression across 10 cell types | Selectively | vs reference cell type only |
| PCA or UMAP | No | N/A |
| Heatmap or volcano plot | No | N/A |
| Correlation scatter | No | Report r and p in text/legend |
| Exploratory bar plot, no hypothesis | Optional | vs control if applicable |
Best Practices
- Match annotations to text claims: Every asterisk on the plot must correspond to a statistical test described in the analysis. Never annotate without having computed the test.
- Use adjusted p-values for multiple comparisons: When testing more than one pair, always use FDR-corrected or Bonferroni-corrected p-values. State the correction method in the figure legend.
- Limit annotated pairs: Annotate only comparisons relevant to the analysis conclusion. Over-annotating clutters the figure and dilutes focus.
- Position brackets clearly: Place brackets above the data range with enough vertical offset to avoid overlapping with data points, error bars, or other brackets. Stack multiple brackets with consistent spacing.
- State the statistical test: Always note the test used (t-test, Mann-Whitney U, Wilcoxon, ANOVA + Tukey HSD, etc.) in the figure title, caption, or legend.
- Include sample sizes: Show n per group in the axis labels (e.g., "Control (n=30)") or figure legend.
- Use bold titles: Set
fontweight='bold'on figure titles for publication readiness.
Common Pitfalls
-
Annotating all pairwise comparisons in a multi-group plot
- How to avoid: Select only hypothesis-driven pairs. For k groups, k*(k-1)/2 pairs quickly becomes unreadable. Show vs control or specific contrasts only.
-
Using raw p-values when multiple comparisons were performed
- How to avoid: Apply
statsmodels.stats.multitest.multipletests(pvals, method='fdr_bh')or use adjusted p-values from upstream tools (DESeq2padj).
- How to avoid: Apply
-
Bracket overlap with data or other brackets
- How to avoid: Use incremental vertical offset for stacked brackets. Start the first bracket above the maximum data value + error bar, then add a fixed offset for each additional bracket.
-
Asterisks without stating which test was used
- How to avoid: Always include the test name in the plot title or annotation (e.g., "Mann-Whitney U test" or "Tukey HSD post-hoc").
-
Inconsistent notation across figures
- How to avoid: Use the same
pvalue_to_asterisk()function throughout the analysis. Define it once and reuse.
- How to avoid: Use the same
-
Annotating "ns" on every non-significant pair
- How to avoid: Only show "ns" when the non-significance itself is a notable finding (e.g., showing no difference between two treatments). Omit ns annotations for pairs not being compared.
-
Placing annotations below the data
- How to avoid: Always place brackets and asterisks above the compared groups, never below.
Workflow
Step 1: Compute Statistical Tests
Run the appropriate test and collect p-values before plotting:
from scipy import stats
# Two-group comparison
stat, pval = stats.mannwhitneyu(group_a, group_b, alternative='two-sided')
# or for normal data:
stat, pval = stats.ttest_ind(group_a, group_b)
# Multi-group: ANOVA + post-hoc
from scipy.stats import f_oneway
stat, pval_anova = f_oneway(group_a, group_b, group_c)
# Post-hoc pairwise (if ANOVA significant)
from itertools import combinations
from statsmodels.stats.multitest import multipletests
pairs = list(combinations(["A", "B", "C"], 2))
groups = {"A": group_a, "B": group_b, "C": group_c}
raw_pvals = []
for g1, g2 in pairs:
_, p = stats.mannwhitneyu(groups[g1], groups[g2], alternative='two-sided')
raw_pvals.append(p)
# Adjust for multiple comparisons
rejected, adj_pvals, _, _ = multipletests(raw_pvals, method='fdr_bh')
Step 2: Add Bracket Annotations to the Plot
Use this helper function to draw brackets with asterisks on any matplotlib axes:
def add_significance_bracket(ax, x1, x2, y, p_value, dh=0.02, barh=0.015, fontsize=11):
"""Draw a significance bracket with asterisk notation between two x positions.
Args:
ax: matplotlib Axes object.
x1, x2: x-axis positions of the two groups (0-indexed).
y: y-coordinate for the bracket (top of bracket line).
p_value: p-value for the comparison.
dh: vertical offset above bracket for the text (in axes fraction).
barh: height of the bracket tips (in axes fraction).
fontsize: font size for the asterisk text.
"""
asterisk = pvalue_to_asterisk(p_value)
# Draw bracket: two tips and a connecting line
ax.plot([x1, x1, x2, x2], [y - barh, y, y, y - barh],
lw=1.2, color='black')
# Place asterisk text centered above the bracket
ax.text((x1 + x2) / 2, y + dh, asterisk,
ha='center', va='bottom', fontsize=fontsize, fontweight='bold')
Step 3: Integrate with Seaborn Plots
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
# Example: box plot with significance annotation
fig, ax = plt.subplots(figsize=(6, 5))
sns.boxplot(data=df, x="group", y="value", ax=ax, palette="Set2")
sns.stripplot(data=df, x="group", y="value", ax=ax,
color="black", alpha=0.4, size=3, jitter=True)
# Determine bracket y-position from data
y_max = df["value"].max()
y_range = df["value"].max() - df["value"].min()
offset = y_range * 0.08 # spacing between brackets
# Add brackets for each significant comparison
# pairs_with_pvals: list of (group1_idx, group2_idx, p_value)
pairs_with_pvals = [(0, 1, 0.003), (0, 2, 0.042)]
for i, (x1, x2, pval) in enumerate(pairs_with_pvals):
bracket_y = y_max + offset * (i + 1)
add_significance_bracket(ax, x1, x2, bracket_y, pval)
ax.set_title("Gene Expression by Treatment", fontweight='bold', fontsize=14)
ax.set_ylabel("Expression (log2 CPM)")
# Extend y-axis to fit brackets
ax.set_ylim(top=y_max + offset * (len(pairs_with_pvals) + 1.5))
plt.tight_layout()
plt.savefig("expression_comparison.png", dpi=150, bbox_inches='tight')
Step 4: Annotating Grouped Bar Plots
For bar plots with error bars, position brackets above the error bars:
fig, ax = plt.subplots(figsize=(7, 5))
bar_plot = sns.barplot(data=df, x="gene", y="fold_change", hue="condition",
ax=ax, palette="Set2", ci="sd", capsize=0.05)
# For grouped bars, calculate x positions manually
# Each gene has multiple bars offset by group
n_groups = df["condition"].nunique()
n_genes = df["gene"].nunique()
bar_width = 0.8 / n_groups
for gene_idx in range(n_genes):
# x positions of the two bars within this gene group
x1 = gene_idx - bar_width / 2
x2 = gene_idx + bar_width / 2
# Get the max value + error for this gene
gene_data = df[df["gene"] == df["gene"].unique()[gene_idx]]
y_top = gene_data["fold_change"].mean() + gene_data["fold_change"].std()
p_val = pvals_per_gene[gene_idx] # pre-computed
if p_val <= 0.05: # only annotate significant results
add_significance_bracket(ax, x1, x2, y_top + 0.1, p_val)
ax.set_title("Fold Change by Condition", fontweight='bold', fontsize=14)
plt.tight_layout()
plt.savefig("fold_change_comparison.png", dpi=150, bbox_inches='tight')
Protocol Guidelines
- Always compute tests before plotting: The statistical test should be run and results stored before any plotting code. Do not compute p-values inside the plotting block.
- Use consistent style: Use the same
add_significance_bracketfunction andpvalue_to_asteriskconversion across all figures in an analysis. - Report test details in solution text: When presenting the figure, state: the test used, number of samples per group, and whether p-values are adjusted.
- Adjust y-axis limits: After adding brackets, extend the y-axis upper limit to prevent clipping. Use
ax.set_ylim(top=...)orax.margins(y=0.15). - For DESeq2/edgeR results: Use
padj(adjusted p-value) directly. Do not re-test the raw counts.
Further Reading
- Graphpad: How to report statistical significance — Asterisk notation conventions
- Nature Methods: Points of Significance — Statistical best practices for figures
- Weissgerber et al. (2015) Beyond Bar and Line Graphs — Why to show individual data points alongside statistical annotations
Related Skills
seaborn-statistical-plots— Seaborn plotting fundamentals; use this guide's annotation workflow on top of seaborn figuresscientific-visualization— General scientific figure design principles
skills/genomics-bioinformatics/alignment/bwa-mem2-dna-aligner/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill bwa-mem2-dna-aligner -g -y
SKILL.md
Frontmatter
{
"name": "bwa-mem2-dna-aligner",
"license": "MIT",
"description": "Fast short-read DNA aligner for WGS\/WES\/ChIP-seq. 2× faster BWA-MEM successor; outputs SAM\/BAM with read group headers for GATK. Primary plus supplementary records for chimeric reads. Use STAR for RNA-seq splice-aware alignment; Bowtie2 is a comparable alternative."
}
BWA-MEM2 — DNA Short-Read Aligner
Overview
BWA-MEM2 aligns short DNA reads (Illumina, 50–250 bp) to a reference genome using the BWT-FM index. It is the standard aligner for whole-genome sequencing (WGS), whole-exome sequencing (WES), ChIP-seq, and ATAC-seq DNA alignment. BWA-MEM2 is 2× faster than the original BWA-MEM while producing identical results. It outputs SAM format with proper read group (@RG) headers required by GATK HaplotypeCaller and Picard tools. For paired-end reads, it marks proper pairs and resolves chimeric/split reads into supplementary alignments.
When to Use
- Aligning WGS or WES Illumina reads to a reference genome for variant calling (SNP, indel, SV)
- ChIP-seq or ATAC-seq DNA alignment to produce BAM files for peak calling with MACS3
- Producing GATK-compatible BAM files with
@RGread group tags - Aligning reads ≥ 50 bp; for shorter reads (< 50 bp), BWA-backtrack may be more appropriate
- Re-aligning legacy FASTQ files to an updated reference genome assembly
- Use STAR instead for RNA-seq reads that span splice junctions
- Use Bowtie2 as an alternative for local alignment or when index size must be minimized
Prerequisites
- Software: bwa-mem2 (conda or pre-compiled binary), samtools
- Reference: genome FASTA (e.g., GRCh38, hg19, mm10)
- RAM: ~28 GB for human genome index; 6–8 GB for mouse
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v bwa-mem2first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run bwa-mem2rather than barebwa-mem2.
# Install with conda (recommended)
conda install -c bioconda bwa-mem2 samtools
# Or download pre-compiled binary
wget https://github.com/bwa-mem2/bwa-mem2/releases/download/v2.2.1/bwa-mem2-2.2.1_x64-linux.tar.bz2
tar -jxf bwa-mem2-2.2.1_x64-linux.tar.bz2
export PATH="$PWD/bwa-mem2-2.2.1_x64-linux:$PATH"
# Verify
bwa-mem2 version
# 2.2.1
Quick Start
# 1. Build genome index (~30 min, run once)
bwa-mem2 index GRCh38.fa
# 2. Align paired-end reads and sort
bwa-mem2 mem -t 16 -R "@RG\tID:sample1\tSM:sample1\tPL:ILLUMINA" \
GRCh38.fa sample1_R1.fastq.gz sample1_R2.fastq.gz \
| samtools sort -@ 8 -o sample1.sorted.bam
# 3. Index the BAM
samtools index sample1.sorted.bam
echo "Aligned reads: $(samtools view -c -F 4 sample1.sorted.bam)"
Workflow
Step 1: Download Reference Genome
Obtain the reference genome FASTA file matching the target assembly.
# Download GRCh38 primary assembly (human)
wget https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA/000/001/405/GCA_000001405.15_GRCh38/seqs_for_alignment_pipelines.ucsc_ids/GCA_000001405.15_GRCh38_no_alt_analysis_set.fna.gz
gunzip GCA_000001405.15_GRCh38_no_alt_analysis_set.fna.gz
mv GCA_000001405.15_GRCh38_no_alt_analysis_set.fna GRCh38.fa
# Or use ENSEMBL/GENCODE
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_47/GRCh38.primary_assembly.genome.fa.gz
gunzip GRCh38.primary_assembly.genome.fa.gz
echo "Reference size: $(du -sh GRCh38.fa)"
Step 2: Build BWA-MEM2 Index
Index the reference genome — required once per genome, takes ~25-35 min for human.
# Build index (~28 GB RAM required for human genome)
bwa-mem2 index GRCh38.fa
# This creates: GRCh38.fa.0123, GRCh38.fa.amb, GRCh38.fa.ann,
# GRCh38.fa.bwt.2bit.64, GRCh38.fa.pac
echo "Index files: $(ls GRCh38.fa.* | wc -l) created"
ls -lh GRCh38.fa.*
Step 3: Align Paired-End Reads
Align FASTQ reads with a read group header required for GATK compatibility.
# Align with read group (required for GATK)
# @RG fields: ID (run ID), SM (sample name), PL (platform), LB (library), PU (flowcell)
bwa-mem2 mem \
-t 16 \
-R "@RG\tID:sample1_run1\tSM:sample1\tPL:ILLUMINA\tLB:lib1\tPU:flowcell1" \
GRCh38.fa \
sample1_R1.fastq.gz \
sample1_R2.fastq.gz \
| samtools sort -@ 8 -m 2G -o sample1.sorted.bam
samtools index sample1.sorted.bam
echo "Alignment complete."
echo "Total reads: $(samtools view -c sample1.sorted.bam)"
echo "Mapped reads: $(samtools view -c -F 4 sample1.sorted.bam)"
Step 4: Mark PCR Duplicates
Remove or mark optical and PCR duplicates before variant calling.
# Option A: samtools markdup (fast)
samtools fixmate -m sample1.sorted.bam sample1.fixmate.bam
samtools sort -@ 8 -o sample1.fixmate.sorted.bam sample1.fixmate.bam
samtools markdup -@ 8 sample1.fixmate.sorted.bam sample1.markdup.bam
samtools index sample1.markdup.bam
echo "Duplication rate:"
samtools flagstat sample1.markdup.bam | grep "duplicate"
# Option B: Picard MarkDuplicates (GATK best practices)
picard MarkDuplicates \
INPUT=sample1.sorted.bam \
OUTPUT=sample1.markdup.bam \
METRICS_FILE=sample1.dupmetrics.txt \
REMOVE_DUPLICATES=false \
CREATE_INDEX=true
cat sample1.dupmetrics.txt | grep -A2 "ESTIMATED"
Step 5: Assess Alignment Quality
Generate alignment statistics and check key quality metrics.
# Full alignment statistics
samtools flagstat sample1.markdup.bam > sample1.flagstat.txt
cat sample1.flagstat.txt
# Coverage statistics
samtools coverage sample1.markdup.bam | head -30
# Parse key metrics with Python
python3 - << 'EOF'
from pathlib import Path
flagstat = Path("sample1.flagstat.txt").read_text()
for line in flagstat.splitlines():
if any(kw in line for kw in ["total", "mapped", "properly paired", "duplicate"]):
print(line)
EOF
Step 6: Complete WGS/WES Pipeline → Variant Calling
Pipe BWA-MEM2 output directly into GATK HaplotypeCaller.
#!/bin/bash
# Complete WGS alignment → variant calling pipeline
GENOME="GRCh38.fa"
SAMPLE="sample1"
R1="data/${SAMPLE}_R1.fastq.gz"
R2="data/${SAMPLE}_R2.fastq.gz"
THREADS=16
OUTDIR="results/${SAMPLE}"
mkdir -p "$OUTDIR"
# Step 1: Align + sort
bwa-mem2 mem -t $THREADS \
-R "@RG\tID:${SAMPLE}\tSM:${SAMPLE}\tPL:ILLUMINA\tLB:lib1\tPU:run1" \
$GENOME $R1 $R2 \
| samtools sort -@ 8 -o $OUTDIR/${SAMPLE}.sorted.bam
samtools index $OUTDIR/${SAMPLE}.sorted.bam
# Step 2: Mark duplicates
samtools fixmate -m $OUTDIR/${SAMPLE}.sorted.bam - \
| samtools sort -@ 8 \
| samtools markdup -@ 8 - $OUTDIR/${SAMPLE}.markdup.bam
samtools index $OUTDIR/${SAMPLE}.markdup.bam
# Step 3: GATK variant calling
gatk HaplotypeCaller \
-R $GENOME \
-I $OUTDIR/${SAMPLE}.markdup.bam \
-O $OUTDIR/${SAMPLE}.g.vcf.gz \
-ERC GVCF \
--native-pair-hmm-threads 4
echo "Pipeline complete: $OUTDIR/${SAMPLE}.g.vcf.gz"
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-t |
1 |
1–64 | CPU threads; use 8–16 for production runs |
-R |
— | @RG\tID:...\tSM:... |
Read group string; required for GATK compatibility |
-k |
19 |
10–28 | Minimum seed length; lower = more sensitive for shorter reads |
-w |
100 |
50–500 | Band width for Smith-Waterman alignment |
-M |
off | flag | Mark split/supplementary reads as secondary (BWA-MEM style); needed for Picard compatibility |
-a |
off | flag | Output all alignments for single-end reads (for seeding; increases file size) |
-p |
off | flag | Treat input as interleaved paired-end FASTQ |
-c |
500 |
100–10000 | Skip alignment for MEM count > threshold (reduces multi-mapper noise) |
-T |
30 |
20–60 | Minimum alignment score threshold; lower = report more low-quality alignments |
-Y |
off | flag | Use soft clipping for supplementary alignments (recommended for GATK) |
Common Recipes
Recipe 1: Batch Align Multiple Samples
#!/bin/bash
# Align all samples in parallel using GNU parallel or sequential loop
GENOME="GRCh38.fa"
SAMPLES=(ctrl_1 ctrl_2 treat_1 treat_2)
THREADS=12
for sample in "${SAMPLES[@]}"; do
echo "=== Aligning $sample ==="
bwa-mem2 mem -t $THREADS \
-R "@RG\tID:${sample}\tSM:${sample}\tPL:ILLUMINA\tLB:lib1\tPU:run1" \
$GENOME \
data/${sample}_R1.fastq.gz \
data/${sample}_R2.fastq.gz \
| samtools sort -@ 4 -m 2G -o results/${sample}.sorted.bam
samtools index results/${sample}.sorted.bam
MAPPED=$(samtools view -c -F 4 results/${sample}.sorted.bam)
TOTAL=$(samtools view -c results/${sample}.sorted.bam)
echo "$sample: $MAPPED / $TOTAL reads mapped"
done
Recipe 2: Parse Alignment Metrics with Python
import subprocess
import pandas as pd
from pathlib import Path
samples = ["ctrl_1", "ctrl_2", "treat_1", "treat_2"]
metrics = []
for sample in samples:
bam = f"results/{sample}.sorted.bam"
result = subprocess.run(
["samtools", "flagstat", bam], capture_output=True, text=True
)
stats = {}
for line in result.stdout.splitlines():
if "total" in line:
stats["total"] = int(line.split()[0])
elif "mapped" in line and "%" in line:
stats["mapped"] = int(line.split()[0])
stats["pct_mapped"] = float(line.split("(")[1].split("%")[0])
elif "properly paired" in line:
stats["properly_paired"] = int(line.split()[0])
stats["sample"] = sample
metrics.append(stats)
df = pd.DataFrame(metrics).set_index("sample")
print(df[["total", "mapped", "pct_mapped", "properly_paired"]])
df.to_csv("alignment_metrics.tsv", sep="\t")
Expected Outputs
| Output | Format | Description |
|---|---|---|
*.sorted.bam |
BAM | Coordinate-sorted aligned reads; index with samtools index |
*.sorted.bam.bai |
BAI | BAM index; required for random access by GATK and IGV |
*.flagstat.txt |
Text | Alignment summary: total/mapped/paired/duplicate counts and percentages |
*.markdup.bam |
BAM | Duplicate-marked BAM; use as input to GATK HaplotypeCaller |
*.dupmetrics.txt |
Text | Picard duplication metrics with estimated library size |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Low mapping rate (< 85%) | Genome mismatch, contamination, or low quality reads | Verify genome assembly matches sample; run FastQC; trim adapters with Trim Galore |
@RG header missing error in GATK |
-R flag not specified during alignment |
Re-align with -R "@RG\tID:...\tSM:...\tPL:ILLUMINA" |
| Out of memory during indexing | Insufficient RAM for genome index | BWA-MEM2 requires ~28 GB for human; use classic bwa with less RAM if needed |
| Unbalanced paired-end counts | Interleaved FASTQ or file mismatch | Add -p for interleaved; verify R1/R2 read counts with zcat r1.fq.gz | wc -l |
[E::bwa_idx_load_from_disk] error |
Index files missing or wrong prefix | Re-run bwa-mem2 index genome.fa; ensure all .0123, .bwt.2bit.64 files exist |
| Slow alignment speed | Low thread count or slow I/O | Use -t 16 or more; store data on SSD; pipe directly to samtools sort |
| Supplementary alignments causing issues | Split reads in downstream tools | Add -M flag to mark split reads as secondary (Picard compatibility) |
| GATK base quality score recalibration fails | Missing known variant VCF | Download dbSNP VCF for your genome assembly from NCBI or GATK resource bundle |
References
- BWA-MEM2 GitHub: bwa-mem2/bwa-mem2 — source code, pre-compiled binaries, and benchmarks
- Vasimuddin M et al. (2019) "Efficient Architecture-Aware Acceleration of BWA-MEM for Multicore Systems" — IPDPS 2019. DOI:10.1109/IPDPS.2019.00041
- Li H & Durbin R (2009) "Fast and accurate short read alignment with Burrows-Wheeler Aligner" — Bioinformatics 25(14):1754-1760. DOI:10.1093/bioinformatics/btp324
- GATK Best Practices for germline short variant discovery — GATK workflow recommending BWA-MEM2 alignment
skills/genomics-bioinformatics/alignment/pysam-genomic-files/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pysam-genomic-files -g -y
SKILL.md
Frontmatter
{
"name": "pysam-genomic-files",
"license": "MIT",
"description": "Read\/write SAM\/BAM\/CRAM, VCF\/BCF, FASTA\/FASTQ. Region queries, pileup, variant filtering, read groups. Python htslib wrapper exposing samtools\/bcftools CLI. Use STAR\/BWA for alignment; GATK\/DeepVariant for variant calling."
}
Pysam — Genomic File Toolkit
Overview
Pysam provides a Pythonic interface to htslib for reading, manipulating, and writing genomic data files. It handles SAM/BAM/CRAM alignments, VCF/BCF variants, and FASTA/FASTQ sequences with efficient region-based random access. Also exposes samtools and bcftools as callable Python functions.
When to Use
- Reading and querying BAM/CRAM alignment files (region extraction, read filtering)
- Analyzing VCF/BCF variant files (genotype access, variant filtering, annotation)
- Extracting reference sequences from indexed FASTA files
- Calculating per-base coverage and pileup statistics
- Building custom bioinformatics pipelines that combine alignment + variant + sequence data
- Quality control of NGS data (mapping quality, flag filtering, coverage)
- For alignment from FASTQ (read mapping), use STAR, BWA, or minimap2 instead
- For variant calling from BAM, use GATK or DeepVariant instead
Prerequisites
pip install pysam
Note: Requires htslib C library (bundled with pip install on most platforms). On some Linux systems, may need libhts-dev or equivalent. Index files (.bai, .tbi, .fai) required for random access — create with pysam.index(), pysam.tabix_index(), or pysam.faidx().
Quick Start
import pysam
# Read BAM file, fetch reads in a region
with pysam.AlignmentFile("sample.bam", "rb") as bam:
for read in bam.fetch("chr1", 1000, 2000):
print(f"{read.query_name}: pos={read.reference_start}, mapq={read.mapping_quality}")
print(f"Total reads in region: {bam.count('chr1', 1000, 2000)}")
Core API
1. Alignment Files (SAM/BAM/CRAM)
Read, query, and write aligned sequencing reads.
import pysam
# Open BAM (binary) or SAM (text) file
bam = pysam.AlignmentFile("sample.bam", "rb") # rb=read BAM, r=read SAM, rc=read CRAM
# Fetch reads overlapping a region (requires .bai index)
for read in bam.fetch("chr1", 10000, 20000):
print(f"Name: {read.query_name}")
print(f" Position: {read.reference_start}-{read.reference_end}")
print(f" MAPQ: {read.mapping_quality}")
print(f" CIGAR: {read.cigarstring}")
print(f" Sequence: {read.query_sequence[:30]}...")
break
# Count reads in region (fast, no iteration needed)
n_reads = bam.count("chr1", 10000, 20000)
print(f"Reads in region: {n_reads}")
# Filter reads by quality and flags
for read in bam.fetch("chr1", 10000, 20000):
if read.mapping_quality >= 30 and not read.is_unmapped and not read.is_duplicate:
pass # Process high-quality, mapped, non-duplicate reads
bam.close()
# Write filtered reads to a new BAM file
with pysam.AlignmentFile("input.bam", "rb") as inbam:
with pysam.AlignmentFile("filtered.bam", "wb", header=inbam.header) as outbam:
for read in inbam.fetch("chr1", 10000, 20000):
if read.mapping_quality >= 30:
outbam.write(read)
# Index the output
pysam.index("filtered.bam")
print("Created filtered.bam + filtered.bam.bai")
2. Coverage and Pileup Analysis
Calculate per-base coverage statistics.
import pysam
import numpy as np
bam = pysam.AlignmentFile("sample.bam", "rb")
# Pileup: per-base coverage with read-level detail
for pileup_col in bam.pileup("chr1", 10000, 10100, min_mapping_quality=30):
bases = [p.alignment.query_sequence[p.query_position]
for p in pileup_col.pileups if not p.is_del and p.query_position is not None]
print(f"Pos {pileup_col.reference_pos}: depth={pileup_col.nsegments}, bases={''.join(bases[:5])}")
# Quick coverage count per region (faster than pileup)
coverage = bam.count_coverage("chr1", 10000, 10100, quality_threshold=20)
# Returns tuple of 4 arrays (A, C, G, T counts per position)
total_cov = np.array(coverage).sum(axis=0)
print(f"Mean coverage: {total_cov.mean():.1f}x")
bam.close()
3. Variant Files (VCF/BCF)
Read, query, and filter genetic variants.
import pysam
# Open VCF/BCF file
vcf = pysam.VariantFile("variants.vcf.gz")
# Iterate all variants
for record in vcf.fetch("chr1", 10000, 50000):
print(f"{record.chrom}:{record.pos} {record.ref}>{','.join(record.alts or [])}")
print(f" QUAL={record.qual}, FILTER={list(record.filter)}")
print(f" INFO: {dict(record.info)}")
# Access genotypes per sample
for sample in record.samples:
gt = record.samples[sample]["GT"]
print(f" {sample}: GT={gt}")
break
vcf.close()
# Filter variants and write to new VCF
with pysam.VariantFile("variants.vcf.gz") as vcf_in:
with pysam.VariantFile("filtered.vcf.gz", "wz", header=vcf_in.header) as vcf_out:
for record in vcf_in:
if record.qual and record.qual >= 30 and "PASS" in record.filter:
vcf_out.write(record)
pysam.tabix_index("filtered.vcf.gz", preset="vcf")
print("Created filtered.vcf.gz + filtered.vcf.gz.tbi")
4. Sequence Files (FASTA/FASTQ)
Random access to reference sequences and sequential reading of raw reads.
import pysam
# FASTA: random access (requires .fai index)
fasta = pysam.FastaFile("reference.fasta")
seq = fasta.fetch("chr1", 10000, 10050)
print(f"Sequence ({len(seq)} bp): {seq}")
print(f"Available contigs: {fasta.references[:5]}")
print(f"Contig lengths: {dict(zip(fasta.references[:3], fasta.lengths[:3]))}")
fasta.close()
# Create FASTA index if needed
# pysam.faidx("reference.fasta")
# FASTQ: sequential reading
with pysam.FastxFile("reads.fastq.gz") as fq:
for i, entry in enumerate(fq):
print(f"Read {entry.name}: {len(entry.sequence)} bp, mean_qual={sum(entry.get_quality_array())/len(entry.sequence):.1f}")
if i >= 2:
break
5. Read Groups and Sample Information
Extract and filter reads by read group (essential for multi-sample BAM files).
import pysam
bam = pysam.AlignmentFile("multisample.bam", "rb")
# Access read group information from BAM header
print("Read groups in file:")
for rg_dict in bam.header.get("RG", []):
print(f" ID: {rg_dict['ID']}, Sample: {rg_dict.get('SM', 'N/A')}, Library: {rg_dict.get('LB', 'N/A')}, Platform: {rg_dict.get('PL', 'N/A')}")
# Get all samples in the BAM (from RG headers)
samples = set()
for rg_dict in bam.header.get("RG", []):
if "SM" in rg_dict:
samples.add(rg_dict["SM"])
print(f"Samples in BAM: {sorted(samples)}")
bam.close()
# Filter reads by read group ID
def extract_reads_by_rg(bam_path, rg_id, output_path):
"""Extract all reads from a specific read group.
WARNING: Uses fetch(until_eof=True), which scans the entire BAM sequentially.
Multi-sample BAMs can be tens to hundreds of GB — this may be slow.
For large files, prefer region-based filtering:
for read in bam.fetch("chr1", start, end): ...
Or use the samtools CLI equivalent (faster for one-off extractions):
samtools view -b -r <rg_id> input.bam -o output.bam
"""
with pysam.AlignmentFile(bam_path, "rb") as bam_in:
with pysam.AlignmentFile(output_path, "wb", header=bam_in.header) as bam_out:
for read in bam_in.fetch(until_eof=True):
if read.has_tag("RG") and read.get_tag("RG") == rg_id:
bam_out.write(read)
pysam.index(output_path)
print(f"Extracted reads from RG:{rg_id} → {output_path}")
extract_reads_by_rg("multisample.bam", "SAMPLE_001_LaneA", "sample001_laneA.bam")
from collections import defaultdict
import pysam
# Count reads per sample
def reads_per_sample(bam_path):
"""Count reads per sample from read group information.
Two distinct "unknown" cases are tracked separately:
- "no_sm_field": RG header entry exists but is missing the SM (sample name) field.
- "undefined_rg": A read carries an RG tag not declared in the BAM header.
"""
counts = defaultdict(int)
rg_to_sample = {}
with pysam.AlignmentFile(bam_path, "rb") as bam:
# Build RG → sample mapping from header
for rg_dict in bam.header.get("RG", []):
rg_id = rg_dict["ID"]
# (a) RG header entry lacks SM field
rg_to_sample[rg_id] = rg_dict.get("SM", "no_sm_field")
# Count reads per resolved sample name
for read in bam.fetch(until_eof=True):
if read.has_tag("RG"):
rg_id = read.get_tag("RG")
# (b) Read's RG tag is not declared in the header
sample = rg_to_sample.get(rg_id, "undefined_rg")
counts[sample] += 1
return dict(counts)
sample_counts = reads_per_sample("multisample.bam")
for sample, count in sorted(sample_counts.items()):
print(f" {sample}: {count:,} reads")
6. Samtools/Bcftools CLI Access
Call samtools and bcftools commands from Python.
import pysam
# Sort BAM file
pysam.sort("-o", "sorted.bam", "input.bam")
# Index BAM
pysam.index("sorted.bam")
# View region as BAM
pysam.view("-b", "-o", "region.bam", "sorted.bam", "chr1:1000-2000")
# BCFtools: compress and index VCF
pysam.bcftools.view("-O", "z", "-o", "output.vcf.gz", "input.vcf")
pysam.tabix_index("output.vcf.gz", preset="vcf")
# Error handling
try:
pysam.sort("-o", "output.bam", "nonexistent.bam")
except pysam.SamtoolsError as e:
print(f"samtools error: {e}")
CLI equivalents (for reference — use Python API in automated pipelines):
# These are equivalent to the Python calls above:
samtools sort -o sorted.bam input.bam
samtools index sorted.bam
samtools view -b -o region.bam sorted.bam chr1:1000-2000
bcftools view -O z -o output.vcf.gz input.vcf
Key Concepts
Coordinate Systems
Critical: pysam uses 0-based, half-open coordinates (Python convention):
| System | Start | End | Example: "bases 1000-2000" |
|---|---|---|---|
| pysam Python API | 0-based | exclusive | fetch("chr1", 999, 2000) |
| samtools region string | 1-based | inclusive | fetch("chr1:1000-2000") |
| VCF file format | 1-based | — | record.pos = 1-based, record.start = 0-based |
| BED format | 0-based | exclusive | chr1\t999\t2000 |
Index File Requirements
| File Type | Index Extension | Create With |
|---|---|---|
| BAM | .bai |
pysam.index("file.bam") |
| CRAM | .crai |
pysam.index("file.cram") |
| FASTA | .fai |
pysam.faidx("file.fasta") |
| VCF.gz | .tbi |
pysam.tabix_index("file.vcf.gz", preset="vcf") |
| BCF | .csi |
pysam.tabix_index("file.bcf", preset="bcf") |
Without an index, use fetch(until_eof=True) for sequential reading.
File Mode Strings
| Mode | Format | Direction |
|---|---|---|
"rb" |
BAM (binary) | Read |
"r" |
SAM (text) | Read |
"rc" |
CRAM | Read |
"wb" |
BAM | Write |
"w" |
SAM | Write |
"wz" |
VCF.gz (compressed) | Write |
Common Workflows
Workflow 1: Coverage Analysis for Target Regions
Goal: Calculate coverage statistics for a set of target regions (e.g., exome capture targets).
import pysam
import numpy as np
def coverage_for_regions(bam_path, regions, min_mapq=30):
"""Calculate coverage stats for a list of (chrom, start, end) regions."""
results = []
with pysam.AlignmentFile(bam_path, "rb") as bam:
for chrom, start, end in regions:
cov = np.array(bam.count_coverage(chrom, start, end,
quality_threshold=min_mapq))
total = cov.sum(axis=0)
results.append({
"region": f"{chrom}:{start}-{end}",
"mean_cov": total.mean(),
"min_cov": total.min(),
"pct_above_20x": (total >= 20).mean() * 100,
})
return results
regions = [("chr1", 10000, 10500), ("chr1", 20000, 20500), ("chr2", 5000, 5500)]
stats = coverage_for_regions("sample.bam", regions)
for s in stats:
print(f"{s['region']}: mean={s['mean_cov']:.1f}x, min={s['min_cov']}x, ≥20x={s['pct_above_20x']:.1f}%")
Workflow 2: Variant Annotation with Read Support
Goal: For each variant in a VCF, count supporting reads from the BAM.
import pysam
def annotate_variants_with_reads(vcf_path, bam_path, output_path):
"""Add read support counts to each variant."""
with pysam.VariantFile(vcf_path) as vcf_in:
# Add INFO field to header
vcf_in.header.add_line(
'##INFO=<ID=READ_SUPPORT,Number=1,Type=Integer,Description="Reads supporting alt allele">'
)
with pysam.VariantFile(output_path, "w", header=vcf_in.header) as vcf_out:
with pysam.AlignmentFile(bam_path, "rb") as bam:
for record in vcf_in:
alt_count = 0
for col in bam.pileup(record.chrom, record.start, record.stop,
min_mapping_quality=30, truncate=True):
if col.reference_pos == record.start:
for p in col.pileups:
if (not p.is_del and p.query_position is not None and
p.alignment.query_sequence[p.query_position] in (record.alts or [])):
alt_count += 1
record.info["READ_SUPPORT"] = alt_count
vcf_out.write(record)
annotate_variants_with_reads("variants.vcf", "sample.bam", "annotated.vcf")
print("Created annotated.vcf with READ_SUPPORT field")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
| mode string | AlignmentFile, VariantFile |
— | "rb", "r", "rc", "wb", "w", "wz" |
File format and read/write direction |
min_mapping_quality |
pileup() |
0 | 0–60 | Filter reads below this MAPQ |
quality_threshold |
count_coverage() |
15 | 0–40 | Minimum base quality to count |
truncate |
pileup() |
False | True/False | Truncate pileup to exact region (True) vs include overlapping reads (False) |
until_eof |
fetch() |
False | True/False | Read all records sequentially without index |
multiple_iterators |
fetch() |
False | True/False | Allow multiple simultaneous iterators (slight overhead) |
preset |
tabix_index() |
— | "vcf", "bed", "gff", "sam" |
File format for tabix indexing |
Best Practices
-
Always use context managers (
withstatement) for automatic file cleanup. Unclosed files can leak file descriptors. -
Create and verify index files first: Most random-access operations fail silently or raise cryptic errors without indexes. Check for
.bai/.tbi/.faifiles before queries. -
Use
count()instead of iterating to count reads:bam.count("chr1", 1000, 2000)is much faster thansum(1 for _ in bam.fetch(...)). -
Use
count_coverage()for coverage,pileup()for base-level detail:count_coverage()is faster when you only need depth numbers. Usepileup()only when you need per-read, per-base information. -
Anti-pattern — mixing 0-based and 1-based coordinates: Always double-check coordinate systems when combining pysam (0-based) with VCF files (1-based POS), BED files (0-based), or region strings (1-based). See Key Concepts table.
-
Anti-pattern — forgetting
truncate=Truein pileup: Withouttruncate=True,pileup()extends to the full extent of overlapping reads, which can be much larger than the requested region.
Common Recipes
Recipe: Extract Gene Sequences from Reference
import pysam
def get_gene_sequence(fasta_path, chrom, start, end, strand="+"):
"""Extract gene sequence, reverse-complement if on minus strand."""
with pysam.FastaFile(fasta_path) as fasta:
seq = fasta.fetch(chrom, start, end)
if strand == "-":
complement = str.maketrans("ACGTacgt", "TGCAtgca")
seq = seq.translate(complement)[::-1]
return seq
seq = get_gene_sequence("reference.fasta", "chr1", 10000, 11000, strand="-")
print(f"Gene sequence ({len(seq)} bp): {seq[:50]}...")
Recipe: BAM Statistics Summary
import pysam
def bam_summary(bam_path):
"""Quick summary statistics for a BAM file."""
with pysam.AlignmentFile(bam_path, "rb") as bam:
stats = {"total": 0, "mapped": 0, "unmapped": 0, "duplicates": 0, "mapq_ge30": 0}
for read in bam.fetch(until_eof=True):
stats["total"] += 1
if read.is_unmapped:
stats["unmapped"] += 1
else:
stats["mapped"] += 1
if read.is_duplicate:
stats["duplicates"] += 1
if read.mapping_quality >= 30:
stats["mapq_ge30"] += 1
return stats
summary = bam_summary("sample.bam")
for k, v in summary.items():
print(f" {k}: {v:,}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: could not open alignment file |
Missing file or wrong mode string | Check file path; use "rb" for BAM, "r" for SAM |
ValueError: fetch called on bamfile without index |
No .bai index file |
Run pysam.index("file.bam") first |
| Region returns unexpected reads | Reads overlapping boundaries are included | Use truncate=True in pileup() or filter by read.reference_start >= start |
| Coordinate off-by-one errors | Mixing 0-based (pysam) with 1-based (VCF, samtools) | See Key Concepts coordinate table; record.pos is 1-based, record.start is 0-based |
PileupProxy accessed after iterator finished |
Pileup iterator went out of scope | Store needed data from pileup columns immediately, don't save PileupProxy references |
SamtoolsError from CLI calls |
Invalid arguments or missing input | Wrap in try/except pysam.SamtoolsError; check samtools docs for argument syntax |
| Very slow iteration | Iterating all reads without region query | Use fetch("chr1", start, end) for targeted queries; use indexed files |
| Read group filter returns 0 reads | RG tag missing or wrong ID specified | Verify RG tag exists: read.has_tag("RG"); list available RGs from bam.header.get("RG", []) |
Related Skills
- biopython-molecular-biology — sequence I/O and alignment; complementary for non-BAM sequence formats
- pydeseq2-differential-expression — downstream analysis of read counts from BAM coverage data
- scanpy-scrna-seq — single-cell analysis; pysam handles the upstream BAM processing
References
- Pysam documentation — official API reference
- htslib — underlying C library for genomic file formats
- SAM format specification — SAM/BAM format details
- Li et al. (2009) "The Sequence Alignment/Map format and SAMtools" — Bioinformatics
skills/genomics-bioinformatics/alignment/samtools-bam-processing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill samtools-bam-processing -g -y
SKILL.md
Frontmatter
{
"name": "samtools-bam-processing",
"license": "MIT",
"description": "CLI toolkit for SAM\/BAM\/CRAM: sort, index, convert, filter, QC alignments. Core commands: view, sort, index, flagstat, stats, depth, markdup, merge. Required between alignment and variant\/peak calling. Use pysam for Python-native BAM access; deeptools for normalized coverage tracks."
}
samtools — SAM/BAM/CRAM Alignment Toolkit
Overview
samtools is the standard command-line toolkit for processing sequence alignment files in SAM, BAM, and CRAM formats. It handles the complete alignment file lifecycle: format conversion, coordinate sorting, index creation, quality control statistics, read filtering, duplicate marking, and multi-file merging. samtools is a near-universal component of NGS pipelines between alignment (STAR, BWA) and downstream analysis (variant calling, peak calling, coverage).
When to Use
- Sorting BAM files by coordinate after alignment (required before indexing)
- Indexing sorted BAM files for random access and region queries
- Converting between SAM, BAM, and CRAM formats to save storage
- Generating alignment QC metrics: mapping rates, insert sizes, per-chromosome stats
- Filtering reads by mapping quality, FLAG bits, or genomic regions
- Marking or removing PCR duplicates before variant calling
- Merging multiple BAM files from different lanes or samples
- Calculating per-base depth or coverage breadth for target regions
- Use
pysaminstead for Python-native BAM manipulation in custom scripts - Use
deeptools bamCoverageinstead when you need normalized bigWig coverage tracks - Use
mosdepthinstead for whole-genome per-base depth (faster, parallelized)
Prerequisites
- Installation: samtools 1.17+ recommended
- Input requirements: SAM/BAM/CRAM files; CRAM requires FASTA reference
- Companion tools:
samtools faidxfor FASTA indexing;samtools sortbeforesamtools index
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v samtoolsfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run samtoolsrather than baresamtools.
# Bioconda (recommended)
conda install -c bioconda samtools
# Homebrew (macOS)
brew install samtools
# Verify
samtools --version | head -1
Quick Start
# Typical post-alignment workflow: sort → index → QC
samtools sort -@ 8 -o sorted.bam input.bam
samtools index sorted.bam
samtools flagstat sorted.bam
Core API
Module 1: BAM/SAM I/O and Format Conversion
Convert between SAM/BAM/CRAM formats and extract subsets.
# SAM → BAM (saves ~75% disk space)
samtools view -b -h input.sam -o output.bam
# BAM → CRAM (saves additional 40-50%)
samtools view -C -T reference.fa input.bam -o output.cram
# Filter: mapping quality ≥20, exclude unmapped (-F 4)
samtools view -q 20 -F 4 input.bam -o filtered.bam
# Extract specific region (requires index)
samtools view -h sorted.bam "chr1:1000000-2000000" -o region.bam
# Count reads matching filter
samtools view -c -F 4 input.bam
# Output: 45231923 (number of mapped reads)
# Extract reads as FASTQ (for realignment or de novo assembly)
samtools fastq -@ 4 -1 R1.fastq.gz -2 R2.fastq.gz -0 unpaired.fastq.gz input.bam
# Extract reads as FASTA
samtools fasta input.bam > reads.fasta
# Filter by read group
samtools view -r SAMPLE_001 multi_rg.bam -o sample001.bam
Module 2: Sorting and Indexing
Organize BAM files for efficient random access.
# Sort by coordinate (required before indexing)
samtools sort -@ 8 -m 2G input.bam -o sorted.bam
# Sort by read name (required for fixmate/markdup)
samtools sort -n -@ 8 input.bam -o namesorted.bam
# Index sorted BAM (creates sorted.bam.bai)
samtools index sorted.bam
# For chromosomes > 512 Mbp: use CSI index instead
samtools index -c sorted.bam
# Group reads by name (fast, for fixmate — no full sort needed)
samtools collate -o collated.bam input.bam
Module 3: Quality Control and Statistics
Generate alignment QC metrics and coverage reports.
# Quick summary: total, mapped, paired, properly paired
samtools flagstat sorted.bam
# Example output:
# 50000000 + 0 in total (QC-passed reads + QC-failed reads)
# 48523111 + 0 mapped (97.05% : N/A)
# 50000000 + 0 paired in sequencing
# 48490234 + 0 properly paired (96.98% : N/A)
# Per-chromosome mapped/unmapped read counts
samtools idxstats sorted.bam
# chr1 248956422 12345678 0
# chr2 242193529 11234567 0
# Comprehensive stats (insert sizes, GC content, base quality)
samtools stats -r reference.fa sorted.bam > full_stats.txt
grep "^SN" full_stats.txt | cut -f2,3 # Summary Numbers only
# Coverage report (min/max/mean per region/chromosome)
samtools coverage sorted.bam
# Per-base read depth for specific regions
samtools depth -b target_regions.bed sorted.bam > depth.txt
# Output: chr pos depth (e.g., chr1 1000 45)
# Statistics split by read group
samtools stats -S RG sorted.bam > per_rg_stats.txt
Module 4: Read Filtering and FLAG Operations
Filter reads using SAM FLAG bits for specific subsets.
# FLAG reference — common masks:
# 1 = paired 4 = unmapped
# 2 = proper pair 8 = mate unmapped
# 16 = reverse strand 64 = R1 (first in pair)
# 128 = R2 256= secondary alignment
# 1024 = PCR duplicate 2048= supplementary
# Extract properly paired, mapped reads (FLAG 2 set, 4 unset)
samtools view -f 2 -F 4 sorted.bam -o proper_pairs.bam
# Extract R1 reads only
samtools view -f 64 sorted.bam -o R1.bam
# Remove secondary and supplementary alignments
samtools view -F 2304 sorted.bam -o primary.bam
# Extract reads from BED file regions
samtools view -L regions.bed -b sorted.bam -o regions.bam
Module 5: Duplicate Handling
Mark or remove PCR duplicates before variant calling.
# Full duplicate marking workflow (collate → fixmate → sort → markdup)
samtools collate -@ 8 -o collated.bam input.bam
samtools fixmate -m -@ 8 collated.bam fixmated.bam
samtools sort -@ 8 -o sorted.bam fixmated.bam
samtools markdup -@ 8 sorted.bam marked.bam
samtools index marked.bam
# Check duplication rate
samtools flagstat marked.bam | grep "duplicates"
# Output: 2345678 + 0 duplicates (4.83%)
# NovaSeq optical duplicate detection (2500 pixel distance)
samtools markdup -d 2500 sorted.bam marked_novaseq.bam
# Remove duplicates instead of marking
samtools markdup -r sorted.bam deduped.bam
# Get duplication stats without writing output
samtools markdup -s sorted.bam /dev/null
Module 6: Multi-file Operations and Region Analysis
Merge BAM files and perform region-level analysis.
# Merge multiple BAM files (all must be sorted)
samtools merge -@ 8 merged.bam lane1.bam lane2.bam lane3.bam
# Merge files listed in a text file (one per line)
samtools merge -b bam_list.txt -@ 8 merged.bam
# Merge with read group tags from filenames
samtools merge -r merged.bam sample1.bam sample2.bam
# Extract specific chromosome region from merged output
samtools view -h merged.bam chr1 -b -o chr1.bam
Key Concepts
SAM FLAG Bits
FLAGS encode read properties as a sum of bit values. Common filtering patterns:
| Common Filter | -f (require) |
-F (exclude) |
Selects |
|---|---|---|---|
| Mapped reads | — | 4 | All aligned reads |
| Proper pairs | 2 | — | Properly paired, both mapped |
| Unique primary | — | 2308 | No secondary/supplementary/duplicate |
| R1 only | 64 | — | First-in-pair reads |
| Unmapped | 4 | — | Failed to align |
CRAM vs BAM vs SAM
| Format | Size | Speed | Requires |
|---|---|---|---|
| SAM | ~10× BAM | Slow I/O | Nothing |
| BAM | 1× | Fast | .bai index for random access |
| CRAM | ~0.6× BAM | Slightly slower | Reference FASTA + index |
Use CRAM for long-term storage; BAM for active analysis.
Common Workflows
Workflow 1: Post-Alignment QC and Preparation
Goal: Convert aligner output to analysis-ready BAM with QC metrics.
#!/bin/bash
SAMPLE="sample_001"
REF="reference.fa"
THREADS=8
# 1. Sort and index (aligner often outputs unsorted SAM/BAM)
samtools sort -@ $THREADS -o ${SAMPLE}.sorted.bam ${SAMPLE}.bam
samtools index ${SAMPLE}.sorted.bam
# 2. QC metrics
samtools flagstat ${SAMPLE}.sorted.bam > ${SAMPLE}.flagstat.txt
samtools stats -r $REF ${SAMPLE}.sorted.bam > ${SAMPLE}.stats.txt
samtools coverage ${SAMPLE}.sorted.bam > ${SAMPLE}.coverage.txt
# 3. Per-chromosome stats
samtools idxstats ${SAMPLE}.sorted.bam > ${SAMPLE}.idxstats.txt
echo "QC complete: $(grep 'mapped (' ${SAMPLE}.flagstat.txt | head -1)"
Workflow 2: Full Duplicate-Marking Pipeline
Goal: Prepare BAM for GATK or other variant callers requiring deduplicated input.
#!/bin/bash
INPUT="aligned.bam"
FINAL="deduped.bam"
THREADS=8
# Collate → fixmate → sort → markdup
samtools collate -@ $THREADS -o collated.bam $INPUT
samtools fixmate -m -@ $THREADS collated.bam fixmated.bam
samtools sort -@ $THREADS -o sorted.bam fixmated.bam
samtools markdup -@ $THREADS -s sorted.bam $FINAL
# Clean up intermediates
rm collated.bam fixmated.bam sorted.bam
# Index and verify
samtools index $FINAL
samtools flagstat $FINAL | grep "duplic"
# Expected: 3-15% duplicates (WGS); 10-30% for amplicon
Key Parameters
| Parameter | Command | Default | Range/Options | Effect |
|---|---|---|---|---|
-@ |
Most | 0 | 1–N cores | Additional compression/I/O threads |
-m |
sort | 768M | e.g., 2G, 4G | Memory per thread for sorting |
-q |
view | 0 | 0–60 | Minimum mapping quality filter |
-f |
view | 0 | FLAG bits | Include reads with ALL bits set |
-F |
view | 0 | FLAG bits | Exclude reads with ANY bit set |
-b |
view | — | flag | Output BAM format |
-C |
view | — | flag | Output CRAM (requires -T) |
-T |
view | — | FASTA path | Reference for CRAM output |
-d |
markdup | 0 | 0–2500 | Optical duplicate pixel distance |
-r |
markdup | — | flag | Remove duplicates (vs just mark) |
-n |
sort | — | flag | Sort by read name instead of position |
-c |
index | — | flag | Create CSI index (needed for chr > 512 Mb) |
Best Practices
-
Always sort before indexing:
samtools indexrequires coordinate-sorted input. Attempting to index an unsorted BAM will fail or produce incorrect results. -
Use
-@for all production runs: Most samtools commands are I/O-bound. Adding-@ 8provides near-linear speedup for compression/decompression with minimal overhead. -
Run flagstat before any analysis:
samtools flagstatruns in seconds and catches alignment failures (low mapping rate, unexpected paired-end rates) before wasting time on downstream steps. -
Use the collate → fixmate → sort → markdup pipeline: Running
samtools markdupdirectly on coordinate-sorted BAM without fixmate produces incorrect duplicate detection. The mate information added byfixmate -mis essential. -
Prefer CRAM for archiving: CRAM reduces storage 40-50% vs BAM with no loss. Always store the reference FASTA alongside CRAM files.
-
Use
-L bed_filefor targeted analyses: Restrictingsamtools viewto BED-defined target regions (WES capture, amplicons) dramatically reduces I/O for downstream steps.
Common Recipes
Recipe: Batch Flagstat for Multiple Samples
# Process all BAM files in directory
for bam in *.sorted.bam; do
echo "=== $bam ==="
samtools flagstat $bam | grep -E "mapped|properly paired|duplicates"
done
Recipe: Extract Unmapped Reads for De Novo Assembly
# Pull both unmapped reads (useful for pathogen detection)
samtools view -f 4 -b input.bam -o unmapped.bam
samtools fastq -@ 4 -1 unmapped_R1.fastq -2 unmapped_R2.fastq unmapped.bam
echo "Unmapped pairs ready for de novo assembly"
Recipe: Downsample BAM to Target Coverage
# Estimate current depth, then subsample to ~30×
TOTAL=$(samtools flagstat input.bam | grep "mapped (" | head -1 | awk '{print $1}')
GENOME_SIZE=3100000000 # hg38
READ_LEN=150
CURRENT_COV=$(echo "scale=1; $TOTAL * $READ_LEN / $GENOME_SIZE" | bc)
TARGET_FRAC=$(echo "scale=3; 30 / $CURRENT_COV" | bc)
echo "Current: ${CURRENT_COV}×; subsample fraction: $TARGET_FRAC"
samtools view -b -s $TARGET_FRAC input.bam -o downsampled.bam
samtools index downsampled.bam
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
[bam_index_build2] fail to index |
BAM not sorted by coordinate | Sort first: samtools sort -o sorted.bam input.bam |
BAI index too large for chromosome |
Chromosome > 512 Mbp | Use CSI index: samtools index -c input.bam |
CRAM: reference not found |
Missing or wrong reference FASTA | Set REF_PATH env var or use -T ref.fa |
| Duplicate marking incorrect | fixmate step skipped |
Run full pipeline: collate → fixmate → sort → markdup |
flagstat shows 0% properly paired |
Paired-end BAM missing mate info | Run samtools fixmate to populate mate coordinates |
| Very slow sorting | Low memory per thread | Increase -m 4G; reduce -@ if memory-limited |
| Region query returns nothing | BAM not indexed or wrong coords | Run samtools index; use 1-based coords: chr1:1000-2000 |
[E::hts_open_format] fail to open |
File path wrong or BAM corrupt | Verify path; test with samtools quickcheck file.bam |
Related Skills
- deeptools-ngs-analysis — normalized bigWig coverage tracks and ChIP-seq visualization downstream of samtools
- pysam-genomic-files — Python API for BAM manipulation in custom scripts
- bedtools-genomic-intervals — genomic interval operations on BAM/BED files produced by samtools
References
- samtools documentation — official man pages and command reference
- GitHub: samtools/samtools — source, releases, issue tracker
- Danecek et al. (2021) "Twelve years of SAMtools and BCFtools" — GigaScience 10(2)
- SAM format specification — FLAG bits, CIGAR strings, optional tags
skills/genomics-bioinformatics/alignment/star-rna-seq-aligner/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill star-rna-seq-aligner -g -y
SKILL.md
Frontmatter
{
"name": "star-rna-seq-aligner",
"license": "MIT",
"description": "Splice-aware RNA-seq aligner producing sorted BAM and splice junction tables. Builds genome index, runs two-pass alignment for better junctions. Outputs sorted BAM, junctions (SJ.out.tab), stats (Log.final.out), optional gene counts. Use Salmon for fast pseudoalignment; STAR when a BAM is needed for variant calling, IGV, or ENCODE pipelines."
}
STAR — Spliced RNA-seq Aligner
Overview
STAR (Spliced Transcripts Alignment to a Reference) aligns RNA-seq reads to a genome in a splice-aware manner, identifying novel and annotated splice junctions in a single pass. It generates coordinate-sorted BAM files compatible with samtools, IGV, deeptools, and GATK. STAR's 2-pass mode re-aligns reads using junctions discovered in the first pass, improving sensitivity for novel splice sites. With --quantMode GeneCounts, STAR simultaneously produces gene-level read count tables without requiring a separate featureCounts or HTSeq step.
When to Use
- Aligning bulk RNA-seq reads to a reference genome when downstream tools require a BAM file (variant calling, visualization, deeptools)
- Running ENCODE-compliant RNA-seq pipelines that mandate genome alignment
- Discovering novel splice junctions and alternative splicing events in the dataset
- Generating gene count tables alongside BAM alignment in a single step with
--quantMode GeneCounts - Processing long reads or reads with high mismatch rates by tuning
--outFilterMismatchNmax - Use Salmon instead when you only need transcript/gene quantification and do not need a BAM file — Salmon is 20-50× faster
Prerequisites
- Software: STAR ≥ 2.7.0 (conda or compiled binary)
- Reference files: genome FASTA + GTF annotation (same assembly)
- RAM: 30–32 GB for human/mouse genome index; 8–16 GB for smaller genomes
- Disk: ~25 GB for human genome index, ~5–10 GB per sample BAM
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v STARfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run STARrather than bareSTAR.
# Install with conda (recommended)
conda install -c bioconda star
# Verify
STAR --version
# STAR_2.7.11a
# Or compile from source
git clone https://github.com/alexdobin/STAR
cd STAR/source && make STAR
Quick Start
# 1. Generate genome index (~30 min, run once)
STAR --runMode genomeGenerate \
--runThreadN 8 \
--genomeDir genome/star_index \
--genomeFastaFiles genome/GRCh38.fa \
--sjdbGTFfile genome/gencode.v47.gtf \
--sjdbOverhang 100 # ReadLength - 1
# 2. Align paired-end reads (~10-20 min)
STAR --runThreadN 8 \
--genomeDir genome/star_index \
--readFilesIn sample_R1.fastq.gz sample_R2.fastq.gz \
--readFilesCommand zcat \
--outSAMtype BAM SortedByCoordinate \
--outFileNamePrefix results/sample/
# 3. Index the BAM
samtools index results/sample/Aligned.sortedByCoord.out.bam
Workflow
Step 1: Prepare Reference Files
Download a genome FASTA and matching GTF annotation (same assembly version).
# Download GRCh38 genome and GENCODE annotation
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_47/GRCh38.primary_assembly.genome.fa.gz
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_47/gencode.v47.primary_assembly.annotation.gtf.gz
gunzip GRCh38.primary_assembly.genome.fa.gz gencode.v47.primary_assembly.annotation.gtf.gz
mkdir -p genome/star_index
echo "Genome and GTF ready."
ls -lh GRCh38.primary_assembly.genome.fa gencode.v47.primary_assembly.annotation.gtf
Step 2: Generate Genome Index
Build the STAR genome index — required once per genome/read-length combination.
# Standard human genome index (requires ~32 GB RAM)
STAR --runMode genomeGenerate \
--runThreadN 16 \
--genomeDir genome/star_index/ \
--genomeFastaFiles GRCh38.primary_assembly.genome.fa \
--sjdbGTFfile gencode.v47.primary_assembly.annotation.gtf \
--sjdbOverhang 100
# For small genomes (e.g., E. coli ~4.6 Mb), reduce genomeSAindexNbases
# STAR --runMode genomeGenerate \
# --genomeSAindexNbases 11 \
# --genomeDir genome/ecoli_index/ ...
echo "Index complete: $(ls genome/star_index/ | wc -l) files"
Step 3: Align RNA-seq Reads
Align single-end or paired-end FASTQ files to the indexed genome.
# Single-end alignment
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1.fastq.gz \
--readFilesCommand zcat \
--outSAMtype BAM SortedByCoordinate \
--outSAMattributes NH HI AS NM MD \
--outFileNamePrefix results/sample1/
# Paired-end alignment
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1_R1.fastq.gz sample1_R2.fastq.gz \
--readFilesCommand zcat \
--outSAMtype BAM SortedByCoordinate \
--outSAMattributes NH HI AS NM MD \
--outFileNamePrefix results/sample1/
echo "BAM: results/sample1/Aligned.sortedByCoord.out.bam"
Step 4: Run 2-Pass Alignment for Improved Sensitivity
Two-pass mode collects splice junctions from the first pass and uses them as annotation for the second pass.
# First pass — collect splice junctions
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1_R1.fastq.gz sample1_R2.fastq.gz \
--readFilesCommand zcat \
--outSAMtype None \
--outFileNamePrefix pass1/sample1/
# Second pass — realign with all junctions from pass 1
SJ_FILES=$(ls pass1/*/SJ.out.tab | tr '\n' ' ')
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1_R1.fastq.gz sample1_R2.fastq.gz \
--readFilesCommand zcat \
--sjdbFileChrStartEnd $SJ_FILES \
--outSAMtype BAM SortedByCoordinate \
--outFileNamePrefix results/sample1/
# Alternative: single-command 2-pass
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1_R1.fastq.gz sample1_R2.fastq.gz \
--readFilesCommand zcat \
--twopassMode Basic \
--outSAMtype BAM SortedByCoordinate \
--outFileNamePrefix results/sample1/
Step 5: Check Alignment Statistics
Parse the alignment log to assess mapping rate and read quality.
# View the alignment summary
cat results/sample1/Log.final.out
# Parse key metrics with python
python3 - << 'EOF'
import re, sys
from pathlib import Path
log = Path("results/sample1/Log.final.out").read_text()
metrics = {}
for line in log.splitlines():
if "|" in line:
key, _, val = line.partition("|")
metrics[key.strip()] = val.strip()
print(f"Unique mapping: {metrics.get('Uniquely mapped reads %', 'N/A')}")
print(f"Multi-mapping: {metrics.get('% of reads mapped to multiple loci', 'N/A')}")
print(f"Too many mismatches:{metrics.get('% of reads unmapped: too many mismatches', 'N/A')}")
print(f"Total input reads: {metrics.get('Number of input reads', 'N/A')}")
EOF
Step 6: Generate Gene Count Tables
Enable simultaneous gene counting during alignment using --quantMode GeneCounts.
# Align and count simultaneously
STAR --runThreadN 8 \
--genomeDir genome/star_index/ \
--readFilesIn sample1_R1.fastq.gz sample1_R2.fastq.gz \
--readFilesCommand zcat \
--outSAMtype BAM SortedByCoordinate \
--quantMode GeneCounts \
--outFileNamePrefix results/sample1/
# ReadsPerGene.out.tab has 4 columns:
# gene_id unstranded stranded_fwd stranded_rev
head results/sample1/ReadsPerGene.out.tab
# Load into pandas (select column based on library strandedness)
python3 - << 'EOF'
import pandas as pd
df = pd.read_csv("results/sample1/ReadsPerGene.out.tab",
sep="\t", header=None, skiprows=4,
names=["gene_id", "unstranded", "fwd", "rev"])
# For unstranded library: use column 2 (unstranded)
counts = df.set_index("gene_id")["unstranded"]
print(f"Genes with counts > 0: {(counts > 0).sum()}")
print(counts[counts > 0].sort_values(ascending=False).head())
EOF
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
--runThreadN |
1 |
1–64 | CPU threads for alignment |
--sjdbOverhang |
99 |
ReadLength-1 | Splice junction overhang; set to ReadLength-1 |
--outSAMtype |
SAM |
BAM SortedByCoordinate, BAM Unsorted |
Output format and sort order |
--outFilterMismatchNmax |
10 |
0–33 | Max mismatches per read; lower for stricter mapping |
--outFilterMultimapNmax |
10 |
1–9999 | Max genomic loci per read; reads exceeding limit marked unmapped |
--quantMode |
– |
GeneCounts, TranscriptomeSAM |
Enable gene counting or transcriptome BAM |
--twopassMode |
None |
None, Basic |
Enable 2-pass alignment for novel junction discovery |
--alignIntronMax |
1000000 |
1–1e9 | Maximum intron length; reduce for bacterial genomes |
--outReadsUnmapped |
None |
Fastx |
Write unmapped reads to FASTQ |
--genomeSAindexNbases |
14 |
10–14 | SA index size; set log2(GenomeSize)/2 − 1 for small genomes |
Common Recipes
Recipe 1: Batch Align All Samples
#!/bin/bash
# Align all paired-end samples in a directory
SAMPLES=(ctrl_1 ctrl_2 treat_1 treat_2)
INDEX="genome/star_index"
DATA="data"
OUT="results"
THREADS=12
mkdir -p "$OUT"
for sample in "${SAMPLES[@]}"; do
echo "Aligning: $sample"
mkdir -p "$OUT/$sample"
STAR --runThreadN "$THREADS" \
--genomeDir "$INDEX" \
--readFilesIn "$DATA/${sample}_R1.fastq.gz" "$DATA/${sample}_R2.fastq.gz" \
--readFilesCommand zcat \
--outSAMtype BAM SortedByCoordinate \
--quantMode GeneCounts \
--twopassMode Basic \
--outFileNamePrefix "$OUT/$sample/"
samtools index "$OUT/$sample/Aligned.sortedByCoord.out.bam"
echo "Done: $sample — $(grep 'Uniquely mapped reads %' $OUT/$sample/Log.final.out | awk '{print $NF}')"
done
Recipe 2: Build Gene Count Matrix Across Samples
import pandas as pd
from pathlib import Path
results_dir = Path("results")
samples = ["ctrl_1", "ctrl_2", "treat_1", "treat_2"]
strandedness = "unstranded" # or "fwd" / "rev"
col_map = {"unstranded": 1, "fwd": 2, "rev": 3}
col = col_map[strandedness]
counts = {}
for sample in samples:
count_file = results_dir / sample / "ReadsPerGene.out.tab"
df = pd.read_csv(count_file, sep="\t", header=None, skiprows=4)
counts[sample] = df.set_index(0)[col]
matrix = pd.DataFrame(counts)
matrix = matrix[matrix.sum(axis=1) > 0] # drop zero-count genes
matrix.to_csv("gene_count_matrix.tsv", sep="\t")
print(f"Count matrix: {matrix.shape} (genes × samples)")
print(matrix.head())
Recipe 3: Integrate with DESeq2 via pydeseq2
import pandas as pd
from pydeseq2.dds import DeseqDataSet
from pydeseq2.default_inference import DefaultInference
# Load count matrix from STAR output
counts = pd.read_csv("gene_count_matrix.tsv", sep="\t", index_col=0).T
metadata = pd.DataFrame({
"condition": ["control", "control", "treated", "treated"]
}, index=counts.index)
# Run DESeq2
dds = DeseqDataSet(counts=counts, metadata=metadata,
design_factors="condition",
inference=DefaultInference(n_cpus=4))
dds.deseq2()
print("DESeq2 complete — see dds.varm['LFC'] for results")
Expected Outputs
| Output | Format | Description |
|---|---|---|
Aligned.sortedByCoord.out.bam |
BAM | Coordinate-sorted aligned reads; index with samtools index |
SJ.out.tab |
TSV | Splice junction table with coverage, motif, and novelty flags |
Log.final.out |
Text | Alignment statistics: unique mapping %, multimappers %, etc. |
ReadsPerGene.out.tab |
TSV | Gene counts (4 columns: unstranded/fwd/rev) when --quantMode GeneCounts |
Unmapped.out.mate1/2 |
FASTQ | Unmapped reads (when --outReadsUnmapped Fastx) |
Log.out |
Text | Verbose run log; check for warnings and parameter echoes |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Unique mapping < 60% | Wrong genome assembly or species contamination | Verify genome FASTA matches sample species; run FastQC to check overrepresented sequences |
Fatal error: genome files not found |
Wrong --genomeDir path or incomplete index |
Re-run genomeGenerate; check genomeDir contains Genome, SA, SAindex files |
| Out of memory during genome generation | Not enough RAM for genome SA index | Add --genomeSAindexNbases 13 (or lower) for small genomes; request ≥32 GB RAM for human |
.gz files not decompressed |
Missing --readFilesCommand zcat |
Add --readFilesCommand zcat for gzip-compressed inputs |
Error: number of input files differ |
R1/R2 read count mismatch | Verify FASTQ files with `zcat file.fastq.gz |
ReadsPerGene.out.tab missing |
--quantMode GeneCounts not set |
Re-run with --quantMode GeneCounts or use featureCounts on BAM |
| Very high multimapping (>20%) | Highly repetitive genome or wrong --outFilterMultimapNmax |
Reduce --outFilterMultimapNmax; use --outSAMmultNmax 1 to output only one alignment per read |
| Genome index takes too long | Large genome + slow disk | Use SSD storage; pre-built indices available from ENCODE and Ensembl |
References
- STAR GitHub repository — source code, releases, and STAR manual PDF
- Dobin A et al. (2013) "STAR: ultrafast universal RNA-seq aligner" — Bioinformatics 29(1):15-21. DOI:10.1093/bioinformatics/bts635
- ENCODE RNA-seq alignment standards — ENCODE project guidelines using STAR
- GENCODE genome annotations — recommended GTF source for human/mouse STAR indices
skills/genomics-bioinformatics/annotation/bakta-genome-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill bakta-genome-annotation -g -y
SKILL.md
Frontmatter
{
"name": "bakta-genome-annotation",
"license": "GPL-3.0",
"description": "Annotate bacterial and archaeal genomes and plasmids with Bakta's Prodigal\/HMM\/diamond pipeline. Identifies CDS, ncRNA, tRNA, rRNA, tmRNA, sORFs, CRISPR arrays, oriC\/oriV\/oriT, and gaps against a curated UniRef-derived database. Produces NCBI-compatible GFF3, GenBank, EMBL, JSON, FASTA, TSV, and a circular genome plot. Use Prokka for legacy pipelines or non-bacterial kingdoms; PGAP for NCBI GenBank submission."
}
Bakta Genome Annotation
Overview
Bakta is a command-line pipeline for rapid, standardized annotation of bacterial and archaeal genomes and plasmids. It combines Prodigal for CDS prediction, tRNAscan-SE/Aragorn/Barrnap/Infernal for non-coding RNA, PILER-CR/PILERCR for CRISPR detection, and a tiered DIAMOND/HMM search against a curated UniRef100 + IPS/UPS database to assign gene names, EC numbers, GO terms, and COG categories. Bakta produces NCBI-compatible outputs (GFF3, GenBank, EMBL, INSDC-formatted FASTA, plus a JSON summary and a circular Circos plot) for a typical 5 Mb genome in 5–15 minutes on 8 CPUs.
When to Use
- Annotating bacterial or archaeal genome assemblies (Illumina, PacBio, Nanopore) with NCBI-compatible locus tags and product names
- Annotating plasmids and other circular replicons separately with
--plasmidand--completeflags - Producing JSON-structured annotation outputs that can be parsed without GenBank or GFF3 detours
- Generating a publication-ready circular genome plot via the bundled
bakta_plotcommand - Annotating MAGs (metagenome-assembled genomes) with
--metato disable Prodigal training - Use Prokka instead when you need viral/mitochondrial kingdoms or when you must reproduce a legacy Prokka pipeline exactly
- Use PGAP instead when submitting to NCBI GenBank with full standards compliance
- Use Bakta when you want faster runs, regularly updated UniRef-derived databases, AMRFinderPlus integration, and a JSON summary out of the box
Prerequisites
- Software: Bakta ≥ 1.9, Python 3.8+, Prodigal, tRNAscan-SE, Aragorn, Barrnap, Infernal, DIAMOND, HMMER3, PILER-CR, BLAST+, AMRFinderPlus
- Database: Bakta DB (full ~70 GB, or light ~3 GB) downloaded once with
bakta_db download - Python packages (for output parsing):
biopython,pandas,matplotlib - Input: assembled genome in FASTA format (one or more contigs)
- Hardware: ≥ 16 GB RAM for full DB, ≥ 4 GB RAM for light DB; ≥ 8 CPUs recommended
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v baktafirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run baktarather than barebakta.
# Install Bakta via conda/mamba (recommended)
mamba install -c conda-forge -c bioconda bakta
# Verify installation
bakta --version
# bakta 1.9.4
# Download the light database (~3 GB, faster, fewer functional hits)
bakta_db download --output db/ --type light
# Or full database (~70 GB, comprehensive UniRef100 coverage)
# bakta_db download --output db/ --type full
# Install Python parsing dependencies
pip install biopython pandas matplotlib
Quick Start
# Annotate a bacterial genome — results in results/ directory
bakta genome.fasta \
--db db/bakta_db_light \
--output results/ \
--prefix sample1 \
--threads 8
# Inspect the JSON summary for feature counts
python -c "
import json
with open('results/sample1.json') as f:
d = json.load(f)
print('Genus:', d['genome'].get('genus'))
print('Length:', d['genome']['size'], 'bp')
print('CDS:', sum(1 for f in d['features'] if f['type'] == 'cds'))
print('tRNA:', sum(1 for f in d['features'] if f['type'] == 'tRNA'))
"
Workflow
Step 1: Install Bakta and Download the Database
Install Bakta and prepare the reference database. The database download is one-time and reused across runs.
# Create a dedicated conda environment (avoids dependency conflicts)
mamba create -n bakta_env -c conda-forge -c bioconda bakta python=3.11 -y
mamba activate bakta_env
# Verify Bakta and its dependencies
bakta --version
# bakta 1.9.4
bakta --help | head -20
# Download the light database (sufficient for routine annotation)
mkdir -p db/
bakta_db download --output db/ --type light
# Downloads ~3 GB; expands to ~5 GB on disk
# Verify the database was extracted correctly
ls db/bakta_db_light/
# antifam.h3f bakta.db expert oric.fna pfam.h3f rfam-go.tsv ...
# (Optional) Update AMRFinderPlus DB used by Bakta for AMR gene calling
amrfinder -u
# Install Python parsing tools
pip install biopython pandas matplotlib
Step 2: Prepare the Input Assembly
Bakta requires clean FASTA headers without spaces or special characters. Pre-clean and optionally filter short contigs.
from Bio import SeqIO
import re
input_fasta = "genome.fasta"
records = list(SeqIO.parse(input_fasta, "fasta"))
print(f"Input assembly: {len(records)} contigs")
total_bases = sum(len(r) for r in records)
print(f"Total bases: {total_bases:,}")
print(f"Largest contig: {max(len(r) for r in records):,} bp")
# Bakta preferred: short, alphanumeric, unique IDs
cleaned = []
for i, rec in enumerate(records, 1):
new_id = f"contig_{i:04d}"
new_rec = rec.__class__(rec.seq, id=new_id, description="")
cleaned.append(new_rec)
SeqIO.write(cleaned, "genome_clean.fasta", "fasta")
print(f"Wrote genome_clean.fasta with {len(cleaned)} contigs")
# Filter out short contigs (<200 bp) which contribute little to annotation
awk 'BEGIN{RS=">"; ORS=""} NR>1 {n=split($0, a, "\n"); seq=""; for(i=2;i<=n;i++) seq=seq a[i]; if (length(seq) >= 200) print ">" $0}' \
genome_clean.fasta > genome_filtered.fasta
echo "Filtered assembly: $(grep -c '>' genome_filtered.fasta) contigs"
Step 3: Run Standard Bakta Annotation
Run Bakta with genus/species hints. Locus tags are auto-generated from the strain field.
# Standard annotation for a draft bacterial genome
bakta genome_clean.fasta \
--db db/bakta_db_light \
--output annotation/ \
--prefix E_coli_K12 \
--genus Escherichia \
--species coli \
--strain K12 \
--locus-tag ECOLI \
--threads 8 \
--keep-contig-headers
# Expected runtime: 5–15 min for ~5 Mb genome on 8 CPUs (light DB)
echo "Bakta annotation outputs:"
ls annotation/
# E_coli_K12.embl E_coli_K12.faa E_coli_K12.ffn
# E_coli_K12.fna E_coli_K12.gbff E_coli_K12.gff3
# E_coli_K12.hypotheticals.faa E_coli_K12.hypotheticals.tsv
# E_coli_K12.json E_coli_K12.log E_coli_K12.png
# E_coli_K12.svg E_coli_K12.tsv E_coli_K12.txt
Step 4: Parse the JSON Summary
Bakta's JSON output is the canonical, machine-readable annotation. Parse it directly for downstream pipelines.
import json
import pandas as pd
from collections import Counter
with open("annotation/E_coli_K12.json") as f:
bakta = json.load(f)
# Genome-level metadata
genome = bakta["genome"]
print(f"Organism: {genome.get('genus')} {genome.get('species')} {genome.get('strain')}")
print(f"Size: {genome['size']:,} bp across {len(bakta['sequences'])} sequences")
print(f"GC content: {genome['gc']:.2%}")
# Feature type counts
features = bakta["features"]
type_counts = Counter(f["type"] for f in features)
print("\nFeature counts:")
for ftype, n in sorted(type_counts.items(), key=lambda x: -x[1]):
print(f" {ftype:>10}: {n}")
# Build a tidy CDS DataFrame
cds_rows = []
for f in features:
if f["type"] != "cds":
continue
cds_rows.append({
"locus_tag": f.get("locus", ""),
"contig": f.get("contig", ""),
"start": f.get("start"),
"stop": f.get("stop"),
"strand": f.get("strand"),
"gene": f.get("gene", ""),
"product": f.get("product", ""),
"length_aa": len(f.get("aa", "")),
})
cds_df = pd.DataFrame(cds_rows)
print(f"\nTotal CDS: {len(cds_df)}")
print(cds_df.head(5).to_string(index=False))
Step 5: Parse the TSV Feature Table
The TSV output is convenient for spreadsheet workflows and quick filtering.
import pandas as pd
# Bakta TSV begins with comment lines starting with '#'
df = pd.read_csv("annotation/E_coli_K12.tsv", sep="\t", comment="#",
names=["sequence_id", "type", "start", "stop", "strand",
"locus_tag", "gene", "product", "dbxrefs"])
print(f"Total features: {len(df)}")
print(f"Feature types: {df['type'].value_counts().to_dict()}")
# Hypothetical vs annotated CDS
cds = df[df["type"] == "cds"].copy()
hypothetical = cds["product"].str.contains("hypothetical", case=False, na=True)
print(f"\nCDS with assigned function: {(~hypothetical).sum()} / {len(cds)}")
print(f"Hypothetical proteins: {hypothetical.sum()}")
# Cross-references (UniRef, KEGG, EC, GO, etc.) parsed from the dbxrefs column
def split_xrefs(xref_str):
if not isinstance(xref_str, str) or xref_str in ("", "-"):
return []
return [x.strip() for x in xref_str.split(",")]
cds["dbxref_list"] = cds["dbxrefs"].apply(split_xrefs)
ec_hits = cds[cds["dbxref_list"].apply(lambda xs: any(x.startswith("EC:") for x in xs))]
print(f"CDS with EC numbers: {len(ec_hits)}")
print(ec_hits[["locus_tag", "gene", "product"]].head(5).to_string(index=False))
Step 6: Render the Circular Genome Plot
Bakta emits a Circos-style PNG/SVG by default. Regenerate with custom styling using bakta_plot.
# Re-render the plot from the existing JSON with a different style
bakta_plot --output annotation/ \
--prefix E_coli_K12_replot \
--type cog \
--dpi 300 \
annotation/E_coli_K12.json
ls annotation/E_coli_K12_replot.*
# E_coli_K12_replot.png E_coli_K12_replot.svg
# Display the rendered PNG inline in a Jupyter notebook
python <<'PY'
from pathlib import Path
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
img = mpimg.imread("annotation/E_coli_K12.png")
fig, ax = plt.subplots(figsize=(6, 6))
ax.imshow(img)
ax.axis("off")
ax.set_title("Bakta circular genome annotation")
plt.savefig("bakta_circular_thumbnail.png", dpi=120, bbox_inches="tight")
print("Saved bakta_circular_thumbnail.png")
PY
Step 7: Compute Annotation Quality Statistics
Summarize hypothetical-protein rate, gene density, and feature coverage to assess annotation quality.
import json
import pandas as pd
import matplotlib.pyplot as plt
with open("annotation/E_coli_K12.json") as f:
bakta = json.load(f)
genome_size = bakta["genome"]["size"]
features = bakta["features"]
# Per-feature-type counts and density (per Mb)
counts = {}
for f in features:
counts[f["type"]] = counts.get(f["type"], 0) + 1
density = {k: v / (genome_size / 1e6) for k, v in counts.items()}
cds = [f for f in features if f["type"] == "cds"]
n_cds = len(cds)
n_hypo = sum(1 for f in cds if "hypothetical" in (f.get("product") or "").lower())
n_known = n_cds - n_hypo
coding_density = sum(abs(f["stop"] - f["start"] + 1) for f in cds) / genome_size
print(f"Genome: {genome_size:,} bp")
print(f"CDS: {n_cds} ({density.get('cds', 0):.1f} per Mb)")
print(f" Known function: {n_known} Hypothetical: {n_hypo}")
print(f"Coding density: {coding_density:.1%}")
print(f"tRNA: {counts.get('tRNA', 0)} rRNA: {counts.get('rRNA', 0)}")
print(f"ncRNA: {counts.get('ncRNA', 0)} CRISPR: {counts.get('crispr', 0)}")
# Bar plot of feature type counts
fig, ax = plt.subplots(figsize=(8, 4))
items = sorted(counts.items(), key=lambda x: -x[1])
labels = [k for k, _ in items]
values = [v for _, v in items]
bars = ax.bar(labels, values, color="#2980B9", edgecolor="white")
for bar, v in zip(bars, values):
ax.text(bar.get_x() + bar.get_width() / 2, v + max(values) * 0.01,
str(v), ha="center", fontsize=9)
ax.set_ylabel("Count")
ax.set_title("Bakta feature counts by type")
plt.xticks(rotation=20)
plt.tight_layout()
plt.savefig("bakta_feature_counts.png", dpi=150, bbox_inches="tight")
print("Saved bakta_feature_counts.png")
Step 8: Batch Annotation Across Multiple Genomes
Run Bakta over a directory of assemblies and aggregate per-sample summary statistics.
#!/bin/bash
# batch_bakta.sh — annotate all FASTA files in a directory
INPUT_DIR="genomes/"
OUTPUT_DIR="annotations/"
DB="db/bakta_db_light"
mkdir -p "$OUTPUT_DIR"
for FASTA in "$INPUT_DIR"/*.fasta; do
SAMPLE=$(basename "$FASTA" .fasta)
echo "Annotating: $SAMPLE"
bakta "$FASTA" \
--db "$DB" \
--output "${OUTPUT_DIR}/${SAMPLE}" \
--prefix "$SAMPLE" \
--threads 4 \
--skip-plot \
--force
done
echo "Batch annotation complete."
# Aggregate per-genome summaries from each sample's JSON file
from pathlib import Path
import json
import pandas as pd
annotation_dir = Path("annotations/")
rows = []
for json_file in sorted(annotation_dir.glob("*/*.json")):
with open(json_file) as f:
bakta = json.load(f)
sample = json_file.stem
counts = {}
for feat in bakta["features"]:
counts[feat["type"]] = counts.get(feat["type"], 0) + 1
rows.append({
"sample": sample,
"size": bakta["genome"]["size"],
"gc": bakta["genome"]["gc"],
"CDS": counts.get("cds", 0),
"tRNA": counts.get("tRNA", 0),
"rRNA": counts.get("rRNA", 0),
"ncRNA": counts.get("ncRNA", 0),
"crispr": counts.get("crispr", 0),
})
summary_df = pd.DataFrame(rows)
print(f"Annotated genomes: {len(summary_df)}")
print(summary_df.to_string(index=False))
summary_df.to_csv("batch_bakta_summary.csv", index=False)
print("Saved: batch_bakta_summary.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
--db |
— | path to Bakta DB directory | Required; selects light or full reference DB |
--genus |
— | any genus name string | Sets organism metadata in GenBank/EMBL output |
--species |
— | any species name string | Combined with --genus for organism qualifier |
--strain |
— | any string | Adds strain qualifier to organism metadata |
--locus-tag |
auto | 3–24 alphanumeric chars | Prefix for locus tags (e.g., ECOLI_00001) |
--complete |
off | flag | Treat all input contigs as complete circular replicons |
--plasmid |
off | flag | Treat all input contigs as plasmids (circular) |
--meta |
off | flag | Disable Prodigal training (use for MAGs / metagenomic bins) |
--translation-table |
11 |
NCBI table IDs (1, 4, 11, 25, …) | Genetic code used by Prodigal for CDS translation |
--min-contig-length |
1 |
any integer (bp) | Skip contigs shorter than this length |
--threads |
1 |
1–CPU count |
Parallel threads for DIAMOND/HMMER searches |
--skip-plot |
off | flag | Skip the slow circular plot rendering step |
--keep-contig-headers |
off | flag | Preserve original contig IDs instead of renaming |
--proteins |
— | path to GenBank or FASTA file | Custom expert protein DB used before UniRef search |
Common Recipes
Recipe: Plasmid-only Annotation
When to use: A finished circular plasmid sequence that should be annotated without chromosome assumptions.
bakta plasmid.fasta \
--db db/bakta_db_light \
--output plasmid_annotation/ \
--prefix pBR322 \
--plasmid \
--complete \
--threads 4
# Inspect plasmid-typing results (incompatibility group, replication initiator)
grep -E "rep|inc" plasmid_annotation/pBR322.tsv | head -10
Recipe: MAG (Metagenome-Assembled Genome) Annotation
When to use: Annotating a bin from metagenomic assembly where Prodigal cannot train on the full sequence.
bakta MAG_bin_42.fasta \
--db db/bakta_db_light \
--output mag_annotation/ \
--prefix MAG_bin_42 \
--meta \
--min-contig-length 500 \
--skip-plot \
--threads 8
cat mag_annotation/MAG_bin_42.txt | head -20
Recipe: Annotation with Custom Expert Protein Database
When to use: You have curated reference proteins (e.g., a virulence factor catalog) that should take priority over UniRef.
# Custom proteins are searched before the bundled UniRef DB
bakta genome.fasta \
--db db/bakta_db_light \
--proteins virulence_factors.faa \
--output annotation_custom/ \
--prefix novel_strain \
--threads 8
echo "Annotations referencing custom DB:"
grep "User-provided" annotation_custom/novel_strain.tsv | head
Recipe: Convert Bakta GFF3 to a Pandas DataFrame
When to use: You need feature coordinates and qualifiers for downstream coordinate-overlap analyses.
import pandas as pd
def parse_gff3(gff_file):
rows = []
with open(gff_file) as f:
for line in f:
if line.startswith("##FASTA"):
break
if line.startswith("#") or not line.strip():
continue
parts = line.rstrip("\n").split("\t")
if len(parts) < 9:
continue
attrs = {}
for item in parts[8].split(";"):
if "=" in item:
k, v = item.split("=", 1)
attrs[k] = v
rows.append({
"seqname": parts[0],
"feature": parts[2],
"start": int(parts[3]),
"end": int(parts[4]),
"strand": parts[6],
"locus_tag": attrs.get("locus_tag", attrs.get("ID", "")),
"gene": attrs.get("gene", ""),
"product": attrs.get("product", ""),
})
return pd.DataFrame(rows)
gff_df = parse_gff3("annotation/E_coli_K12.gff3")
print(f"Features parsed: {len(gff_df)}")
print(gff_df[gff_df["feature"] == "CDS"].head(5).to_string(index=False))
Expected Outputs
| Output File | Format | Description |
|---|---|---|
{prefix}.gff3 |
GFF3 | Genome annotation with feature coordinates and attributes; INSDC-compliant |
{prefix}.gbff |
GenBank Flat File | Annotated GenBank record for use in Geneious, BioPython, NCBI submission prep |
{prefix}.embl |
EMBL | EMBL-format annotation for ENA submission |
{prefix}.fna |
FASTA | Nucleotide sequences of the input contigs |
{prefix}.ffn |
FASTA | Nucleotide sequences of all annotated features |
{prefix}.faa |
FASTA | Protein sequences for all CDS features |
{prefix}.hypotheticals.faa |
FASTA | Protein sequences flagged as hypothetical (for further investigation) |
{prefix}.hypotheticals.tsv |
TSV | Detailed table for hypothetical CDS with low-confidence hits |
{prefix}.tsv |
TSV | Full feature table: seqid, type, start, stop, strand, locus_tag, gene, product, dbxrefs |
{prefix}.json |
JSON | Machine-readable summary with genome metadata + every feature with full qualifiers |
{prefix}.png / .svg |
Image | Circos circular genome plot colored by COG category |
{prefix}.txt |
Text | Plain-text summary of feature counts and per-replicon stats |
{prefix}.log |
Text | Full Bakta run log including timing per step |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error: database not found at <path> |
DB path incorrect, or DB not extracted | Re-run bakta_db download --output db/ --type light; pass full path to --db |
KILLED during DIAMOND step |
Out of memory on full DB | Switch to --type light, reduce --threads, or run on a host with ≥ 32 GB RAM |
| Very high hypothetical-protein rate (>60 %) | Divergent strain or light DB without close hits | Re-run with the full DB or supply a curated --proteins reference set |
tRNAscan-SE: Error opening file |
Missing tRNAscan-SE installation in conda env | mamba install -c bioconda trnascan-se and rerun |
| Bakta complains about contig headers | Spaces or special characters in FASTA IDs | Pre-clean headers with awk '/^>/{print ">contig_"++i; next}{print}' |
| Plot generation hangs or fails | Circos / matplotlib backend issue | Re-run with --skip-plot; render later via bakta_plot from the JSON file |
AMRFinderPlus: database not up-to-date warning |
AMRFinderPlus DB stale | amrfinder -u to refresh, or pass --skip-amr to bypass |
| Different locus tag prefix than expected | --locus-tag not set, default uses random prefix |
Explicitly pass --locus-tag MYORG to control prefix |
| Run is much slower than reported | Default --threads 1 |
Set --threads to physical core count; use --skip-plot for batch jobs |
References
- Bakta GitHub: oschwengers/bakta — source code, installation, parameter reference, and changelog
- Schwengers et al. (2021) Microb Genom 7(11):000685 — original Bakta publication with benchmarks vs. Prokka and PGAP
- Bakta database releases on Zenodo — versioned light/full database archives
- Prodigal documentation — gene prediction engine used by Bakta for CDS calling
- BioPython GenBank parsing tutorial — reference for parsing Bakta
.gbffoutput in Python - DIAMOND aligner — protein search engine used for the UniRef-derived database lookups
skills/genomics-bioinformatics/annotation/prokka-genome-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill prokka-genome-annotation -g -y
SKILL.md
Frontmatter
{
"name": "prokka-genome-annotation",
"license": "GPL-3.0",
"description": "Annotate prokaryotic genomes (bacteria, archaea, viruses) via Prokka's BLAST\/HMM pipeline. Identifies CDS, rRNA, tRNA, tmRNA, signal peptides against Pfam, TIGRFAMs, RefSeq. Outputs GFF3, GenBank, FASTA, TSV. Use PGAP for NCBI GenBank submission; Bakta for faster NCBI-compatible annotation."
}
Prokka Genome Annotation
Overview
Prokka is a command-line pipeline for rapid annotation of prokaryotic genomes (bacteria, archaea, and viruses). It uses a tiered search strategy: protein-coding genes (CDS) are predicted with Prodigal and searched first against a genus-specific database, then RefSeq proteins, then Pfam/TIGRFAMs HMMs. Non-coding RNA genes (rRNA, tRNA, tmRNA) are identified with Barrnap, Aragorn, and Infernal. Prokka processes a single FASTA assembly in minutes and outputs a comprehensive annotation in GFF3, GenBank, FASTA, and tabular formats.
When to Use
- Annotating a newly assembled bacterial or archaeal genome from Illumina, PacBio, or Nanopore assemblies
- Getting functional protein annotations (CDS with product names, EC numbers, GO terms) from a draft or complete genome
- Preparing annotation files for downstream comparative genomics (Roary pan-genome, OrthoFinder)
- Annotating viral or phage genomes when kingdom-specific databases are important
- Performing metagenome-assembled genome (MAG) annotation with the
--metagenomeflag - Parsing annotated outputs in Python with BioPython for downstream sequence or feature analysis
- Use PGAP (NCBI Prokaryotic Genome Annotation Pipeline) instead when the goal is NCBI GenBank submission with standards compliance
- Use Bakta instead for faster annotation with built-in NCBI-compatible outputs and a more regularly updated database
Prerequisites
- Software: Prokka ≥ 1.14, Perl 5, Prodigal, Barrnap, HMMER3, BLAST+, Aragorn, Infernal, tbl2asn
- Python packages (for output parsing):
biopython,pandas,matplotlib - Input: assembled genome in FASTA format (complete or draft with multiple contigs)
- Environment: conda strongly recommended to handle the Perl and C dependency stack
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v prokkafirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run prokkarather than bareprokka.
# Install Prokka via conda/mamba (recommended)
conda install -c conda-forge -c bioconda prokka
# Or with mamba (faster)
mamba install -c conda-forge -c bioconda prokka
# Verify installation and database setup
prokka --version
# prokka 1.14.6
# Check that required tools are on PATH
prokka --depends
# prokka needs: awk, sed, grep, makeblastdb, blastp, hmmscan, ...
# Install Python parsing dependencies
pip install biopython pandas matplotlib
Quick Start
# Annotate a bacterial genome assembly — results in results/ directory
prokka genome.fasta \
--outdir results/ \
--prefix sample1 \
--kingdom Bacteria \
--cpus 4
# Check output summary
cat results/sample1.txt
# Organism: Genus species strain
# Contigs: 1
# Bases: 4639675
# CDS: 4140
# rRNA: 22
# tRNA: 86
echo "Annotation complete. Key output files:"
ls results/sample1.{gff,gbk,faa,ffn,tsv}
Workflow
Step 1: Install and Verify Prokka
Install Prokka and confirm all dependent tools are accessible in the current environment.
# Create a dedicated conda environment
conda create -n prokka_env -c conda-forge -c bioconda prokka python=3.10 -y
conda activate prokka_env
# Verify Prokka version and all tool dependencies
prokka --version
# prokka 1.14.6
prokka --depends
# Checking that required tools are installed...
# OK: makeblastdb is installed (2.13.0+)
# OK: blastp is installed (2.13.0+)
# OK: hmmscan is installed (3.3.2)
# OK: prodigal is installed (2.6.3)
# OK: barrnap is installed (0.9)
# Check available genus-specific databases bundled with Prokka
ls $(conda info --base)/envs/prokka_env/db/genus/
# Archaea Bacteria Mitochondria Viruses
# Install Python parsing tools
pip install biopython pandas matplotlib
Step 2: Prepare the Input Genome
Clean and rename contigs to comply with Prokka's header requirements before annotation.
from Bio import SeqIO
import re
# Load and inspect assembly
input_fasta = "genome.fasta"
records = list(SeqIO.parse(input_fasta, "fasta"))
print(f"Input assembly: {len(records)} contigs")
total_bases = sum(len(r) for r in records)
print(f"Total bases: {total_bases:,}")
print(f"Largest contig: {max(len(r) for r in records):,} bp")
print(f"N50 approx: see assembly stats tool")
# Rename contigs to short IDs compatible with Prokka (max 37 chars)
# Prokka requires: no spaces, no special characters in header
cleaned = []
for i, rec in enumerate(records, 1):
new_id = f"contig_{i:04d}"
new_rec = rec.__class__(rec.seq, id=new_id, description=f"len={len(rec.seq)}")
cleaned.append(new_rec)
SeqIO.write(cleaned, "genome_clean.fasta", "fasta")
print(f"\nWrote genome_clean.fasta with {len(cleaned)} renamed contigs")
# genome_clean.fasta: contig_0001 through contig_NNNN
# Alternatively, clean headers with a simple bash one-liner
awk '/^>/{print ">contig_" ++i; next}{print}' genome.fasta > genome_clean.fasta
# Filter out short contigs (< 200 bp) to reduce annotation noise
awk '/^>/{header=$0; next} length($0) >= 200 {print header; print}' \
genome_clean.fasta > genome_filtered.fasta
echo "Filtered assembly ready: $(grep -c '>' genome_filtered.fasta) contigs"
Step 3: Run Basic Prokka Annotation
Run Prokka with standard options for a bacterial genome, specifying genus/species for database selection.
# Basic annotation with genus/species hint (uses genus-specific protein database first)
prokka genome_clean.fasta \
--outdir annotation/ \
--prefix E_coli_K12 \
--kingdom Bacteria \
--genus Escherichia \
--species coli \
--strain K12 \
--cpus 8 \
--mincontiglen 200
# Expected runtime: 2–10 minutes for a typical 4–6 Mb bacterial genome
echo "Prokka annotation output files:"
ls annotation/
# E_coli_K12.err E_coli_K12.faa E_coli_K12.ffn
# E_coli_K12.fna E_coli_K12.gbk E_coli_K12.gff
# E_coli_K12.log E_coli_K12.sqn E_coli_K12.tbl
# E_coli_K12.tsv E_coli_K12.txt
Step 4: Parse Annotation Summary (TSV)
Load the TSV output for a quick overview of annotated features and their functional assignments.
import pandas as pd
# Load the annotation TSV (tab-delimited feature table)
tsv_file = "annotation/E_coli_K12.tsv"
df = pd.read_csv(tsv_file, sep="\t")
print(f"Total features: {len(df)}")
print(f"Columns: {list(df.columns)}")
# Columns: [locus_tag, ftype, length_bp, gene, EC_number, COG, product]
# Feature type summary
print("\nFeature type counts:")
print(df["ftype"].value_counts().to_string())
# CDS 4140
# tRNA 86
# rRNA 22
# tmRNA 1
# Functional gene annotations (non-hypothetical CDS)
cds_df = df[df["ftype"] == "CDS"].copy()
hypothetical = cds_df["product"].str.contains("hypothetical", case=False, na=True)
print(f"\nCDS with known function: {(~hypothetical).sum()}")
print(f"Hypothetical proteins: {hypothetical.sum()}")
# Genes with EC numbers (enzymes)
ec_annotated = cds_df[cds_df["EC_number"].notna() & (cds_df["EC_number"] != "")]
print(f"CDS with EC numbers: {len(ec_annotated)}")
print(ec_annotated[["locus_tag", "gene", "EC_number", "product"]].head(5).to_string(index=False))
Step 5: Parse GenBank Output with BioPython
Read the GenBank file to access per-gene sequences, qualifiers, and feature coordinates.
from Bio import SeqIO
import pandas as pd
# Parse GenBank file
gbk_file = "annotation/E_coli_K12.gbk"
records = list(SeqIO.parse(gbk_file, "genbank"))
print(f"Contigs in GenBank: {len(records)}")
# Iterate over CDS features and extract details
rows = []
for rec in records:
for feat in rec.features:
if feat.type != "CDS":
continue
qualifiers = feat.qualifiers
rows.append({
"contig": rec.id,
"locus_tag": qualifiers.get("locus_tag", ["?"])[0],
"gene": qualifiers.get("gene", [""])[0],
"product": qualifiers.get("product", ["hypothetical protein"])[0],
"EC_number": qualifiers.get("EC_number", [""])[0],
"protein_id": qualifiers.get("protein_id", [""])[0],
"start": int(feat.location.start),
"end": int(feat.location.end),
"strand": feat.location.strand,
"aa_length": len(qualifiers.get("translation", [""])[0]),
})
features_df = pd.DataFrame(rows)
print(f"CDS features extracted: {len(features_df)}")
print(features_df.head(3).to_string(index=False))
# Retrieve protein sequence for a specific gene
gene_name = "dnaA"
gene_feat = features_df[features_df["gene"] == gene_name]
if not gene_feat.empty:
idx = gene_feat.index[0]
print(f"\n{gene_name}: {features_df.loc[idx, 'aa_length']} aa")
Step 6: Visualize Annotation Statistics
Generate a summary barplot of feature types and functional annotation coverage.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
tsv_file = "annotation/E_coli_K12.tsv"
df = pd.read_csv(tsv_file, sep="\t")
cds = df[df["ftype"] == "CDS"].copy()
n_cds = len(cds)
n_known = (~cds["product"].str.contains("hypothetical", case=False, na=True)).sum()
n_hypo = n_cds - n_known
n_ec = cds["EC_number"].notna().sum()
n_rrna = (df["ftype"] == "rRNA").sum()
n_trna = (df["ftype"] == "tRNA").sum()
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# Left panel: feature type counts
types = df["ftype"].value_counts()
colors_left = ["#2980B9", "#27AE60", "#E74C3C", "#F39C12"] + ["#95A5A6"] * len(types)
axes[0].bar(types.index, types.values, color=colors_left[:len(types)], edgecolor="white")
axes[0].set_title("Annotated Feature Counts")
axes[0].set_xlabel("Feature Type")
axes[0].set_ylabel("Count")
for i, (label, val) in enumerate(types.items()):
axes[0].text(i, val + 5, str(val), ha="center", fontsize=9)
# Right panel: CDS functional annotation breakdown
labels = ["Known function", "Hypothetical protein", "Enzyme (EC number)"]
sizes = [n_known, n_hypo, n_ec]
colors_right = ["#2980B9", "#BDC3C7", "#E74C3C"]
bars = axes[1].bar(labels, sizes, color=colors_right, edgecolor="white")
axes[1].set_title(f"CDS Functional Annotation\n(n={n_cds} total CDS)")
axes[1].set_ylabel("Count")
axes[1].tick_params(axis="x", rotation=15)
for bar, val in zip(bars, sizes):
axes[1].text(bar.get_x() + bar.get_width() / 2,
val + 10, str(val), ha="center", fontsize=9)
plt.suptitle("Prokka Genome Annotation Summary", fontsize=12, fontweight="bold")
plt.tight_layout()
plt.savefig("prokka_annotation_summary.png", dpi=150, bbox_inches="tight")
print(f"Saved prokka_annotation_summary.png")
print(f"CDS: {n_cds} | Known function: {n_known} | Hypothetical: {n_hypo}")
print(f"rRNA: {n_rrna} | tRNA: {n_trna} | EC-annotated CDS: {n_ec}")
Step 7: Batch Annotation Across Multiple Genomes
Annotate multiple genome assemblies sequentially and collect summary statistics.
#!/bin/bash
# batch_prokka.sh — annotate all FASTA files in a directory
INPUT_DIR="genomes/"
OUTPUT_DIR="annotations/"
mkdir -p "$OUTPUT_DIR"
for FASTA in "$INPUT_DIR"/*.fasta; do
SAMPLE=$(basename "$FASTA" .fasta)
echo "Annotating: $SAMPLE"
prokka "$FASTA" \
--outdir "${OUTPUT_DIR}/${SAMPLE}" \
--prefix "$SAMPLE" \
--kingdom Bacteria \
--cpus 4 \
--mincontiglen 200 \
--quiet
echo " Done: ${OUTPUT_DIR}/${SAMPLE}/${SAMPLE}.txt"
done
echo "Batch annotation complete."
# Collect summary statistics from all Prokka .txt files
from pathlib import Path
import pandas as pd
annotation_dir = Path("annotations/")
rows = []
for txt_file in sorted(annotation_dir.glob("*/*.txt")):
sample = txt_file.stem
stats = {}
with open(txt_file) as f:
for line in f:
line = line.strip()
if ": " in line:
key, val = line.split(": ", 1)
stats[key.strip()] = val.strip()
rows.append({
"sample": sample,
"contigs": int(stats.get("Contigs", 0)),
"bases": int(stats.get("Bases", 0)),
"CDS": int(stats.get("CDS", 0)),
"rRNA": int(stats.get("rRNA", 0)),
"tRNA": int(stats.get("tRNA", 0)),
})
summary_df = pd.DataFrame(rows)
print(f"Annotated genomes: {len(summary_df)}")
print(summary_df.to_string(index=False))
summary_df.to_csv("batch_annotation_summary.csv", index=False)
print("\nSaved: batch_annotation_summary.csv")
Step 8: Compare Annotations Between Strains
Use the protein FASTA outputs for cross-strain comparison with identity-based clustering.
from Bio import SeqIO, pairwise2
import pandas as pd
# Load protein sequences from two strains
def load_proteins(faa_file):
"""Return dict of locus_tag -> protein sequence."""
return {rec.id: str(rec.seq)
for rec in SeqIO.parse(faa_file, "fasta")}
strain_a = load_proteins("annotation_A/strain_A.faa")
strain_b = load_proteins("annotation_B/strain_B.faa")
print(f"Strain A proteins: {len(strain_a)}")
print(f"Strain B proteins: {len(strain_b)}")
# Compare gene counts and size distributions
import matplotlib.pyplot as plt
import numpy as np
len_a = [len(seq) for seq in strain_a.values()]
len_b = [len(seq) for seq in strain_b.values()]
fig, ax = plt.subplots(figsize=(8, 4))
bins = np.linspace(0, 1500, 50)
ax.hist(len_a, bins=bins, alpha=0.6, label=f"Strain A (n={len(len_a)})", color="#2980B9")
ax.hist(len_b, bins=bins, alpha=0.6, label=f"Strain B (n={len(len_b)})", color="#E74C3C")
ax.set_xlabel("Protein Length (aa)")
ax.set_ylabel("Count")
ax.set_title("Protein Length Distribution by Strain")
ax.legend()
plt.tight_layout()
plt.savefig("strain_protein_length_comparison.png", dpi=150, bbox_inches="tight")
print("Saved strain_protein_length_comparison.png")
# Find proteins unique to each strain by size/count difference
print(f"\nSize difference: {abs(len(strain_a) - len(strain_b))} proteins")
print("→ Use Roary or OrthoFinder for formal pan-genome analysis")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
--kingdom |
Bacteria |
Bacteria, Archaea, Viruses, Mitochondria |
Selects gene prediction model and default databases |
--genus |
— | any genus name string | Prioritizes genus-specific protein database for CDS annotation |
--species |
— | any species name string | Combined with --genus for locus_tag prefix and organism metadata |
--strain |
— | any string | Added to organism metadata in GenBank output |
--proteins |
— | path to FASTA file | Custom protein database prepended before RefSeq search |
--hmms |
— | path to HMM file | Additional custom HMM database for specialized annotation |
--evalue |
1e-6 |
1e-4–1e-9 |
E-value cutoff for BLAST and HMMER hits |
--cpus |
8 |
1–CPU count |
Parallel processes for BLAST and HMMER searches |
--mincontiglen |
1 |
any integer | Skip contigs shorter than this length (bp) |
--metagenome |
off | flag | Disables Prodigal training on this genome (for MAGs) |
--rfam |
off | flag | Enable Infernal rRNA/ncRNA search against Rfam (slower) |
--norrna |
off | flag | Skip rRNA prediction (use when assembly has no rRNA genes) |
--notrna |
off | flag | Skip tRNA prediction |
Common Recipes
Recipe: Annotation with Custom Protein Database
When to use: You have closely related reference proteins (e.g., characterized isolate) to improve annotation accuracy.
# Use a custom protein database to enhance annotation of a novel strain
# Custom proteins are searched first, before internal Prokka databases
prokka genome.fasta \
--proteins reference_proteins.faa \
--outdir custom_annotation/ \
--prefix novel_strain \
--kingdom Bacteria \
--cpus 4
echo "Custom DB annotation complete:"
grep "CDS" custom_annotation/novel_strain.txt
Recipe: Metagenome-Assembled Genome (MAG) Annotation
When to use: Annotating a MAG where Prodigal cannot train on the full genome sequence.
# --metagenome disables Prodigal model training (uses meta mode)
# --mincontiglen 500 discards short, potentially chimeric contigs
prokka MAG_bin_42.fasta \
--metagenome \
--kingdom Bacteria \
--outdir mag_annotation/ \
--prefix MAG_bin_42 \
--mincontiglen 500 \
--cpus 4
echo "MAG annotation complete:"
cat mag_annotation/MAG_bin_42.txt
Recipe: Extract Specific Gene Sequences
When to use: Retrieve nucleotide or protein sequences for a target gene or pathway from the annotation.
from Bio import SeqIO
# Extract all sequences for a specific gene name from protein FASTA
faa_file = "annotation/E_coli_K12.faa"
target_gene = "dnaA"
matches = []
for rec in SeqIO.parse(faa_file, "fasta"):
# Prokka FASTA header: >locus_tag gene product
if target_gene in rec.description:
matches.append(rec)
print(f"Proteins matching '{target_gene}': {len(matches)}")
for rec in matches:
print(f" {rec.id}: {len(rec.seq)} aa — {rec.description}")
# Save matching sequences
if matches:
SeqIO.write(matches, f"{target_gene}_proteins.faa", "fasta")
print(f"Saved: {target_gene}_proteins.faa")
Recipe: Convert GFF to Pandas DataFrame
When to use: Work with genomic coordinates for feature overlap analysis or visualization.
import pandas as pd
def parse_gff(gff_file):
"""Parse Prokka GFF3 file into a DataFrame (skips sequence section)."""
rows = []
with open(gff_file) as f:
for line in f:
if line.startswith("##FASTA"):
break
if line.startswith("#") or not line.strip():
continue
parts = line.rstrip("\n").split("\t")
if len(parts) < 9:
continue
attr_dict = {}
for item in parts[8].split(";"):
if "=" in item:
k, v = item.split("=", 1)
attr_dict[k] = v
rows.append({
"seqname": parts[0],
"source": parts[1],
"feature": parts[2],
"start": int(parts[3]),
"end": int(parts[4]),
"score": parts[5],
"strand": parts[6],
"frame": parts[7],
"locus_tag": attr_dict.get("ID", ""),
"gene": attr_dict.get("gene", ""),
"product": attr_dict.get("product", ""),
})
return pd.DataFrame(rows)
gff_df = parse_gff("annotation/E_coli_K12.gff")
print(f"GFF features: {len(gff_df)}")
print(gff_df[gff_df["feature"] == "CDS"].head(5)[
["seqname", "start", "end", "strand", "gene", "product"]
].to_string(index=False))
Expected Outputs
| Output File | Format | Description |
|---|---|---|
{prefix}.gff |
GFF3 | Genome annotation with feature coordinates and attributes; includes FASTA sequence at the end |
{prefix}.gbk |
GenBank | Full GenBank-format annotation for use in Geneious, BioPython, and submission prep |
{prefix}.faa |
FASTA | Predicted protein sequences for all CDS features |
{prefix}.ffn |
FASTA | Nucleotide sequences for all annotated features (CDS, rRNA, tRNA) |
{prefix}.fna |
FASTA | Nucleotide FASTA of the complete assembly (contigs) |
{prefix}.tsv |
TSV | Tab-delimited summary table: locus_tag, ftype, length_bp, gene, EC_number, COG, product |
{prefix}.txt |
Text | One-line summary counts: Contigs, Bases, CDS, rRNA, tRNA, tmRNA |
{prefix}.tbl |
TBL | Feature table format for tbl2asn GenBank submission |
{prefix}.sqn |
SQN | ASN.1 format for NCBI submission (generated by tbl2asn) |
{prefix}.err |
Text | Warnings and errors from tbl2asn validation step |
{prefix}.log |
Text | Full Prokka run log with timing per step |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FATAL: Can't find any tRNA genes |
No tRNA detected on short or highly fragmented assembly | Add --notrna flag; check if assembly is too fragmented (N50 < 1 kb) |
Can't exec "makeblastdb" |
BLAST+ not on PATH | conda install -c bioconda blast; ensure prokka env is activated |
Contig ID too long (>37 chars) |
Assembler produced long contig headers | Pre-process FASTA to shorten headers: awk '/^>/{print ">contig_"++i; next}{print}' |
| Very high hypothetical protein rate (>60%) | Divergent organism with few database matches | Add --proteins with closely related strain FAA; consider --genus flag |
ERROR: Argument --proteins: file does not exist |
Path to custom protein file is incorrect | Use absolute path; verify file exists with ls -la custom.faa |
tbl2asn error: multiple /product |
Duplicate product qualifiers in annotation | Ignore if exporting for local use; for NCBI submission, use PGAP instead |
| Annotation is very slow (>30 min for ~5 Mb) | --cpus not set or set to 1; --rfam enabled |
Set --cpus to available thread count; disable --rfam for faster runs |
| rRNA count is 0 for complete genome | --norrna flag was set, or barrnap threshold too strict |
Remove --norrna; check barrnap is installed with barrnap --version |
References
- Prokka GitHub: tseemann/prokka — source code, installation instructions, and detailed parameter reference
- Seemann T (2014) Bioinformatics 30(14):2068–2069 — original Prokka paper with benchmarks vs. RAST
- Prodigal documentation — gene prediction engine used by Prokka for CDS calling
- Barrnap GitHub: tseemann/barrnap — rRNA prediction tool bundled with Prokka
- BioPython GenBank parsing tutorial — reference for parsing Prokka GenBank output in Python
skills/genomics-bioinformatics/annotation/roary-pangenome/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill roary-pangenome -g -y
SKILL.md
Frontmatter
{
"name": "roary-pangenome",
"license": "GPL-3.0",
"description": "Compute the bacterial pan-genome from Prokka\/Bakta GFF3 annotations with Roary's CD-HIT + BLAST + MCL clustering pipeline. Builds gene presence\/absence matrices, core\/soft-core\/shell\/cloud partitions, multi-FASTA core gene alignments (with `-e`), and a pan-genome reference. Use Panaroo for higher-accuracy pan-genomes from highly fragmented assemblies, PIRATE for paralog-aware clustering, or PPanGGOLiN for graph-based partitioning."
}
Roary Pan-Genome Pipeline
Overview
Roary is a high-throughput pan-genome pipeline for prokaryotes that takes per-sample GFF3 annotations (typically from Prokka or Bakta) and produces a clustered gene presence/absence matrix across the entire input set. It first reduces redundancy with CD-HIT iterative clustering, then performs an all-vs-all BLASTP within each pre-cluster, and finally applies MCL graph clustering to define orthologous gene families. The output partitions the gene space into core (≥ 99 %), soft-core (95–99 %), shell (15–95 %), and cloud (< 15 %) genes and optionally builds a concatenated core-gene alignment suitable for phylogenetic inference.
When to Use
- Computing a pan-genome from a set of bacterial isolate annotations (10–10,000 genomes)
- Producing a
gene_presence_absence.csvmatrix for downstream GWAS, accessory-gene mining, or core-gene phylogenetics - Building a concatenated core-gene multi-FASTA alignment for ML/Bayesian phylogenetic trees
- Generating a pan-genome reference FASTA to use as a non-redundant gene catalog
- Comparative genomics across closely related strains where >95 % nucleotide identity is expected
- Use Panaroo instead when assemblies are highly fragmented or annotations are noisy (Panaroo aggressively cleans annotation errors)
- Use PIRATE instead when paralog-aware clustering with multiple identity thresholds is needed
- Use PPanGGOLiN instead when graph-based, statistically grounded gene-family partitioning is preferred over fixed-frequency cutoffs
Prerequisites
- Software: Roary ≥ 3.13, Perl 5, BLAST+, CD-HIT, MCL, BEDTools, PRANK or MAFFT (for
-ecore alignment), FastTree (optional) - Python packages (for output parsing):
pandas,matplotlib,seaborn,biopython,dendropy - Input: per-sample GFF3 files with embedded FASTA at the end (Prokka/Bakta default output)
- Hardware: ≥ 8 GB RAM for ~50 genomes; 32 GB+ recommended for ~500 genomes
- Naming: each GFF3 filename becomes the sample column header in the output matrix; use
sample_id.gffstyle
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v roaryfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run roaryrather than bareroary.
# Install Roary via conda/mamba (recommended)
mamba install -c conda-forge -c bioconda roary
# Verify installation
roary --version
# 3.13.0
# Verify dependent tools
which cd-hit blastp mcl bedtools mafft
# /opt/conda/bin/cd-hit
# /opt/conda/bin/blastp
# /opt/conda/bin/mcl
# /opt/conda/bin/bedtools
# /opt/conda/bin/mafft
# Install Python parsing dependencies
pip install pandas matplotlib seaborn biopython dendropy
Quick Start
# Run Roary on all GFF3 files in current directory; emit core gene alignment
roary -e --mafft -p 8 -o pangenome -f roary_out/ *.gff
# Inspect summary statistics
cat roary_out/summary_statistics.txt
# Core genes (99% <= strains <= 100%) 2823
# Soft core genes (95% <= strains < 99%) 78
# Shell genes (15% <= strains < 95%) 1542
# Cloud genes ( 0% <= strains < 15%) 3104
# Total genes 7547
Workflow
Step 1: Install Roary and Verify Dependencies
Install Roary in a dedicated environment to avoid Perl module conflicts.
# Create a dedicated conda environment
mamba create -n roary_env -c conda-forge -c bioconda roary mafft fasttree python=3.11 -y
mamba activate roary_env
# Verify Roary
roary --version
# 3.13.0
# Confirm pipeline dependencies are reachable
for tool in cd-hit blastp mcl bedtools mafft FastTree; do
if command -v $tool >/dev/null; then
echo "OK: $tool"
else
echo "MISSING: $tool"
fi
done
# Install Python parsing tools
pip install pandas matplotlib seaborn biopython dendropy
Step 2: Prepare Per-Sample GFF3 Files from Prokka or Bakta
Roary requires GFF3 files with an embedded FASTA section (the ##FASTA block). Both Prokka .gff and Bakta .gff3 outputs satisfy this format.
# Verify GFF3 files include the embedded FASTA section
mkdir -p roary_input/
cp annotations/*/sample*.gff roary_input/
for GFF in roary_input/*.gff; do
if grep -q "^##FASTA" "$GFF"; then
echo "OK: $GFF"
else
echo "MISSING ##FASTA in: $GFF"
fi
done
# Roary uses GFF filename (without .gff) as the sample column name.
# Rename or symlink to ensure unique, descriptive names.
cd roary_input/
ls *.gff | head
# strain_A.gff strain_B.gff strain_C.gff
# Sanity-check sample size and unique names
from pathlib import Path
gff_files = sorted(Path("roary_input/").glob("*.gff"))
print(f"GFF files: {len(gff_files)}")
names = [g.stem for g in gff_files]
duplicates = [n for n in names if names.count(n) > 1]
assert not duplicates, f"Duplicate sample names: {set(duplicates)}"
# Show first 5
for g in gff_files[:5]:
size_mb = g.stat().st_size / 1e6
print(f" {g.name}: {size_mb:.1f} MB")
Step 3: Run Roary with Core Gene Alignment
The -e --mafft combination produces the concatenated core-gene alignment used downstream for phylogenetics.
# Run Roary on the prepared GFF directory
# -e: extract core gene alignment per gene, then concatenate
# --mafft: use MAFFT for alignment (faster than default PRANK)
# -p 8: 8 parallel processes
# -i 95: min BLASTP percentage identity (default 95)
# -cd 99: % isolates a gene must be in to be "core" (default 99)
roary -e --mafft \
-p 8 \
-i 95 \
-cd 99 \
-o pangenome \
-f roary_out/ \
roary_input/*.gff
# Expected runtime:
# ~50 genomes: 10–20 min
# ~500 genomes: 4–8 hours
ls roary_out/
# accessory_binary_genes.fa gene_presence_absence.csv
# accessory_binary_genes.fa.newick gene_presence_absence.Rtab
# accessory.header.embl number_of_conserved_genes.Rtab
# accessory.tab number_of_genes_in_pan_genome.Rtab
# blast_identity_frequency.Rtab number_of_new_genes.Rtab
# clustered_proteins number_of_unique_genes.Rtab
# core_accessory.header.embl pan_genome_reference.fa
# core_accessory.tab summary_statistics.txt
# core_gene_alignment.aln
Step 4: Parse the Gene Presence/Absence Matrix
The CSV output is the canonical pan-genome matrix: rows are gene families, columns include metadata and one column per sample (filled with locus tags or empty).
import pandas as pd
import numpy as np
df = pd.read_csv("roary_out/gene_presence_absence.csv", low_memory=False)
print(f"Gene families: {len(df)}")
print(f"Columns: {len(df.columns)}")
# Standard metadata columns end with 'Inference', then sample columns follow
metadata_cols = list(df.columns[:14])
sample_cols = list(df.columns[14:])
print(f"Samples: {len(sample_cols)}")
# Build a binary presence/absence matrix (1 = locus tag present, 0 = empty)
binary = df[sample_cols].notna().astype(int)
binary.index = df["Gene"]
print(f"Binary matrix shape: {binary.shape}")
# Pan-genome partition by frequency
n_samples = len(sample_cols)
freq = binary.sum(axis=1) / n_samples
core = freq[freq >= 0.99]
soft_core = freq[(freq >= 0.95) & (freq < 0.99)]
shell = freq[(freq >= 0.15) & (freq < 0.95)]
cloud = freq[freq < 0.15]
print(f"\nPan-genome partition (n={n_samples} genomes):")
print(f" Core (>=99 %): {len(core):>5}")
print(f" Soft-core (95–99 %): {len(soft_core):>5}")
print(f" Shell (15–95 %): {len(shell):>5}")
print(f" Cloud (<15 %): {len(cloud):>5}")
print(f" Total : {len(freq):>5}")
Step 5: Visualize the Pan-Genome Frequency Distribution
A frequency histogram exposes whether the input set has a U-shaped (open) or core-skewed (closed) pan-genome.
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
df = pd.read_csv("roary_out/gene_presence_absence.csv", low_memory=False)
sample_cols = list(df.columns[14:])
n = len(sample_cols)
binary = df[sample_cols].notna().astype(int)
freq = binary.sum(axis=1)
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# Histogram of gene frequencies
axes[0].hist(freq, bins=range(1, n + 2), color="#2980B9", edgecolor="white")
axes[0].axvline(0.99 * n, color="red", linestyle="--", label="99 % core cutoff")
axes[0].axvline(0.15 * n, color="orange", linestyle="--", label="15 % shell cutoff")
axes[0].set_xlabel("Number of genomes containing the gene")
axes[0].set_ylabel("Number of gene families")
axes[0].set_title("Pan-genome frequency distribution")
axes[0].legend()
# Pie chart of pan-genome partition
freqp = freq / n
core_n = (freqp >= 0.99).sum()
soft_n = ((freqp >= 0.95) & (freqp < 0.99)).sum()
shell_n = ((freqp >= 0.15) & (freqp < 0.95)).sum()
cloud_n = (freqp < 0.15).sum()
axes[1].pie([core_n, soft_n, shell_n, cloud_n],
labels=[f"Core ({core_n})", f"Soft-core ({soft_n})",
f"Shell ({shell_n})", f"Cloud ({cloud_n})"],
colors=["#27AE60", "#3498DB", "#F39C12", "#95A5A6"],
autopct="%1.1f%%", startangle=90)
axes[1].set_title(f"Pan-genome partition (n={n} genomes)")
plt.tight_layout()
plt.savefig("pangenome_distribution.png", dpi=150, bbox_inches="tight")
print(f"Saved pangenome_distribution.png (total families: {len(freq)})")
Step 6: Build a Phylogenetic Tree from the Core Gene Alignment
Use FastTree on the concatenated core-gene alignment to recover strain relationships.
# FastTree expects an aligned multi-FASTA; Roary produces this with `-e`
FastTree -nt -gtr -nosupport \
< roary_out/core_gene_alignment.aln \
> roary_out/core_gene_tree.nwk
echo "Tree built: roary_out/core_gene_tree.nwk"
# (Optional) IQ-TREE for support values:
# iqtree -s core_gene_alignment.aln -m GTR+G -bb 1000 -nt 8
# Visualize the FastTree result alongside the gene presence/absence matrix
import dendropy
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
tree = dendropy.Tree.get(path="roary_out/core_gene_tree.nwk", schema="newick")
leaves = [leaf.taxon.label for leaf in tree.leaf_node_iter()]
print(f"Tree leaves: {len(leaves)}")
print(f"Tree length: {tree.length():.4f} substitutions/site")
# Print first 10 leaves with their root-to-tip distances
for leaf in list(tree.leaf_node_iter())[:10]:
dist = leaf.distance_from_root()
print(f" {leaf.taxon.label:<25} d={dist:.4f}")
Step 7: Produce Roary's Built-in Summary Plots
Roary ships with a roary_plots.py companion that builds a tree-anchored matrix figure.
# Use the bundled python helper from the Roary install
# (download from: https://github.com/sanger-pathogens/Roary/blob/master/contrib/roary_plots/roary_plots.py)
python roary_plots.py \
roary_out/core_gene_tree.nwk \
roary_out/gene_presence_absence.csv \
--format png \
--labels
ls *.png
# pangenome_frequency.png
# pangenome_matrix.png
# pangenome_pie.png
Step 8: Compute Per-Genome Accessory Gene Counts
Identify which genomes carry the most unique (cloud) genes — useful for strain-specific gene investigation.
import pandas as pd
df = pd.read_csv("roary_out/gene_presence_absence.csv", low_memory=False)
sample_cols = list(df.columns[14:])
n = len(sample_cols)
binary = df[sample_cols].notna().astype(int)
binary.index = df["Gene"]
freq = binary.sum(axis=1)
cloud_mask = freq < (0.15 * n)
shell_mask = (freq >= 0.15 * n) & (freq < 0.95 * n)
per_genome = pd.DataFrame({
"genes_total": binary.sum(axis=0),
"cloud_genes": binary.loc[cloud_mask].sum(axis=0),
"shell_genes": binary.loc[shell_mask].sum(axis=0),
})
per_genome = per_genome.sort_values("cloud_genes", ascending=False)
print(per_genome.head(10).to_string())
per_genome.to_csv("per_genome_accessory_counts.csv")
print("\nSaved per_genome_accessory_counts.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
-i |
95 |
70–99 (% identity) |
Minimum BLASTP percentage identity for clustering |
-cd |
99 |
90–100 (%) |
Minimum % of isolates a gene must be in to be called "core" |
-p |
1 |
1–CPU count |
Number of parallel processes for BLAST and MCL stages |
-e |
off | flag | Extract concatenated core gene multi-FASTA alignment (per-gene MSA + concat) |
--mafft |
off | flag (with -e) |
Use MAFFT for alignment (faster than default PRANK) |
-s |
off | flag | Do not split paralogs into separate gene families |
-n |
off | flag | Use fast core gene alignment with MAFFT (skips per-gene PRANK) |
-g |
50000 |
any integer | Maximum number of clusters expected (cap on output size) |
-iv |
1.5 |
1.2–5.0 |
MCL inflation value; higher = tighter clusters |
-r |
off | flag | Generate R plots (presence/absence heatmap, pan-genome curves) |
-o |
clustered_proteins |
string | Output prefix for clustered proteins file |
-f |
. |
path | Output directory |
-z |
off | flag | Don't delete intermediate files (useful for debugging) |
Common Recipes
Recipe: Core Gene Alignment from Existing Roary Output
When to use: You already ran Roary without -e and now need a phylogenetic alignment.
# Re-run only the alignment step using the extracted core genes
# query_pan_genome from the Roary suite extracts gene-family multi-FASTAs
query_pan_genome -a intersection \
-g roary_out/clustered_proteins \
-o core_gene_list.txt \
roary_input/*.gff
echo "Core genes: $(wc -l < core_gene_list.txt)"
# Then re-run Roary with -e to materialize the alignment:
# roary -e --mafft -p 8 -f roary_out2/ roary_input/*.gff
Recipe: Lower Identity Threshold for Diverse Genus-Level Sets
When to use: Comparing genomes across multiple closely related species (e.g., genus-level pan-genome).
# Drop identity to 70 % to capture orthologs across species boundaries
roary -e --mafft \
-p 8 \
-i 70 \
-cd 99 \
-f roary_genus/ \
roary_input/*.gff
cat roary_genus/summary_statistics.txt
# Expect a smaller core and a much larger shell + cloud
Recipe: Roary on a Subset of Strains
When to use: You want a pan-genome of a specific subclade without re-annotating all genomes.
# Stage GFF files for the subset
mkdir -p subset/
for SAMPLE in strain_A strain_B strain_E strain_K; do
cp roary_input/${SAMPLE}.gff subset/
done
roary -e --mafft -p 4 -f roary_subset/ subset/*.gff
echo "Subset pan-genome:"
cat roary_subset/summary_statistics.txt
Recipe: Cross-Tabulate Accessory Genes Against Strain Metadata
When to use: You want to find genes enriched in a specific phenotype or geographic group.
import pandas as pd
from scipy.stats import fisher_exact
# Load presence/absence and strain metadata
pa = pd.read_csv("roary_out/gene_presence_absence.csv", low_memory=False)
sample_cols = list(pa.columns[14:])
binary = pa[sample_cols].notna().astype(int)
binary.index = pa["Gene"]
binary.columns = sample_cols
# Metadata: sample_id, phenotype (e.g., "resistant" / "susceptible")
meta = pd.read_csv("strain_metadata.csv").set_index("sample_id")
group_resistant = meta[meta["phenotype"] == "resistant"].index
group_susceptible = meta[meta["phenotype"] == "susceptible"].index
group_resistant = [g for g in group_resistant if g in binary.columns]
group_susceptible = [g for g in group_susceptible if g in binary.columns]
# Fisher's exact test per gene family
results = []
for gene, row in binary.iterrows():
a = int(row[group_resistant].sum())
b = len(group_resistant) - a
c = int(row[group_susceptible].sum())
d = len(group_susceptible) - c
if a + c == 0 or a + c == len(group_resistant) + len(group_susceptible):
continue # skip invariant genes
odds, pval = fisher_exact([[a, b], [c, d]])
results.append({"gene": gene, "n_resistant": a, "n_susceptible": c,
"odds_ratio": odds, "pvalue": pval})
results_df = pd.DataFrame(results).sort_values("pvalue")
print(f"Gene-phenotype associations (top 10):")
print(results_df.head(10).to_string(index=False))
results_df.to_csv("phenotype_associated_genes.csv", index=False)
Expected Outputs
| Output File | Format | Description |
|---|---|---|
gene_presence_absence.csv |
CSV | Pan-genome matrix; rows = gene families, columns = sample locus tags |
gene_presence_absence.Rtab |
TSV | Binary 0/1 matrix in R-friendly format |
summary_statistics.txt |
Text | Counts of core / soft-core / shell / cloud / total gene families |
pan_genome_reference.fa |
FASTA | Non-redundant nucleotide reference of one representative per gene family |
clustered_proteins |
Text | Cluster definitions: cluster name → constituent locus tags |
core_gene_alignment.aln |
FASTA (aligned) | Concatenated multi-FASTA of core gene alignments (only with -e) |
accessory_binary_genes.fa |
FASTA | Binary 0/1 sequences of accessory presence (input to FastTree) |
accessory_binary_genes.fa.newick |
Newick | Accessory gene phylogeny (built from binary FASTA) |
number_of_conserved_genes.Rtab |
TSV | Rarefaction curve of core genes vs. genome count |
number_of_genes_in_pan_genome.Rtab |
TSV | Rarefaction curve of pan-genome growth |
number_of_new_genes.Rtab |
TSV | New genes added per genome |
number_of_unique_genes.Rtab |
TSV | Per-genome unique gene counts |
blast_identity_frequency.Rtab |
TSV | Distribution of BLAST identities used during clustering |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
GFF file does not contain ##FASTA |
Annotation file is not a Prokka/Bakta-style GFF3 with embedded sequences | Re-export from Prokka/Bakta; do not strip the FASTA tail |
Could not run blastp |
BLAST+ missing from the conda env | mamba install -c bioconda blast and re-activate the env |
| Roary produces 0 core genes | One sample has very different annotations or is misidentified | Inspect gene_presence_absence.csv; remove outlier with roary --check_input style sanity checks |
| Job runs out of memory on > 200 genomes | Default identity (95 %) creates many BLAST jobs | Increase -i to 90 if accuracy permits; split into batches; use Panaroo on RAM-tight machines |
| Paralogs split into many tiny clusters | MCL inflation too high or duplicate locus tags across samples | Add -s to keep paralogs together, or rerun Prokka/Bakta with unique --locus-tag per sample |
FastTree: bad alignment on core_gene_alignment.aln |
Alignment built with PRANK was truncated | Re-run with --mafft for MAFFT-based alignment |
| Output column names look mangled or missing | GFF filenames had spaces or special characters | Rename input GFFs to [a-zA-Z0-9_]+.gff before running |
| Same genome counted twice | Duplicate GFF filenames (e.g., copied symlinks) | Run `ls roary_input/*.gff |
| Pan-genome size grows linearly without saturation | Truly open pan-genome (expected for many bacteria) | This is biological, not a bug; report rarefaction curves and use Heaps' law for confirmation |
References
- Roary GitHub: sanger-pathogens/Roary — source code, parameter reference, and roary_plots.py helper
- Page et al. (2015) Bioinformatics 31(22):3691–3693 — original Roary publication describing the CD-HIT + MCL pipeline
- Roary tutorial — step-by-step worked example with sample data
- Panaroo (alternative tool) — graph-based pan-genome with stricter annotation cleaning
- PIRATE (alternative tool) — paralog-aware pan-genome at multiple identity thresholds
- PPanGGOLiN (alternative tool) — graph-based statistical partitioning of pan-genomes
- FastTree documentation — reference for ML phylogenetic inference from core gene alignments
skills/genomics-bioinformatics/arboreto-grn-inference/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill arboreto-grn-inference -g -y
SKILL.md
Frontmatter
{
"name": "arboreto-grn-inference",
"license": "BSD-3-Clause",
"description": "GRN inference from expression via GRNBoost2 (gradient boosting) or GENIE3 (Random Forest). Load matrix, filter by TFs, infer TF-target-importance links, save network. Dask-parallelized to single-cell scale. Core SCENIC component."
}
Arboreto GRN Inference
Overview
Arboreto infers gene regulatory networks (GRNs) from gene expression data using parallelized tree-based regression. For each target gene, it trains a regression model with all other genes (or a specified TF list) as features and emits TF-target-importance triplets. It provides two interchangeable algorithms -- GRNBoost2 (gradient boosting, fast) and GENIE3 (Random Forest, classic) -- sharing identical input/output formats. Computation is Dask-parallelized, scaling from laptop cores to HPC clusters.
When to Use
- Inferring transcription factor-to-target gene regulatory relationships from bulk RNA-seq expression data
- Building gene regulatory networks from single-cell RNA-seq count matrices (cells as rows, genes as columns)
- Generating the adjacency matrix (Step 1) of the pySCENIC regulatory analysis pipeline
- Comparing regulatory network structure across experimental conditions (e.g., control vs treatment)
- Producing consensus regulatory networks by running inference across multiple random seeds
- Validating GRN results by comparing GRNBoost2 and GENIE3 outputs on the same dataset
- For downstream regulon identification and activity scoring, use arboreto output with pySCENIC
- For single-cell preprocessing (QC, normalization, clustering) before GRN inference, use scanpy-scrna-seq
Prerequisites
- Python packages:
arboreto,pandas,numpy,dask,distributed,scikit-learn,scipy - Data requirements: Gene expression matrix (genes as columns, observations as rows) in TSV/CSV; optionally a TF list file (one gene name per line)
- Environment: Python 3.8+; optional
networkx,matplotlibfor visualization
pip install arboreto distributed networkx matplotlib
Quick Start
Complete GRN inference in a single block. The if __name__ == '__main__': guard is required because Dask spawns worker processes via multiprocessing.
import pandas as pd
from arboreto.algo import grnboost2
from arboreto.utils import load_tf_names
if __name__ == '__main__':
# Load expression matrix (observations x genes)
expression_matrix = pd.read_csv('expression_data.tsv', sep='\t')
tf_names = load_tf_names('tf_list.txt') # optional TF filter
# Infer GRN (uses all local cores by default)
network = grnboost2(expression_data=expression_matrix,
tf_names=tf_names, seed=777)
# Filter top links and save
top_network = network[network['importance'] > 1.0]
top_network.to_csv('grn_output.tsv', sep='\t', index=False, header=False)
print(f"Inferred {len(network)} links, kept {len(top_network)} above threshold")
# Example: Inferred 185432 links, kept 12876 above threshold
Workflow
Step 1: Load Expression Data
Arboreto accepts a pandas DataFrame (recommended) or NumPy array. Rows are observations (cells or samples), columns are genes. Gene names must be column headers.
import pandas as pd
# From TSV (genes as columns, observations as rows)
expression_matrix = pd.read_csv('expression_data.tsv', sep='\t')
print(f"Shape: {expression_matrix.shape} "
f"({expression_matrix.shape[0]} observations x {expression_matrix.shape[1]} genes)")
# Example: Shape: (5000, 18654) (5000 observations x 18654 genes)
# From AnnData (e.g., after scanpy preprocessing)
import anndata as ad
adata = ad.read_h5ad('preprocessed.h5ad')
expression_matrix = pd.DataFrame(
adata.X.toarray() if hasattr(adata.X, 'toarray') else adata.X,
columns=adata.var_names.tolist()
)
print(f"Converted AnnData: {expression_matrix.shape}")
# Example: Converted AnnData: (5000, 18654)
Step 2: Load Transcription Factor List
Providing a TF list restricts regulators to known transcription factors, reducing computation time and improving biological relevance. If omitted, all genes are treated as potential regulators.
from arboreto.utils import load_tf_names
# From file (one TF name per line)
tf_names = load_tf_names('human_tfs.txt')
print(f"Loaded {len(tf_names)} transcription factors")
# Example: Loaded 1639 transcription factors
# Or define directly
tf_names = ['MYC', 'TP53', 'SOX2', 'NANOG', 'POU5F1']
# Verify TFs exist in expression matrix columns
tf_in_data = [tf for tf in tf_names if tf in expression_matrix.columns]
print(f"TFs found in expression data: {len(tf_in_data)}/{len(tf_names)}")
# Example: TFs found in expression data: 1583/1639
Step 3: Configure Dask Client (Optional)
By default arboreto creates an internal Dask client using all local cores. Create an explicit client for resource control, monitoring, or cluster deployment.
from distributed import LocalCluster, Client
# Custom local client with resource limits
local_cluster = LocalCluster(
n_workers=8,
threads_per_worker=1, # avoid GIL contention in scikit-learn
memory_limit='4GB' # per worker
)
client = Client(local_cluster)
print(f"Dashboard: {client.dashboard_link}")
# Example: Dashboard: http://127.0.0.1:8787/status
Step 4: Run GRN Inference
Call grnboost2() (recommended) or genie3(). Both share the same signature and output format.
from arboreto.algo import grnboost2
if __name__ == '__main__':
network = grnboost2(
expression_data=expression_matrix,
tf_names=tf_names, # 'all' if no TF list
client_or_address=client, # omit to use default local scheduler
seed=777, # for reproducibility
verbose=True # print progress
)
print(f"Inferred {len(network)} regulatory links")
print(network.head())
# Example:
# TF target importance
# 0 MYC CDK4 3.214
# 1 MYC CCND1 2.871
# 2 TP53 CDKN1A 2.654
Step 5: Filter Results
Raw output contains links for every TF-target pair with non-zero importance. Filter to retain high-confidence regulatory interactions.
# Strategy 1: Importance threshold
threshold = 1.0
filtered = network[network['importance'] > threshold]
print(f"Threshold {threshold}: {len(filtered)} links "
f"({len(filtered)/len(network)*100:.1f}% retained)")
# Example: Threshold 1.0: 12876 links (6.9% retained)
# Strategy 2: Top N links per target gene
top_n = 10
top_per_target = (network.groupby('target')
.apply(lambda g: g.nlargest(top_n, 'importance'))
.reset_index(drop=True))
print(f"Top {top_n} per target: {len(top_per_target)} links")
# Example: Top 10 per target: 186540 links
Step 6: Save Network
Save as a TSV file with three columns: TF, target, importance.
# Save full network
network.to_csv('full_network.tsv', sep='\t', index=False)
print(f"Saved full network: {len(network)} links")
# Save filtered network (without header for pySCENIC compatibility)
filtered.to_csv('filtered_network.tsv', sep='\t', index=False, header=False)
print(f"Saved filtered network: {len(filtered)} links")
# Clean up Dask client if explicitly created
client.close()
local_cluster.close()
Step 7: Visualize Results (Optional)
Plot the top regulatory interactions as a directed network graph.
import networkx as nx
import matplotlib.pyplot as plt
# Build directed graph from top links
top_links = network.nlargest(50, 'importance')
G = nx.from_pandas_edgelist(
top_links, source='TF', target='target',
edge_attr='importance', create_using=nx.DiGraph()
)
# Draw network
fig, ax = plt.subplots(figsize=(12, 10))
pos = nx.spring_layout(G, k=2, seed=42)
nx.draw_networkx_nodes(G, pos, node_size=300, node_color='lightblue', ax=ax)
nx.draw_networkx_labels(G, pos, font_size=7, ax=ax)
edges = nx.draw_networkx_edges(
G, pos, edge_color=[G[u][v]['importance'] for u, v in G.edges()],
edge_cmap=plt.cm.Reds, width=1.5, arrows=True, ax=ax
)
plt.colorbar(edges, ax=ax, label='Importance')
ax.set_title('Top 50 Regulatory Interactions')
plt.tight_layout()
plt.savefig('grn_network.png', dpi=300, bbox_inches='tight')
print("Saved grn_network.png")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
expression_data |
(required) | DataFrame or ndarray | Expression matrix, observations x genes |
tf_names |
'all' |
list of str or 'all' |
Restrict regulators to known TFs; 'all' uses every gene |
gene_names |
None |
list of str | Required when expression_data is a NumPy array |
client_or_address |
'local' |
Client, str, or 'local' |
Dask client instance or scheduler address |
seed |
None |
int | Random seed for reproducibility; always set for replicable results |
verbose |
False |
bool | Print progress messages during inference |
Key Concepts
Algorithm Selection Guide
Both algorithms follow the same multiple-regression strategy (train one model per target gene, extract feature importances) but differ in the underlying regressor.
| Feature | GRNBoost2 | GENIE3 |
|---|---|---|
| Method | Stochastic gradient boosting with early stopping | Random Forest (or ExtraTrees) |
| Speed | Fast -- optimized for large datasets | Slower -- higher per-model cost |
| Memory | Lower (early stopping limits tree depth) | Higher (full forests per target) |
| Best for | Default choice; 10k+ observations, single-cell scale | Comparison with published GENIE3 results; validation |
| Import | from arboreto.algo import grnboost2 |
from arboreto.algo import genie3 |
| Output format | TF, target, importance (identical) | TF, target, importance (identical) |
Decision rule: Start with GRNBoost2. Use GENIE3 only when reproducing published GENIE3 analyses or as an independent validation of GRNBoost2 results.
Output Format
The network DataFrame has three columns, sorted by descending importance:
| Column | Type | Description |
|---|---|---|
TF |
str | Transcription factor (regulator) gene name |
target |
str | Target gene name |
importance |
float | Regulatory importance score (higher = stronger predicted regulation) |
Importance scores are not p-values. They represent feature importance from the tree-based model. Filter by absolute threshold or top-N per target for downstream analysis.
The if __name__ == '__main__': Requirement
Dask uses Python's multiprocessing module to spawn worker processes. Without the main guard, each spawned process re-executes the script, leading to infinite process spawning. Always wrap arboreto calls in if __name__ == '__main__': when running as a script. This is not needed inside Jupyter notebooks.
Common Recipes
Recipe: Distributed Computing on a Cluster
When to use: datasets too large for a single machine (e.g., 50k+ cells, 20k+ genes). Requires a Dask distributed scheduler running on the cluster.
# On head node: start scheduler
dask-scheduler
# Output: Scheduler at tcp://10.118.224.134:8786
# On each compute node: start workers
dask-worker tcp://10.118.224.134:8786 --nprocs 4 --nthreads 1 --memory-limit 16GB
from distributed import Client
from arboreto.algo import grnboost2
import pandas as pd
if __name__ == '__main__':
client = Client('tcp://10.118.224.134:8786')
print(f"Connected to cluster: {client.scheduler_info()['workers'].__len__()} workers")
# Example: Connected to cluster: 16 workers
expression_data = pd.read_csv('large_scrna.tsv', sep='\t')
tf_names = pd.read_csv('tf_list.txt', header=None)[0].tolist()
network = grnboost2(
expression_data=expression_data,
tf_names=tf_names,
client_or_address=client,
seed=42, verbose=True
)
network.to_csv('cluster_grn.tsv', sep='\t', index=False)
print(f"Cluster inference complete: {len(network)} links")
client.close()
Recipe: Consensus Network from Multiple Seeds
When to use: improve robustness by aggregating networks from multiple random seeds. Links appearing consistently across runs are more reliable.
import pandas as pd
from distributed import LocalCluster, Client
from arboreto.algo import grnboost2
if __name__ == '__main__':
client = Client(LocalCluster(n_workers=8, threads_per_worker=1))
expression_data = pd.read_csv('expression_data.tsv', sep='\t')
tf_names = pd.read_csv('tf_list.txt', header=None)[0].tolist()
seeds = [42, 123, 456, 789, 1001]
all_networks = []
for seed in seeds:
net = grnboost2(expression_data=expression_data,
tf_names=tf_names,
client_or_address=client, seed=seed)
net['seed'] = seed
all_networks.append(net)
print(f"Seed {seed}: {len(net)} links")
combined = pd.concat(all_networks, ignore_index=True)
# Consensus: mean importance across seeds, keep links found in 3+ runs
consensus = (combined.groupby(['TF', 'target'])
.agg(mean_importance=('importance', 'mean'),
n_seeds=('seed', 'nunique'))
.reset_index())
consensus = consensus[consensus['n_seeds'] >= 3]
consensus = consensus.sort_values('mean_importance', ascending=False)
consensus.to_csv('consensus_network.tsv', sep='\t', index=False)
print(f"Consensus network: {len(consensus)} links (found in 3+ of {len(seeds)} runs)")
# Example: Consensus network: 28453 links (found in 3+ of 5 runs)
client.close()
Recipe: pySCENIC Integration
When to use: downstream regulatory analysis after arboreto GRN inference. Arboreto produces the adjacency matrix (Step 1 of SCENIC); pySCENIC performs regulon identification (Step 2) and activity scoring (Step 3).
from arboreto.algo import grnboost2
from arboreto.utils import load_tf_names
import pandas as pd
if __name__ == '__main__':
# Step 1 (arboreto): GRN inference
expression_matrix = pd.read_csv('scrna_expression.tsv', sep='\t')
tf_names = load_tf_names('allTFs_hg38.txt')
adjacencies = grnboost2(
expression_data=expression_matrix,
tf_names=tf_names, seed=777
)
adjacencies.to_csv('adjacencies.tsv', sep='\t', index=False, header=False)
print(f"SCENIC Step 1 complete: {len(adjacencies)} adjacencies")
# Example: SCENIC Step 1 complete: 234567 adjacencies
# Steps 2-3 use pySCENIC CLI (requires pyscenic installation):
# pyscenic ctx adjacencies.tsv hg38_ranking.feather \
# --annotations_fname motifs-v10nr.hgnc-m0.001-o0.0.tbl \
# --output regulons.csv
#
# pyscenic aucell scrna_expression.tsv regulons.csv \
# --output auc_matrix.csv
print("Next: run pyscenic ctx (Step 2) and pyscenic aucell (Step 3)")
Expected Outputs
full_network.tsv-- complete TF-target-importance table; columns: TF, target, importancefiltered_network.tsv-- high-confidence subset after threshold or top-N filteringconsensus_network.tsv-- aggregated network from multi-seed runs; columns: TF, target, mean_importance, n_seedsadjacencies.tsv-- arboreto output formatted for pySCENIC input (no header)grn_network.png-- directed graph visualization of top regulatory interactions
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
RuntimeError: freeze_support() or infinite process spawning |
Missing if __name__ == '__main__': guard |
Wrap all arboreto calls in main guard; not needed in Jupyter |
MemoryError during inference |
Expression matrix too large for available RAM | Filter low-variance genes first, reduce to top 5-10k genes, or use distributed cluster |
| Empty or near-empty network | TF names do not match expression matrix column names | Verify TF names overlap with expression_matrix.columns; check case sensitivity |
| Very slow inference | Using GENIE3 on large dataset, or too few Dask workers | Switch to GRNBoost2; create explicit Dask client with more workers |
ModuleNotFoundError: No module named 'arboreto' |
Package not installed | pip install arboreto |
| All importance scores are 0 or identical | Expression matrix has no variance (e.g., all zeros after filtering) | Check data quality; ensure matrix contains non-zero expression values with variance |
TypeError with NumPy array input |
Missing gene_names parameter |
Provide gene_names= list matching the column count of the array |
Bundled Resources
This entry is fully self-contained with no references/ directory.
Original reference file dispositions:
- references/algorithms.md (139 lines) -- Algorithm comparison, parameter details, custom regressor kwargs. Consolidated into Key Concepts (Algorithm Selection Guide table) and Key Parameters. Custom regressor kwargs (
regressor_type,regressor_kwargs) omitted: advanced tuning rarely needed; users can pass scikit-learn kwargs directly per the arboreto API docs. - references/basic_inference.md (152 lines) -- Input formats, TF loading, basic workflow, output format. Fully consolidated into Workflow Steps 1-2, Key Concepts (Output Format table), and Quick Start.
- references/distributed_computing.md (242 lines) -- Local client, cluster setup, monitoring, performance tips. Consolidated into Workflow Step 3 and Recipe: Distributed Computing. Omitted: Dask dashboard panel descriptions (monitoring UI detail beyond scope), detailed network/storage tuning (cluster-admin concern).
- scripts/basic_grn_inference.py (98 lines) -- CLI wrapper with argparse. Core load-infer-save logic absorbed into Workflow Steps 1-6 and Quick Start. Argparse/CLI boilerplate stripped.
Narrative use-case dispositions (from original Common Use Cases):
- Single-cell RNA-seq analysis -- absorbed into When to Use bullets and Workflow Steps 1/4
- Bulk RNA-seq with TF filtering -- absorbed into Workflow Step 2 and Quick Start
- Comparative analysis (multiple conditions) -- omitted as standalone recipe: trivial loop over conditions, pattern shown in Consensus Network recipe
Related Skills
- scanpy-scrna-seq -- upstream single-cell preprocessing (QC, normalization, HVG selection) before GRN inference
- scvi-tools-single-cell -- deep generative models for batch correction and imputation prior to network inference
- pyscenic (planned) -- downstream regulon identification and cellular activity scoring using arboreto adjacencies
- networkx-graph-analysis -- graph-theoretic analysis of inferred regulatory networks (centrality, communities)
References
- Arboreto GitHub repository -- source code, README, examples
- Arboreto PyPI -- installation and version history
- Moerman et al. (2019) "GRNBoost2 and Arboreto: efficient and scalable inference of gene regulatory networks" Bioinformatics 35(12):2159-2161 -- algorithm paper
- Aibar et al. (2017) "SCENIC: single-cell regulatory network inference and clustering" Nature Methods 14:1083-1086 -- SCENIC pipeline paper
- Dask distributed documentation -- Dask client, cluster setup, dashboard
skills/genomics-bioinformatics/biopython-molecular-biology/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill biopython-molecular-biology -g -y
SKILL.md
Frontmatter
{
"name": "biopython-molecular-biology",
"license": "Biopython License (BSD-like)",
"description": "Molecular biology toolkit: sequence manipulation, FASTA\/GenBank\/PDB I\/O, NCBI Entrez, BLAST automation, pairwise\/MSA alignment, Bio.PDB, phylogenetic trees. Use for batch processing, custom pipelines, format conversion, PubMed\/GenBank queries. For quick gene lookups use gget; for multi-service REST APIs use bioservices."
}
Biopython: Computational Molecular Biology Toolkit
Overview
Biopython is the standard open-source Python library for computational molecular biology, providing modular APIs for sequence handling, biological file parsing, NCBI database access, BLAST searches, protein structure analysis, and phylogenetics. It supports Python 3 and requires NumPy.
When to Use
- Parse and convert biological file formats (FASTA, GenBank, FASTQ, PDB, mmCIF, PHYLIP)
- Fetch sequences or publications from NCBI databases (GenBank, PubMed, Protein) programmatically
- Run and parse BLAST searches (remote NCBI or local BLAST+)
- Perform pairwise or multiple sequence alignments with custom scoring
- Analyze 3D protein structures — distances, angles, DSSP, superimposition
- Build and visualize phylogenetic trees from sequence alignments
- Calculate sequence statistics (GC content, molecular weight, melting temperature)
- Batch-process thousands of sequences with custom filtering logic
- Use
pysaminstead for reading SAM/BAM/CRAM alignment files and working with mapped reads; usescikit-bioinstead for advanced ecological diversity metrics
Prerequisites
- Python packages:
biopython,numpy,matplotlib(for tree visualization) - Data requirements: Sequence files (FASTA, GenBank, FASTQ) or accession IDs for NCBI access
- Environment: Python 3.8+; NCBI Entrez requires email registration
pip install biopython numpy matplotlib
Quick Start
from Bio import SeqIO
from Bio.Seq import Seq
from Bio.SeqUtils import gc_fraction
# Parse a FASTA file and compute basic statistics
records = list(SeqIO.parse("sequences.fasta", "fasta"))
print(f"Sequences loaded: {len(records)}")
seq = records[0].seq
print(f"ID: {records[0].id}")
print(f"Length: {len(seq)} bp")
print(f"GC content: {gc_fraction(seq)*100:.1f}%")
print(f"Reverse complement: {seq.reverse_complement()[:30]}...")
print(f"Protein translation: {seq.translate()[:10]}...")
Core API
Module 1: Sequence Objects (Bio.Seq)
Create and manipulate DNA, RNA, and protein sequences.
from Bio.Seq import Seq
# Create sequence and perform standard operations
dna = Seq("ATGGCCATTGTAATGGGCCGCTGAAAGGGTGCCCGATAG")
print(f"Length: {len(dna)} bp")
print(f"Complement: {dna.complement()}")
print(f"Reverse complement: {dna.reverse_complement()}")
print(f"Transcription: {dna.transcribe()}")
print(f"Translation: {dna.translate()}")
print(f"Translation (to stop): {dna.translate(to_stop=True)}")
# Length: 39 bp
# Translation: MAIVMGR*KGAR*
# Translation (to stop): MAIVMGR
from Bio.Seq import Seq
# Alternative genetic codes (e.g., mitochondrial)
mito_dna = Seq("ATGGCCATTGTAATGGGCCGCTGA")
std_protein = mito_dna.translate(table=1) # Standard
mito_protein = mito_dna.translate(table=2) # Vertebrate mitochondrial
print(f"Standard: {std_protein}")
print(f"Mitochondrial: {mito_protein}")
# Find all start codons
coding_dna = Seq("ATGAAACCCATGGGGTTTAAATAG")
positions = [i for i in range(len(coding_dna) - 2) if coding_dna[i:i+3] == "ATG"]
print(f"ATG positions: {positions}")
# ATG positions: [0, 9]
Module 2: Sequence I/O (Bio.SeqIO)
Read, write, and convert biological file formats.
from Bio import SeqIO
# Parse FASTA file — returns SeqRecord iterator
records = list(SeqIO.parse("sequences.fasta", "fasta"))
print(f"Loaded {len(records)} sequences")
for rec in records[:3]:
print(f" {rec.id}: {len(rec.seq)} bp — {rec.description}")
# Parse GenBank — rich annotation access
for rec in SeqIO.parse("genome.gb", "genbank"):
print(f"{rec.id}: {len(rec.features)} features, {len(rec.seq)} bp")
for feat in rec.features[:5]:
print(f" {feat.type}: {feat.location}")
# Convert between formats
count = SeqIO.convert("input.gb", "genbank", "output.fasta", "fasta")
print(f"Converted {count} records: GenBank → FASTA")
from Bio import SeqIO
from Bio.SeqRecord import SeqRecord
from Bio.Seq import Seq
# Write sequences to file
records = [
SeqRecord(Seq("ATCGATCG"), id="seq1", description="test sequence 1"),
SeqRecord(Seq("GCTAGCTA"), id="seq2", description="test sequence 2"),
]
count = SeqIO.write(records, "output.fasta", "fasta")
print(f"Wrote {count} records to output.fasta")
# Filter sequences by length (streaming — memory efficient)
long_seqs = (rec for rec in SeqIO.parse("large_file.fasta", "fasta") if len(rec.seq) >= 200)
count = SeqIO.write(long_seqs, "filtered.fasta", "fasta")
print(f"Kept {count} sequences >= 200 bp")
# Index large FASTA for random access
idx = SeqIO.index("large_file.fasta", "fasta")
print(f"Indexed {len(idx)} sequences")
rec = idx["target_sequence_id"]
print(f"Retrieved: {rec.id}, {len(rec.seq)} bp")
Module 3: NCBI Database Access (Bio.Entrez)
Programmatic search and download from NCBI databases.
from Bio import Entrez, SeqIO
Entrez.email = "your.email@example.com"
# Entrez.api_key = "your_key" # Optional: 10 req/s instead of 3 req/s
# Search PubMed
handle = Entrez.esearch(db="pubmed", term="CRISPR Cas9 2024", retmax=5)
results = Entrez.read(handle)
handle.close()
print(f"Found {results['Count']} articles, retrieved {len(results['IdList'])} IDs")
print(f"IDs: {results['IdList']}")
# Fetch GenBank record by accession
handle = Entrez.efetch(db="nucleotide", id="EU490707", rettype="gb", retmode="text")
record = SeqIO.read(handle, "genbank")
handle.close()
print(f"{record.id}: {record.description}")
print(f"Length: {len(record.seq)} bp, Features: {len(record.features)}")
from Bio import Entrez
import time
Entrez.email = "your.email@example.com"
# Batch download with rate limiting
handle = Entrez.esearch(db="protein", term="insulin[Protein Name] AND human[Organism]", retmax=20)
results = Entrez.read(handle)
handle.close()
# Fetch summaries in batch
ids = results["IdList"][:10]
handle = Entrez.esummary(db="protein", id=",".join(ids))
summaries = Entrez.read(handle)
handle.close()
for doc in summaries:
print(f" {doc['AccessionVersion']}: {doc['Title'][:60]}...")
print(f"\nFetched {len(summaries)} protein summaries")
Module 4: BLAST Operations (Bio.Blast)
Run and parse BLAST searches against NCBI or local databases.
from Bio.Blast import NCBIWWW, NCBIXML
# Remote BLAST search (nucleotide)
query_seq = "ATCGATCGATCGATCGATCGATCGATCGATCG"
result_handle = NCBIWWW.qblast("blastn", "nt", query_seq, hitlist_size=5)
blast_record = NCBIXML.read(result_handle)
result_handle.close()
print(f"Query: {blast_record.query[:50]}")
print(f"Database: {blast_record.database}")
print(f"Hits: {len(blast_record.alignments)}")
for aln in blast_record.alignments[:3]:
hsp = aln.hsps[0]
print(f"\n {aln.title[:60]}...")
print(f" E-value: {hsp.expect:.2e}, Identity: {hsp.identities}/{hsp.align_length}")
print(f" Score: {hsp.score}, Bits: {hsp.bits:.1f}")
from Bio.Blast.Applications import NcbiblastpCommandline
from Bio.Blast import NCBIXML
# Local BLAST (requires BLAST+ installed)
blastp_cline = NcbiblastpCommandline(
query="query.fasta",
db="swissprot",
evalue=1e-5,
outfmt=5, # XML output
out="blast_results.xml",
num_threads=4,
)
print(f"Command: {blastp_cline}")
# stdout, stderr = blastp_cline() # Execute
# Parse local BLAST XML results
with open("blast_results.xml") as f:
for record in NCBIXML.parse(f):
print(f"Query: {record.query}")
for aln in record.alignments[:3]:
print(f" Hit: {aln.hit_def[:50]}, E={aln.hsps[0].expect:.2e}")
Module 5: Pairwise Alignment (Bio.Align)
Global and local pairwise sequence alignment with customizable scoring.
from Bio import Align
# Global alignment
aligner = Align.PairwiseAligner()
aligner.mode = "global"
aligner.match_score = 2
aligner.mismatch_score = -1
aligner.open_gap_score = -5
aligner.extend_gap_score = -0.5
alignments = aligner.align("ACCGGTAACG", "ACGGTAAC")
print(f"Score: {alignments.score}")
print(f"Number of alignments: {len(alignments)}")
print(f"Best alignment:\n{alignments[0]}")
from Bio import Align
from Bio.Align import substitution_matrices
# Protein alignment with BLOSUM62
aligner = Align.PairwiseAligner()
aligner.mode = "local"
aligner.substitution_matrix = substitution_matrices.load("BLOSUM62")
aligner.open_gap_score = -10
aligner.extend_gap_score = -0.5
seq1 = "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSH"
seq2 = "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLST"
alignments = aligner.align(seq1, seq2)
print(f"Score: {alignments.score}")
print(f"Best alignment:\n{alignments[0]}")
Module 6: Protein Structure Analysis (Bio.PDB)
Parse PDB/mmCIF files and analyze 3D protein structures.
from Bio.PDB import PDBParser, PPBuilder
# Parse PDB structure
parser = PDBParser(QUIET=True)
structure = parser.get_structure("1CRN", "1crn.pdb")
# Navigate SMCRA hierarchy: Structure > Model > Chain > Residue > Atom
model = structure[0]
for chain in model:
residues = list(chain.get_residues())
print(f"Chain {chain.id}: {len(residues)} residues")
# Extract sequence from structure
ppb = PPBuilder()
for pp in ppb.build_peptides(structure):
print(f"Peptide: {pp.get_sequence()[:50]}... ({len(pp.get_sequence())} aa)")
# Calculate CA-CA distance
chain_a = model["A"]
ca1 = chain_a[10]["CA"]
ca2 = chain_a[20]["CA"]
distance = ca1 - ca2
print(f"CA distance (res 10-20): {distance:.2f} Angstrom")
from Bio.PDB import PDBParser, Superimposer
import numpy as np
# Structure superimposition (RMSD calculation)
parser = PDBParser(QUIET=True)
struct1 = parser.get_structure("s1", "structure1.pdb")
struct2 = parser.get_structure("s2", "structure2.pdb")
# Get CA atoms for alignment
atoms1 = [res["CA"] for res in struct1[0]["A"].get_residues() if "CA" in res]
atoms2 = [res["CA"] for res in struct2[0]["A"].get_residues() if "CA" in res]
# Superimpose (requires same number of atoms)
n = min(len(atoms1), len(atoms2))
sup = Superimposer()
sup.set_atoms(atoms1[:n], atoms2[:n])
sup.apply(struct2.get_atoms())
print(f"RMSD: {sup.rms:.3f} Angstrom over {n} CA atoms")
Module 7: Phylogenetics (Bio.Phylo)
Build, manipulate, and visualize phylogenetic trees.
from Bio import Phylo, AlignIO
from Bio.Phylo.TreeConstruction import DistanceCalculator, DistanceTreeConstructor
import io
# Build tree from multiple sequence alignment
alignment = AlignIO.read("aligned_sequences.fasta", "fasta")
print(f"Alignment: {len(alignment)} sequences, {alignment.get_alignment_length()} positions")
# Calculate distance matrix and build NJ tree
calculator = DistanceCalculator("identity")
dm = calculator.get_distance(alignment)
print(f"Distance matrix:\n{dm}")
constructor = DistanceTreeConstructor()
nj_tree = constructor.nj(dm)
upgma_tree = constructor.upgma(dm)
# Visualize
Phylo.draw_ascii(nj_tree)
# Save tree
Phylo.write(nj_tree, "tree.nwk", "newick")
print("Saved tree.nwk")
Module 8: Sequence Utilities (Bio.SeqUtils)
Compute sequence statistics and physicochemical properties.
from Bio.Seq import Seq
from Bio.SeqUtils import gc_fraction, molecular_weight, MeltingTemp
dna = Seq("ATCGATCGATCGATCGATCG")
print(f"GC content: {gc_fraction(dna):.2%}")
print(f"Molecular weight: {molecular_weight(dna, seq_type='DNA'):.2f} Da")
print(f"Melting temp (basic): {MeltingTemp.Tm_Wallace(dna):.1f} C")
print(f"Melting temp (NN): {MeltingTemp.Tm_NN(dna):.1f} C")
# Protein analysis
from Bio.SeqUtils.ProtParam import ProteinAnalysis
protein = ProteinAnalysis("MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTK")
print(f"\nProtein MW: {protein.molecular_weight():.2f} Da")
print(f"Isoelectric point: {protein.isoelectric_point():.2f}")
print(f"Aromaticity: {protein.aromaticity():.4f}")
print(f"Instability index: {protein.instability_index():.2f}")
print(f"GRAVY: {protein.gravy():.4f}")
aa_pct = protein.get_amino_acids_percent()
print(f"Top 3 amino acids: {sorted(aa_pct.items(), key=lambda x: -x[1])[:3]}")
Common Workflows
Workflow 1: Gene Sequence Retrieval and Analysis Pipeline
Goal: Fetch a gene from NCBI, analyze its properties, translate to protein, and compute statistics.
from Bio import Entrez, SeqIO
from Bio.Seq import Seq
from Bio.SeqUtils import gc_fraction
from Bio.SeqUtils.ProtParam import ProteinAnalysis
Entrez.email = "your.email@example.com"
# Step 1: Fetch gene from GenBank
handle = Entrez.efetch(db="nucleotide", id="NM_007294.4", rettype="gb", retmode="text")
record = SeqIO.read(handle, "genbank")
handle.close()
print(f"Gene: {record.description}")
print(f"Length: {len(record.seq)} bp")
# Step 2: Extract CDS
cds_features = [f for f in record.features if f.type == "CDS"]
if cds_features:
cds = cds_features[0]
cds_seq = cds.location.extract(record).seq
print(f"CDS: {len(cds_seq)} bp, GC: {gc_fraction(cds_seq):.2%}")
# Step 3: Translate
protein_seq = cds_seq.translate(to_stop=True)
print(f"Protein: {len(protein_seq)} aa")
print(f"First 30 aa: {protein_seq[:30]}...")
# Step 4: Protein properties
analysis = ProteinAnalysis(str(protein_seq))
print(f"MW: {analysis.molecular_weight():.0f} Da")
print(f"pI: {analysis.isoelectric_point():.2f}")
print(f"Instability: {analysis.instability_index():.1f}")
print(f"GRAVY: {analysis.gravy():.3f}")
Workflow 2: Comparative Sequence Analysis with BLAST and Phylogeny
Goal: BLAST a protein sequence, fetch homologs, align, and build a phylogenetic tree.
from Bio.Blast import NCBIWWW, NCBIXML
from Bio import Entrez, SeqIO, AlignIO, Phylo
from Bio.Phylo.TreeConstruction import DistanceCalculator, DistanceTreeConstructor
from Bio.Align.Applications import MuscleCommandline
import time
Entrez.email = "your.email@example.com"
# Step 1: BLAST search
query = "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSH"
result_handle = NCBIWWW.qblast("blastp", "swissprot", query, hitlist_size=10)
blast_record = NCBIXML.read(result_handle)
print(f"BLAST hits: {len(blast_record.alignments)}")
# Step 2: Collect homolog accessions
accessions = []
for aln in blast_record.alignments[:8]:
acc = aln.accession
accessions.append(acc)
print(f" {acc}: E={aln.hsps[0].expect:.2e}, {aln.hit_def[:50]}...")
# Step 3: Fetch sequences and save for alignment
handle = Entrez.efetch(db="protein", id=",".join(accessions), rettype="fasta", retmode="text")
records = list(SeqIO.parse(handle, "fasta"))
handle.close()
SeqIO.write(records, "homologs.fasta", "fasta")
print(f"Saved {len(records)} homolog sequences")
# Step 4: Align (requires MUSCLE installed)
# muscle_cline = MuscleCommandline(input="homologs.fasta", out="aligned.fasta")
# muscle_cline()
# Step 5: Build phylogenetic tree from alignment
alignment = AlignIO.read("aligned.fasta", "fasta")
calculator = DistanceCalculator("blosum62")
dm = calculator.get_distance(alignment)
tree = DistanceTreeConstructor().nj(dm)
Phylo.draw_ascii(tree)
Phylo.write(tree, "homologs.nwk", "newick")
print("Saved phylogenetic tree to homologs.nwk")
Workflow 3: Batch Sequence Processing and Quality Filtering
Goal: Process a large FASTQ/FASTA dataset — filter by quality/length, compute statistics, and export.
from Bio import SeqIO
from Bio.SeqUtils import gc_fraction
import pandas as pd
# Step 1: Stream through large file and collect stats
stats = []
passed = []
for rec in SeqIO.parse("reads.fastq", "fastq"):
seq_len = len(rec.seq)
gc = gc_fraction(rec.seq)
avg_qual = sum(rec.letter_annotations["phred_quality"]) / seq_len
stats.append({"id": rec.id, "length": seq_len, "gc": gc, "avg_qual": avg_qual})
# Filter: length >= 100 and avg quality >= 20
if seq_len >= 100 and avg_qual >= 20:
passed.append(rec)
# Step 2: Summary statistics
df = pd.DataFrame(stats)
print(f"Total reads: {len(df)}")
print(f"Passed QC: {len(passed)} ({len(passed)/len(df)*100:.1f}%)")
print(f"\nLength: mean={df['length'].mean():.0f}, median={df['length'].median():.0f}")
print(f"GC: mean={df['gc'].mean():.2%}, std={df['gc'].std():.2%}")
print(f"Quality: mean={df['avg_qual'].mean():.1f}, min={df['avg_qual'].min():.1f}")
# Step 3: Export filtered reads
count = SeqIO.write(passed, "filtered_reads.fastq", "fastq")
print(f"\nExported {count} filtered reads to filtered_reads.fastq")
# Step 4: Save statistics
df.to_csv("read_statistics.csv", index=False)
print(f"Saved statistics to read_statistics.csv")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
Seq.translate(table=) |
Bio.Seq | 1 (Standard) |
1-33 |
NCBI genetic code table for translation |
Seq.translate(to_stop=) |
Bio.Seq | False |
True, False |
Stop at first stop codon vs translate entire sequence |
SeqIO.parse(format=) |
Bio.SeqIO | — | "fasta", "genbank", "fastq", "phylip" |
File format for parsing |
Entrez.email |
Bio.Entrez | (required) | Valid email | NCBI requires email for tracking; set before any Entrez call |
Entrez.api_key |
Bio.Entrez | None |
NCBI API key string | Increases rate limit from 3 to 10 requests/second |
PairwiseAligner.mode |
Bio.Align | "global" |
"global", "local" |
Needleman-Wunsch vs Smith-Waterman algorithm |
PairwiseAligner.open_gap_score |
Bio.Align | -1 |
-20 to 0 |
Penalty for opening a gap; more negative = fewer gaps |
PairwiseAligner.substitution_matrix |
Bio.Align | None | "BLOSUM62", "BLOSUM45", "PAM250" |
Scoring matrix for protein alignment |
PDBParser(QUIET=) |
Bio.PDB | False |
True, False |
Suppress parser warnings for non-standard PDB files |
DistanceCalculator(model=) |
Bio.Phylo | "identity" |
"identity", "blosum62" |
Distance model for tree construction |
Common Recipes
Recipe: Restriction Enzyme Analysis
When to use: Find restriction sites in a DNA sequence for cloning design.
from Bio.Seq import Seq
from Bio.Restriction import EcoRI, BamHI, HindIII, RestrictionBatch, Analysis
seq = Seq("GAATTCAAAGGATCCTTTTAAGCTTGGGAATTC")
# Single enzyme
print(f"EcoRI cuts at: {EcoRI.search(seq)}")
print(f"BamHI cuts at: {BamHI.search(seq)}")
# Batch analysis
batch = RestrictionBatch([EcoRI, BamHI, HindIII])
analysis = Analysis(batch, seq)
result = analysis.full()
for enzyme, sites in result.items():
if sites:
print(f"{enzyme}: cuts at positions {sites}")
Recipe: Motif Discovery and Position Weight Matrix
When to use: Analyze transcription factor binding sites or consensus patterns.
from Bio import motifs
from Bio.Seq import Seq
# Create motif from observed binding sites
instances = [
Seq("TACGAT"),
Seq("TAGCAT"),
Seq("TACGGT"),
Seq("TAGCAT"),
Seq("TACGAT"),
]
m = motifs.create(instances)
print(f"Consensus: {m.consensus}")
print(f"Degenerate: {m.degenerate_consensus}")
print(f"\nPosition Weight Matrix:")
print(m.counts)
# Score a new sequence against the motif
pwm = m.counts.normalize(pseudocounts=0.5)
pssm = pwm.log_odds()
test_seq = Seq("AATACGATCCC")
for pos, score in pssm.search(test_seq, threshold=0.0):
print(f" Position {pos}: score={score:.2f}")
Recipe: GenomeDiagram — Visualize Genomic Features
When to use: Create a circular or linear genome map from a GenBank file.
from Bio import SeqIO
from Bio.Graphics import GenomeDiagram
from reportlab.lib import colors
from reportlab.lib.units import cm
# Load annotated genome
record = SeqIO.read("plasmid.gb", "genbank")
# Create diagram
diagram = GenomeDiagram.Diagram(record.name)
track = diagram.new_track(1, name="Annotated Features", greytrack=True)
feature_set = track.new_set()
# Color features by type
color_map = {"CDS": colors.blue, "gene": colors.green, "promoter": colors.red}
for feature in record.features:
if feature.type in color_map:
feature_set.add_feature(feature, color=color_map[feature.type], label=True, label_size=8)
# Draw circular map
diagram.draw(format="circular", circular=True, pagesize=(20*cm, 20*cm), start=0, end=len(record))
diagram.write("genome_map.pdf", "PDF")
print(f"Saved genome_map.pdf ({len(record.features)} features)")
Recipe: Batch PubMed Literature Search
When to use: Programmatically search and download publication metadata.
from Bio import Entrez
import time
Entrez.email = "your.email@example.com"
# Search PubMed with complex query
query = "(CRISPR[Title]) AND (2024[Date - Publication]) AND (review[Publication Type])"
handle = Entrez.esearch(db="pubmed", term=query, retmax=100)
results = Entrez.read(handle)
handle.close()
print(f"Found {results['Count']} articles")
# Fetch abstracts in batches
ids = results["IdList"]
batch_size = 20
articles = []
for i in range(0, len(ids), batch_size):
batch = ids[i:i+batch_size]
handle = Entrez.efetch(db="pubmed", id=",".join(batch), rettype="xml")
records = Entrez.read(handle)
handle.close()
for article in records["PubmedArticle"]:
info = article["MedlineCitation"]["Article"]
title = info.get("ArticleTitle", "N/A")
abstract = info.get("Abstract", {}).get("AbstractText", ["N/A"])[0]
articles.append({"title": title, "abstract": str(abstract)[:200]})
time.sleep(0.4) # Rate limiting
for a in articles[:5]:
print(f" {a['title'][:70]}...")
print(f"\nRetrieved {len(articles)} articles")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTPError 400 from Entrez |
Invalid accession/ID or malformed query | Validate accessions; check query syntax with NCBI web interface first |
HTTPError 429 (Too Many Requests) |
Exceeding NCBI rate limit (3 req/s) | Set Entrez.api_key for 10 req/s; add time.sleep(0.4) between calls |
ValueError: No records found in SeqIO.read() |
Empty file or wrong format string | Use SeqIO.parse() to check if file has records; verify format matches actual content |
Bio.PDB.PDBExceptions.PDBConstructionWarning |
Non-standard atoms or occupancy issues | Use PDBParser(QUIET=True) or fix PDB with pdb-tools; check for alternate conformations |
| BLAST search times out | Large query or busy NCBI servers | Use local BLAST+ for large-scale searches; set NCBIWWW.qblast(hitlist_size=N) to limit results |
Alignment has sequences of different lengths |
Unaligned sequences passed to AlignIO | Align sequences first with MUSCLE/Clustal before loading as alignment |
SeqIO.index() raises ValueError |
Duplicate IDs in FASTA file | Deduplicate IDs with SeqIO.to_dict() or pre-process with awk |
ImportError: No module named Bio |
Biopython not installed in active environment | pip install biopython; verify with python -c "import Bio; print(Bio.__version__)" |
translate() gives unexpected * |
Stop codons in middle of sequence | Check reading frame; use Seq.translate(table=N) with correct genetic code |
| GenomeDiagram blank output | No features matched filter criteria | Check feature.type values in your GenBank file; print types to debug |
References
- Biopython Tutorial and Cookbook — Official comprehensive tutorial
- Biopython API Documentation — Complete API reference
- Cock, P.J.A. et al. (2009) Bioinformatics 25:1422-1423 — Original Biopython paper
- NCBI Entrez Programming Utilities — E-utilities documentation for programmatic NCBI access
- Biopython GitHub Repository — Source code, issues, and release notes
skills/genomics-bioinformatics/biopython-sequence-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill biopython-sequence-analysis -g -y
SKILL.md
Frontmatter
{
"name": "biopython-sequence-analysis",
"license": "Biopython License (BSD-like)",
"description": "Biopython sequence analysis: parse FASTA\/FASTQ\/GenBank\/GFF (SeqIO), NCBI Entrez (esearch\/efetch\/elink), remote\/local BLAST, pairwise\/MSA alignment (PairwiseAligner, MUSCLE\/ClustalW), phylogenetic trees (Phylo). Use for gene family studies, phylogenomics, comparative genomics, NCBI pipelines. For PCR\/restriction\/cloning use biopython-molecular-biology; for SAM\/BAM use pysam."
}
Biopython: Sequence Analysis Toolkit
Overview
Biopython provides a comprehensive suite of modules for sequence-centric bioinformatics: reading and writing every major biological file format (FASTA, FASTQ, GenBank, GFF), querying NCBI databases programmatically, running BLAST searches and parsing results, aligning sequences pairwise or in multiple-sequence alignments, and building and visualizing phylogenetic trees. This skill focuses on analysis workflows — from NCBI data retrieval through alignment to phylogenetic inference.
For PCR primer design, restriction enzyme digestion, cloning simulation, protein structure analysis (Bio.PDB), and molecular weight/Tm calculations, see biopython-molecular-biology.
When to Use
- Download a gene family from NCBI Nucleotide/Protein, align sequences, and construct a phylogenetic tree
- Parse GenBank or GFF3 annotation files and extract CDS sequences for a set of features
- Run a BLAST search against NCBI
ntornr, filter significant hits, and fetch their full sequences - Compute pairwise sequence identities or score alignments with BLOSUM62/PAM250 matrices
- Index a large multi-FASTA or FASTQ file with
SeqIO.index()for random-access retrieval without loading all sequences into RAM - Convert between sequence formats (FASTA ↔ GenBank ↔ FASTQ ↔ PHYLIP) in a single call
- Traverse, root, prune, and annotate a Newick or Nexus phylogenetic tree programmatically
- Use pysam instead when working with SAM/BAM/CRAM alignment files and mapped reads
- Use scikit-bio instead when you need ecological diversity metrics (UniFrac, beta diversity) or ordination methods such as PCoA
- Use gget instead for quick gene lookups and one-liner NCBI queries without writing an Entrez pipeline
Prerequisites
- Python packages:
biopython,numpy,matplotlib - Optional tools: MUSCLE or ClustalW installed locally (for
Bio.Align.Applicationswrappers) - NCBI access: Set
Entrez.emailbefore any E-utilities call; obtain a free API key at https://www.ncbi.nlm.nih.gov/account/ for 10 req/s (default is 3 req/s) - Local BLAST: BLAST+ installed separately (
conda install -c bioconda blast) for offline searches
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v pythonfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run pythonrather than barepython.
pip install biopython numpy matplotlib
conda install -c bioconda blast # optional, for local BLAST
Quick Start
from Bio import SeqIO, Entrez
from Bio.SeqUtils import gc_fraction
# Fetch a GenBank record and display basic stats
Entrez.email = "your.email@example.com"
handle = Entrez.efetch(db="nucleotide", id="NM_007294", rettype="gb", retmode="text")
record = SeqIO.read(handle, "genbank")
handle.close()
print(f"ID: {record.id}")
print(f"Length: {len(record.seq)} bp")
print(f"GC content: {gc_fraction(record.seq)*100:.1f}%")
print(f"Features: {len(record.features)}")
print(f"First feature: {record.features[0].type} at {record.features[0].location}")
# ID: NM_007294.4
# Length: 7207 bp
# GC content: 47.3%
# Features: 9
Core API
Module 1: SeqIO — File Parsing and Format Conversion
Bio.SeqIO reads and writes every major sequence format (FASTA, FASTQ, GenBank, EMBL, PHYLIP, Nexus). SeqIO.parse() returns an iterator of SeqRecord objects; SeqIO.index() builds an on-disk or in-memory dictionary for large files.
from Bio import SeqIO
# Parse FASTA and FASTQ
fasta_records = list(SeqIO.parse("sequences.fasta", "fasta"))
print(f"FASTA: {len(fasta_records)} sequences")
# FASTQ: access quality scores
for rec in SeqIO.parse("reads.fastq", "fastq"):
quals = rec.letter_annotations["phred_quality"]
avg_q = sum(quals) / len(quals)
print(f" {rec.id}: {len(rec.seq)} bp, mean Q={avg_q:.1f}")
break # show one example
# Parse GenBank with feature access
for rec in SeqIO.parse("chromosome.gb", "genbank"):
cdss = [f for f in rec.features if f.type == "CDS"]
print(f"{rec.id}: {len(cdss)} CDS features")
for cds in cdss[:3]:
gene = cds.qualifiers.get("gene", ["unknown"])[0]
print(f" {gene}: {cds.location}")
from Bio import SeqIO
# SeqIO.index() for random access without loading all records
# Useful for large reference FASTA files (genomes, nr database subsets)
idx = SeqIO.index("large_genome.fasta", "fasta")
print(f"Index contains {len(idx)} sequences")
# Retrieve specific sequences by ID in O(1)
target = idx["chr1"]
print(f"chr1: {len(target.seq):,} bp")
region = target.seq[1_000_000:1_001_000]
print(f"Region [1M-1M+1kb]: {region[:60]}...")
# Format conversion: GenBank → FASTA in one call
n = SeqIO.convert("annotation.gb", "genbank", "sequences.fasta", "fasta")
print(f"Converted {n} records to FASTA")
Module 2: Seq and SeqRecord — Sequence Objects and Feature Annotations
Bio.Seq represents a biological sequence with in-place operations. Bio.SeqRecord wraps a Seq with an ID, description, and a dictionary of feature annotations. Features use FeatureLocation with strand (+1, -1).
from Bio.Seq import Seq
from Bio.SeqRecord import SeqRecord
from Bio.SeqFeature import SeqFeature, FeatureLocation
from Bio.SeqUtils import gc_fraction, MeltingTemp
dna = Seq("ATGAAACCCGGGTTTTAA")
print(f"GC content: {gc_fraction(dna)*100:.1f}%")
print(f"Reverse complement: {dna.reverse_complement()}")
print(f"Transcript: {dna.transcribe()}")
print(f"Translation: {dna.translate()}")
print(f"Translation (to stop): {dna.translate(to_stop=True)}")
# Tm calculation for a short primer
primer = Seq("ATGAAACCCGGG")
tm = MeltingTemp.Tm_Wallace(primer)
print(f"Tm (Wallace): {tm:.1f}°C")
from Bio.Seq import Seq
from Bio.SeqRecord import SeqRecord
from Bio.SeqFeature import SeqFeature, FeatureLocation
# Build a SeqRecord with annotated features
gene_seq = Seq("ATGAAACCCGGGTTTTAAATCGATCG" * 10)
record = SeqRecord(gene_seq, id="MY_GENE_001", description="synthetic example gene")
# Annotate a CDS feature
cds = SeqFeature(
FeatureLocation(0, 18, strand=+1),
type="CDS",
qualifiers={"gene": ["myGene"], "product": ["hypothetical protein"]}
)
record.features.append(cds)
# Extract and translate the feature
cds_seq = cds.location.extract(record.seq)
protein = cds_seq.translate(to_stop=True)
print(f"CDS: {cds_seq}")
print(f"Protein: {protein}")
# Slice record preserving feature annotations
sub = record[0:60]
print(f"Subrecord: {len(sub.seq)} bp, {len(sub.features)} features")
Module 3: Entrez — Programmatic NCBI Database Access
Bio.Entrez wraps the NCBI E-utilities API: esearch finds records matching a query, efetch retrieves full records, elink finds related records across databases, and esummary returns document summaries. Always set Entrez.email and respect the 3 req/s rate limit (10 req/s with API key).
from Bio import Entrez, SeqIO
import time
Entrez.email = "your.email@example.com"
# Entrez.api_key = "YOUR_API_KEY" # for 10 req/s
# esearch: find IDs matching a query
handle = Entrez.esearch(db="nucleotide", term="BRCA1[Gene] AND Homo sapiens[Organism] AND mRNA[Filter]", retmax=10)
search_results = Entrez.read(handle)
handle.close()
print(f"Total hits: {search_results['Count']}")
ids = search_results["IdList"]
print(f"Retrieved IDs: {ids}")
# efetch: download full GenBank records
for acc_id in ids[:3]:
handle = Entrez.efetch(db="nucleotide", id=acc_id, rettype="gb", retmode="text")
record = SeqIO.read(handle, "genbank")
handle.close()
print(f" {record.id}: {len(record.seq)} bp — {record.description[:60]}")
time.sleep(0.4) # stay within rate limit
from Bio import Entrez
import time
Entrez.email = "your.email@example.com"
# elink: cross-database links (e.g., PubMed article → related nucleotide sequences)
handle = Entrez.elink(dbfrom="pubmed", db="nucleotide", id="29087512")
link_results = Entrez.read(handle)
handle.close()
linked_ids = []
for linkset in link_results:
for db_links in linkset.get("LinkSetDb", []):
if db_links["DbTo"] == "nucleotide":
linked_ids = [lnk["Id"] for lnk in db_links["Link"]]
break
print(f"Nucleotide sequences linked to PubMed 29087512: {len(linked_ids)}")
print(f"First IDs: {linked_ids[:5]}")
# esummary: lightweight metadata without downloading full records
if linked_ids:
handle = Entrez.esummary(db="nucleotide", id=",".join(linked_ids[:5]))
summaries = Entrez.read(handle)
handle.close()
for doc in summaries:
print(f" {doc['AccessionVersion']}: {doc['Title'][:70]}")
Module 4: BLAST — Remote and Local Sequence Similarity Search
Bio.Blast.NCBIWWW.qblast() submits queries to NCBI BLAST servers and returns XML handles. Bio.Blast.NCBIXML.parse() (or .read() for single queries) yields Blast objects with alignments and hsps. For large-scale searches, use local BLAST+ via subprocess.
from Bio.Blast import NCBIWWW, NCBIXML
from Bio.Seq import Seq
# Remote BLASTP against Swiss-Prot (small, reviewed database)
query = Seq("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSY")
result_handle = NCBIWWW.qblast("blastp", "swissprot", str(query), hitlist_size=10)
# Parse results
blast_record = NCBIXML.read(result_handle)
print(f"Query: {blast_record.query}")
print(f"Database: {blast_record.database}")
print(f"Hits: {len(blast_record.alignments)}")
E_VALUE_THRESH = 1e-5
for alignment in blast_record.alignments[:5]:
for hsp in alignment.hsps:
if hsp.expect < E_VALUE_THRESH:
identity_pct = hsp.identities / hsp.align_length * 100
print(f"\n Hit: {alignment.title[:70]}")
print(f" E-value: {hsp.expect:.2e}, Identity: {identity_pct:.1f}%, Score: {hsp.score}")
print(f" Query: {hsp.query[:60]}")
print(f" Match: {hsp.match[:60]}")
print(f" Sbjct: {hsp.sbjct[:60]}")
import subprocess
from Bio.Blast import NCBIXML
from Bio import SeqIO
# Local BLAST+ via subprocess (faster for large batches)
# Requires: makeblastdb and blastp installed (conda install -c bioconda blast)
# Step 1: Build a local database from a FASTA file
subprocess.run(
["makeblastdb", "-in", "ref_proteins.fasta", "-dbtype", "prot", "-out", "ref_db"],
check=True
)
# Step 2: Run blastp against local database
result = subprocess.run(
["blastp", "-query", "query.fasta", "-db", "ref_db",
"-outfmt", "5", # XML output for NCBIXML parsing
"-evalue", "1e-5",
"-num_threads", "4",
"-out", "blast_results.xml"],
check=True
)
# Step 3: Parse XML results
with open("blast_results.xml") as fh:
for blast_rec in NCBIXML.parse(fh):
print(f"Query: {blast_rec.query_id}")
for aln in blast_rec.alignments[:3]:
hsp = aln.hsps[0]
print(f" Hit: {aln.hit_id}, E={hsp.expect:.2e}, Id={hsp.identities}/{hsp.align_length}")
Module 5: Phylo — Tree Parsing, Manipulation, and Visualization
Bio.Phylo reads Newick, Nexus, PhyloXML, and NeXML formats. Trees are represented as Clade objects with nested children. Key methods: find_clades() (generator over nodes), common_ancestor(), distance(), root_with_outgroup(), and prune(). Visualization uses matplotlib.
from Bio import Phylo
import io
# Parse a Newick tree from a string
newick = "((Homo_sapiens:0.01, Pan_troglodytes:0.012):0.08, (Mus_musculus:0.15, Rattus_norvegicus:0.14):0.12, Drosophila_melanogaster:0.85);"
tree = Phylo.read(io.StringIO(newick), "newick")
print(f"Number of terminals: {tree.count_terminals()}")
print(f"Total branch length: {tree.total_branch_length():.3f}")
print(f"Terminals: {[t.name for t in tree.get_terminals()]}")
# Root with outgroup and compute distances
tree.root_with_outgroup("Drosophila_melanogaster")
human = tree.find_any("Homo_sapiens")
chimp = tree.find_any("Pan_troglodytes")
mouse = tree.find_any("Mus_musculus")
print(f"\nDistance Human–Chimp: {tree.distance(human, chimp):.4f}")
print(f"Distance Human–Mouse: {tree.distance(human, mouse):.4f}")
# Traverse all internal nodes
for clade in tree.find_clades(order="level"):
if not clade.is_terminal():
children = [c.name or "internal" for c in clade.clades]
print(f" Internal node → {children}")
from Bio import Phylo
import matplotlib.pyplot as plt
import io
newick = "((Homo_sapiens:0.01, Pan_troglodytes:0.012):0.08, (Mus_musculus:0.15, Rattus_norvegicus:0.14):0.12, Drosophila_melanogaster:0.85);"
tree = Phylo.read(io.StringIO(newick), "newick")
tree.root_with_outgroup("Drosophila_melanogaster")
tree.ladderize() # sort clades by size for a clean layout
# Annotate bootstrap support (if present in node labels)
for clade in tree.find_clades():
if clade.confidence is not None:
clade.name = f"{clade.confidence:.0f}"
fig, ax = plt.subplots(figsize=(8, 5))
Phylo.draw(tree, axes=ax, do_show=False, show_confidence=True)
ax.set_title("Primate + Outgroup Phylogeny")
plt.tight_layout()
plt.savefig("phylogeny.png", dpi=150, bbox_inches="tight")
print("Saved phylogeny.png")
Module 6: PairwiseAligner — Pairwise Sequence Alignment
Bio.Align.PairwiseAligner replaces the legacy pairwise2 module (deprecated since Biopython 1.80). It supports local and global alignment, gap open/extend penalties, and any substitution matrix from Bio.Align.substitution_matrices.
from Bio.Align import PairwiseAligner, substitution_matrices
# Global protein alignment with BLOSUM62
aligner = PairwiseAligner()
aligner.mode = "global"
aligner.substitution_matrix = substitution_matrices.load("BLOSUM62")
aligner.open_gap_score = -11
aligner.extend_gap_score = -1
seq1 = "MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSY"
seq2 = "MTEYKLVVVGAVGVGKSALTIQLIQNHFVDEYDPTIEDSY" # G12V variant
alignments = aligner.align(seq1, seq2)
best = alignments[0]
print(f"Score: {best.score:.1f}")
print(f"Number of alignments: {aligner.score(seq1, seq2):.0f} (score only, faster)")
print(best) # formatted alignment string
identity = sum(a == b for a, b in zip(*best) if a != "-") / best.shape[1]
print(f"Identity: {identity*100:.1f}%")
from Bio.Align import PairwiseAligner, substitution_matrices
# Local DNA alignment
aligner = PairwiseAligner()
aligner.mode = "local"
aligner.match_score = 2
aligner.mismatch_score = -1
aligner.open_gap_score = -5
aligner.extend_gap_score = -0.5
query = "ACGTACGTACGT"
subject = "TTTTACGTACGTACGTTTTT"
alignments = list(aligner.align(query, subject))
print(f"Local alignments found: {len(alignments)}")
best = alignments[0]
print(f"Score: {best.score}")
print(f"Aligned region in subject: [{best.coordinates[1][0]}:{best.coordinates[1][-1]}]")
print(best)
# Batch pairwise identity matrix
seqs = ["ACGTACGT", "ACGTATGT", "TTGTACGT", "ACGTACGG"]
n = len(seqs)
aligner2 = PairwiseAligner(mode="global", match_score=1, mismatch_score=-1)
print("\nPairwise identity matrix:")
for i in range(n):
row = []
for j in range(n):
score = aligner2.score(seqs[i], seqs[j])
max_len = max(len(seqs[i]), len(seqs[j]))
row.append(f"{score/max_len:.2f}")
print(" " + " ".join(row))
Key Concepts
SeqRecord and Feature Coordinates
SeqRecord stores sequence metadata and a list of SeqFeature objects. Features use zero-based, half-open FeatureLocation(start, end, strand) — the same convention as Python slicing. Use feature.location.extract(record.seq) to obtain the strand-correct subsequence. Compound locations (e.g., spliced exons) use CompoundLocation.
from Bio import SeqIO
# Extract all CDS protein translations from a GenBank record
for rec in SeqIO.parse("gene.gb", "genbank"):
for feat in rec.features:
if feat.type == "CDS":
gene = feat.qualifiers.get("gene", ["?"])[0]
product = feat.qualifiers.get("product", ["unknown"])[0]
cds_nt = feat.location.extract(rec.seq)
protein = cds_nt.translate(to_stop=True)
print(f"{gene} ({product}): {len(protein)} aa — {protein[:10]}...")
Phylo Clade Objects
A Clade is both a node and the subtree rooted at that node. Terminal clades (leaves) have .name set; internal clades may have .confidence (bootstrap) and .branch_length. Use tree.find_clades() with terminal=True/False to filter. The root is tree.root (also a Clade). Phylo.read() returns a Tree wrapper; tree.root gives the root Clade.
Common Workflows
Workflow 1: Gene Family Phylogeny — NCBI Download → MUSCLE Alignment → Tree
Goal: Download all BRCA1 orthologs from Vertebrata, align with MUSCLE, and build a neighbor-joining tree.
import subprocess
import time
from Bio import Entrez, SeqIO, Phylo, AlignIO
from Bio.Align import MultipleSeqAlignment
import matplotlib.pyplot as plt
Entrez.email = "your.email@example.com"
# Step 1: Search NCBI Protein for BRCA1 orthologs
handle = Entrez.esearch(
db="protein",
term="BRCA1[Gene] AND Vertebrata[Organism] AND RefSeq[Filter]",
retmax=20
)
results = Entrez.read(handle); handle.close()
ids = results["IdList"]
print(f"Found {results['Count']} sequences, using {len(ids)}")
# Step 2: Fetch sequences in FASTA format
handle = Entrez.efetch(db="protein", id=",".join(ids), rettype="fasta", retmode="text")
with open("brca1_orthologs.fasta", "w") as fh:
fh.write(handle.read())
handle.close()
print(f"Saved {len(ids)} sequences to brca1_orthologs.fasta")
time.sleep(1)
# Step 3: Align with MUSCLE (must be installed: conda install -c bioconda muscle)
subprocess.run(
["muscle", "-align", "brca1_orthologs.fasta", "-output", "brca1_aligned.fasta"],
check=True
)
alignment = AlignIO.read("brca1_aligned.fasta", "fasta")
print(f"Alignment: {len(alignment)} sequences × {alignment.get_alignment_length()} columns")
# Step 4: Build a neighbor-joining tree using Bio.Phylo + distance matrix
from Bio.Phylo.TreeConstruction import DistanceCalculator, DistanceTreeConstructor
calculator = DistanceCalculator("identity")
dm = calculator.get_distance(alignment)
constructor = DistanceTreeConstructor(calculator, method="nj")
tree = constructor.build_tree(alignment)
# Step 5: Root and save
tree.root_at_midpoint()
tree.ladderize()
Phylo.write(tree, "brca1_nj.nwk", "newick")
print("Saved NJ tree to brca1_nj.nwk")
# Step 6: Visualize
fig, ax = plt.subplots(figsize=(10, 8))
Phylo.draw(tree, axes=ax, do_show=False)
ax.set_title("BRCA1 Ortholog NJ Tree")
plt.tight_layout()
plt.savefig("brca1_tree.png", dpi=150, bbox_inches="tight")
print("Saved brca1_tree.png")
Workflow 2: BLAST Pipeline — FASTA Query → Remote BLAST → Fetch Top Hits
Goal: Take a query FASTA, BLAST against NCBI nr, filter significant hits, retrieve full GenBank records for the top matches, and write a summary CSV.
import csv
import time
from Bio import SeqIO, Entrez
from Bio.Blast import NCBIWWW, NCBIXML
Entrez.email = "your.email@example.com"
# Step 1: Load query sequence
query_record = SeqIO.read("query.fasta", "fasta")
print(f"Query: {query_record.id} ({len(query_record.seq)} aa)")
# Step 2: Remote BLAST against nr protein database
print("Submitting BLAST job (may take 1-5 minutes)...")
result_handle = NCBIWWW.qblast(
"blastp", "nr", str(query_record.seq),
hitlist_size=20,
expect=1e-5,
word_size=6,
matrix_name="BLOSUM62"
)
# Step 3: Parse results and filter
E_THRESH = 1e-5
MIN_IDENTITY = 50.0
top_hits = []
blast_record = NCBIXML.read(result_handle)
for alignment in blast_record.alignments:
for hsp in alignment.hsps:
if hsp.expect > E_THRESH:
continue
identity_pct = hsp.identities / hsp.align_length * 100
if identity_pct < MIN_IDENTITY:
continue
accession = alignment.accession
top_hits.append({
"accession": accession,
"title": alignment.title[:80],
"length": alignment.length,
"score": hsp.score,
"evalue": hsp.expect,
"identity_pct": round(identity_pct, 1),
"coverage": round(hsp.align_length / blast_record.query_length * 100, 1),
})
print(f"Filtered to {len(top_hits)} significant hits")
# Step 4: Fetch full GenBank records for top 5 hits
accessions = [h["accession"] for h in top_hits[:5]]
time.sleep(1)
handle = Entrez.efetch(db="protein", id=",".join(accessions), rettype="gb", retmode="text")
gb_records = list(SeqIO.parse(handle, "genbank"))
handle.close()
SeqIO.write(gb_records, "top_blast_hits.gb", "genbank")
print(f"Saved {len(gb_records)} full GenBank records to top_blast_hits.gb")
# Step 5: Write CSV summary
with open("blast_summary.csv", "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=top_hits[0].keys())
writer.writeheader()
writer.writerows(top_hits)
print(f"Saved blast_summary.csv ({len(top_hits)} hits)")
Workflow 3: FASTQ Quality Filter and Format Pipeline
Goal: Read a FASTQ file, filter reads by mean quality and length, and output a FASTA for downstream alignment.
from Bio import SeqIO
from Bio.SeqUtils import gc_fraction
input_fastq = "raw_reads.fastq"
output_fasta = "filtered_reads.fasta"
MIN_QUAL = 20 # mean Phred quality
MIN_LEN = 100 # minimum read length
MAX_LEN = 300 # maximum read length
def passes_qc(record, min_q=MIN_QUAL, min_l=MIN_LEN, max_l=MAX_LEN):
quals = record.letter_annotations["phred_quality"]
mean_q = sum(quals) / len(quals)
length = len(record.seq)
return mean_q >= min_q and min_l <= length <= max_l
kept = 0
total = 0
with open(output_fasta, "w") as out_fh:
for record in SeqIO.parse(input_fastq, "fastq"):
total += 1
if passes_qc(record):
# Convert FASTQ → FASTA (drops quality scores)
SeqIO.write(record, out_fh, "fasta")
kept += 1
print(f"Total reads: {total:,}")
print(f"Passed QC: {kept:,} ({kept/total*100:.1f}%)")
print(f"Saved to: {output_fasta}")
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
retmax |
Entrez.esearch() |
20 |
1–10000 |
Max IDs returned per search; use history server for >10000 |
hitlist_size |
NCBIWWW.qblast() |
50 |
1–5000 |
Max BLAST alignments returned |
expect |
NCBIWWW.qblast() |
10.0 |
1e-100–1000 |
E-value cutoff for BLAST hit reporting |
matrix_name |
NCBIWWW.qblast() |
"BLOSUM62" |
"BLOSUM45", "BLOSUM80", "PAM250" |
Substitution matrix; BLOSUM62 for general use |
mode |
PairwiseAligner |
"global" |
"global", "local" |
Needleman-Wunsch (global) vs Smith-Waterman (local) |
open_gap_score |
PairwiseAligner |
-1 |
Negative float | Penalty for opening a gap; increase magnitude to penalize more |
extend_gap_score |
PairwiseAligner |
0 |
Negative float | Per-residue gap extension penalty |
method |
DistanceTreeConstructor |
"nj" |
"nj", "upgma" |
NJ (unrooted) vs UPGMA (rooted, assumes clock) |
format |
Phylo.read/write() |
required | "newick", "nexus", "phyloxml" |
Tree file format |
rettype |
Entrez.efetch() |
required | "fasta", "gb", "xml" |
Record format returned; pair with matching SeqIO format string |
Best Practices
-
Always set
Entrez.emailand respect rate limits. NCBI blocks IPs that exceed 3 req/s without a key. Addtime.sleep(0.4)between requests in loops, or useEntrez.api_keyfor 10 req/s.from Bio import Entrez import time Entrez.email = "your.email@institution.edu" Entrez.api_key = "YOUR_API_KEY" # from https://www.ncbi.nlm.nih.gov/account/ # In any batch loop: time.sleep(0.11) # ~9 req/s with key -
Use
SeqIO.index()for large FASTA files instead oflist(SeqIO.parse(...)). Loading all records into a list consumes O(N) memory;index()reads only the byte offsets and fetches records on demand.# Bad for large files: loads everything into RAM # records = {r.id: r for r in SeqIO.parse("genome.fasta", "fasta")} # Good: on-disk index, O(1) access from Bio import SeqIO idx = SeqIO.index("genome.fasta", "fasta") rec = idx["chr22"] # fetches only this record -
Use
PairwiseAlignernot the legacypairwise2.Bio.pairwise2is deprecated since Biopython 1.80 and will be removed.PairwiseAligneris faster, supports substitution matrices directly, and returnsAlignmentobjects with coordinate arrays. -
Close Entrez handles immediately after reading. Handles are HTTP connections; leaving them open risks timeouts and resource exhaustion.
handle = Entrez.efetch(db="nucleotide", id="NM_007294", rettype="gb", retmode="text") record = SeqIO.read(handle, "genbank") handle.close() # do not skip this -
Prefer local BLAST for batch searches. Remote
NCBIWWW.qblast()is suitable for ad hoc queries but can queue for minutes on NCBI servers. For screening >100 sequences, build a local BLAST+ database withmakeblastdband callblastp/blastnviasubprocesswith-outfmt 5(XML) forNCBIXMLparsing. -
Root trees before measuring distances.
Phylo.distance()measures the sum of branch lengths along the path between two nodes. On an unrooted tree, the path is still unique, but midpoint-rooting or outgroup-rooting makes biological sense for visualizations and clade assertions. -
Strip alignment gaps before building SeqRecord collections. BLAST and alignment results may include gap characters (
-).Seqoperations like.translate()will raise errors on gapped sequences; strip withseq.replace("-", "")or useungap().
Common Recipes
Recipe: Batch Entrez Fetch with History Server
When to use: Downloading more than 500 records from NCBI — avoids URL length limits and keeps search results server-side.
from Bio import Entrez, SeqIO
import time
Entrez.email = "your.email@example.com"
# Search and store results on NCBI history server
handle = Entrez.esearch(db="nucleotide", term="16S rRNA[Gene] AND Bacteria[Organism]",
retmax=500, usehistory="y")
results = Entrez.read(handle); handle.close()
webenv = results["WebEnv"]
query_key = results["QueryKey"]
count = int(results["Count"])
print(f"Total: {count} sequences")
# Fetch in batches of 200
batch_size = 200
all_records = []
for start in range(0, min(count, 1000), batch_size):
handle = Entrez.efetch(
db="nucleotide", rettype="fasta", retmode="text",
retstart=start, retmax=batch_size,
webenv=webenv, query_key=query_key
)
batch = list(SeqIO.parse(handle, "fasta"))
handle.close()
all_records.extend(batch)
print(f" Fetched {len(all_records)}/{min(count, 1000)}")
time.sleep(0.5)
SeqIO.write(all_records, "16S_sequences.fasta", "fasta")
print(f"Saved {len(all_records)} sequences")
Recipe: Parse GFF3 with Sequence Features
When to use: Extract gene/CDS sequences from a GFF3 annotation paired with a reference FASTA.
from Bio import SeqIO
# Load genome FASTA into indexed dict
genome = SeqIO.to_dict(SeqIO.parse("genome.fasta", "fasta"))
# Parse GFF3 manually (Biopython does not have a native GFF3 parser;
# use the gffutils package for complex queries, or parse directly for simple cases)
genes = []
with open("annotation.gff3") as fh:
for line in fh:
if line.startswith("#") or not line.strip():
continue
fields = line.strip().split("\t")
if len(fields) < 9 or fields[2] != "CDS":
continue
chrom, _, feat_type, start, end, _, strand, _, attrs = fields
start, end = int(start) - 1, int(end) # GFF3 is 1-based
if chrom not in genome:
continue
seq = genome[chrom].seq[start:end]
if strand == "-":
seq = seq.reverse_complement()
attr_dict = dict(kv.split("=") for kv in attrs.strip().split(";") if "=" in kv)
gene_id = attr_dict.get("gene_id", attr_dict.get("ID", "unknown"))
genes.append((gene_id, seq, strand))
print(f"Extracted {len(genes)} CDS features")
for gid, seq, strand in genes[:3]:
print(f" {gid} ({strand}): {len(seq)} bp — protein: {seq.translate(to_stop=True)[:8]}...")
Recipe: Compute a Pairwise Identity Matrix from a Multiple Alignment
When to use: Quickly assess sequence diversity within an alignment file (FASTA, PHYLIP, Clustal) and identify outlier sequences.
from Bio import AlignIO
import numpy as np
alignment = AlignIO.read("aligned_sequences.fasta", "fasta")
n = len(alignment)
names = [rec.id for rec in alignment]
length = alignment.get_alignment_length()
# Build identity matrix
identity_matrix = np.zeros((n, n))
for i in range(n):
for j in range(n):
if i == j:
identity_matrix[i, j] = 1.0
continue
matches = sum(
a == b and a != "-"
for a, b in zip(str(alignment[i].seq), str(alignment[j].seq))
)
aligned_cols = sum(a != "-" and b != "-"
for a, b in zip(str(alignment[i].seq), str(alignment[j].seq)))
identity_matrix[i, j] = matches / aligned_cols if aligned_cols else 0.0
print("Pairwise identity matrix:")
print(f"{'':20s} " + " ".join(f"{n[:8]:>8s}" for n in names))
for i, name in enumerate(names):
row = " ".join(f"{identity_matrix[i,j]*100:8.1f}" for j in range(n))
print(f"{name[:20]:20s} {row}")
# Find most divergent pair
min_id = np.min(identity_matrix[identity_matrix > 0])
idx = np.unravel_index(np.argmin(np.where(identity_matrix > 0, identity_matrix, 1)), identity_matrix.shape)
print(f"\nMost divergent pair: {names[idx[0]]} vs {names[idx[1]]} ({min_id*100:.1f}%)")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
urllib.error.HTTPError: 429 Too Many Requests |
Exceeding NCBI rate limit (3 req/s) | Add time.sleep(0.4) between calls, or register a free API key at https://www.ncbi.nlm.nih.gov/account/ for 10 req/s |
RuntimeError: Too many requests were made without pausing |
NCBI detects rapid-fire requests | Set Entrez.email (required), reduce loop frequency, batch IDs into comma-joined strings in a single efetch call |
Bio.Application.ApplicationError: blastall not found |
Legacy blastall replaced by BLAST+ |
Replace NcbiblastpCommandline with subprocess.run(["blastp", ...]) and BLAST+ tools |
AttributeError: 'PairwiseAligner' object has no attribute 'align' |
Biopython < 1.78 | Upgrade: pip install --upgrade biopython (PairwiseAligner requires ≥ 1.72; stable from 1.78) |
Bio.pairwise2 deprecation warning |
Using the legacy pairwise2 API | Replace with from Bio.Align import PairwiseAligner (removed in Biopython 1.84+) |
ValueError: Sequence contains letters not in the alphabet |
Non-standard characters (e.g., N, ambiguity codes) in sequence before translate() |
Strip or replace ambiguous bases; use seq.translate(table=1) which handles ambiguous codons |
Empty BLAST results / StopIteration from NCBIXML.read() |
Empty or malformed XML; network timeout; query too short | Check query sequence length (>10 aa recommended); switch from .read() to .parse() and check blast_record.alignments length |
Phylo.draw() hangs or produces blank plot |
Missing matplotlib backend | Call import matplotlib; matplotlib.use("Agg") before importing Phylo for headless environments |
TreeConstruction distance matrix dimension mismatch |
Alignment contains duplicate IDs | Deduplicate SeqRecord IDs before constructing the alignment: {r.id: r for r in records}.values() |
Related Skills
- biopython-molecular-biology — restriction digestion, PCR primer design, protein structure analysis (Bio.PDB), molecular weight and Tm calculations; complement to this skill
- pysam-genomic-files — SAM/BAM/CRAM alignment file access, pileup, and region queries; use when working with read alignments
- scikit-bio — ecological diversity statistics (UniFrac, Bray-Curtis), ordination (PCoA), and microbiome analysis; more specialized than Biopython's phylogenetics for diversity analyses
- gget-genomic-databases — one-liner gene lookups, BLAST, and database queries without setting up an Entrez pipeline
- etetoolkit — advanced phylogenetic tree visualization, annotation with NCBI taxonomy, and publication-quality tree rendering
References
- Biopython Tutorial and Cookbook — comprehensive official tutorial (Chapters 2, 5, 6, 7, 9, 11)
- Biopython API Reference — complete module and class documentation
- GitHub: biopython/biopython — source, changelog, and issue tracker
- NCBI E-utilities Documentation — Entrez API reference for esearch, efetch, elink, esummary
- PairwiseAligner Migration Guide — replacing the deprecated
Bio.pairwise2module
skills/genomics-bioinformatics/databases/archs4-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill archs4-database -g -y
SKILL.md
Frontmatter
{
"name": "archs4-database",
"license": "CC-BY-4.0",
"description": "Query ARCHS4 REST API for uniformly processed RNA-seq expression, tissue patterns, co-expression across 1M+ human\/mouse samples. Retrieve z-scores, co-expressed genes, samples by metadata, HDF5 matrices. For variant population genetics use gnomad-database; for pathway enrichment use gget-genomic-databases (Enrichr)."
}
ARCHS4 Database
Overview
ARCHS4 (All RNA-seq and ChIP-seq Sample and Signature Search) is a resource of uniformly aligned and processed human and mouse RNA-seq data from NCBI GEO and SRA, covering 1 million+ samples. The REST API at https://maayanlab.cloud/archs4/api/ provides gene-level expression profiles, z-score normalized tissue expression, co-expression networks, and sample metadata search — all without authentication. Large-scale bulk queries can also use the downloadable HDF5 expression matrices.
When to Use
- Retrieving tissue-specific or cell-type-specific expression z-scores for a gene of interest across hundreds of tissue types
- Finding genes co-expressed with a query gene (co-expression network construction or guilt-by-association analysis)
- Searching for RNA-seq samples by tissue, disease, or metadata keyword to identify candidate datasets for reanalysis
- Comparing expression profiles of multiple genes across tissues to prioritize candidates for wet-lab follow-up
- Accessing uniformly processed gene expression matrices (HDF5 format) for large-scale cross-study analysis
- Validating differential expression results by checking whether a gene's expression direction matches population-level tissue profiles
- For variant-level population allele frequencies use
gnomad-database; ARCHS4 provides expression evidence only - For Enrichr pathway enrichment from a gene list use
gget-genomic-databases(gget enrichr); ARCHS4 is for expression lookups
Prerequisites
- Python packages:
requests,pandas,matplotlib,seaborn - Data requirements: gene symbols (HGNC format, e.g.,
TP53,BRCA1); sample GEO/SRA IDs for direct sample queries - Environment: internet connection; no API key or account required
- Rate limits: ~10 requests/second; add
time.sleep(0.1)between sequential gene queries to avoid throttling
pip install requests pandas matplotlib seaborn
Quick Start
import requests
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def archs4_get(endpoint: str, params: dict = None) -> dict:
"""Send a GET request to the ARCHS4 API and return parsed JSON."""
r = requests.get(f"{ARCHS4_BASE}/{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
# Quick check: top tissues expressing TP53
data = archs4_get("meta/genes/TP53/zscore")
tissues = data.get("values", [])
print(f"TP53 tissue expression entries: {len(tissues)}")
top5 = sorted(tissues, key=lambda x: x.get("zscore", 0), reverse=True)[:5]
for t in top5:
print(f" {t['tissue']:<40} z={t['zscore']:.2f}")
# TP53 tissue expression entries: 200
# thymus z=2.81
# testis z=2.44
Core API
Query 1: Gene Expression Z-Scores Across Tissues
Retrieve z-score normalized expression for a gene across all available tissue types. Z-scores are computed per-sample relative to the population distribution; positive values indicate above-average expression.
import requests
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def get_gene_tissue_zscore(gene_symbol: str, species: str = "human") -> pd.DataFrame:
"""Return tissue z-score expression profile for a gene.
Parameters
----------
gene_symbol : str
HGNC gene symbol (e.g., 'TP53').
species : str
'human' or 'mouse' (default: 'human').
"""
endpoint = f"meta/genes/{gene_symbol}/zscore"
r = requests.get(
f"{ARCHS4_BASE}/{endpoint}",
params={"species": species},
timeout=30
)
r.raise_for_status()
data = r.json()
records = data.get("values", [])
df = pd.DataFrame(records)
return df.sort_values("zscore", ascending=False).reset_index(drop=True)
df = get_gene_tissue_zscore("MYC")
print(f"MYC tissue z-scores: {len(df)} tissue types")
print(df[["tissue", "zscore"]].head(10).to_string(index=False))
# MYC tissue z-scores: 200
# tissue zscore
# colon 3.12
# small intestine 2.98
# placenta 2.74
# Query mouse tissues for a gene
df_mouse = get_gene_tissue_zscore("Myc", species="mouse")
print(f"Mouse Myc: top 5 tissues")
print(df_mouse[["tissue", "zscore"]].head(5).to_string(index=False))
Query 2: Co-expressed Genes
Find genes whose expression is most correlated with a query gene across all ARCHS4 samples. Useful for identifying pathway partners, regulators, or candidate targets.
import requests
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def get_coexpressed_genes(gene_symbol: str, top_n: int = 50,
species: str = "human") -> pd.DataFrame:
"""Return genes co-expressed with the query gene.
Parameters
----------
gene_symbol : str
HGNC gene symbol.
top_n : int
Number of correlated genes to return (default: 50).
species : str
'human' or 'mouse' (default: 'human').
"""
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene_symbol}/correlations",
params={"species": species, "limit": top_n},
timeout=30
)
r.raise_for_status()
data = r.json()
records = data.get("values", [])
df = pd.DataFrame(records)
return df.sort_values("correlation", ascending=False).reset_index(drop=True)
coexp = get_coexpressed_genes("PCNA", top_n=20)
print(f"Top co-expressed genes with PCNA (n={len(coexp)}):")
print(coexp[["gene", "correlation"]].head(10).to_string(index=False))
# Top co-expressed genes with PCNA (n=20):
# gene correlation
# RFC4 0.91
# RFC2 0.89
# MCM6 0.87
# Extract gene list for downstream enrichment
gene_list = coexp["gene"].tolist()
print(f"Co-expression gene list: {gene_list[:10]}")
# Pass gene_list to Enrichr or pathway analysis tools
Query 3: Sample Search
Search for RNA-seq samples by metadata keyword (tissue, disease condition, cell type, treatment). Returns GEO/SRA sample identifiers with metadata fields.
import requests
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def search_samples(keyword: str, species: str = "human",
limit: int = 100) -> pd.DataFrame:
"""Search ARCHS4 samples by metadata keyword.
Parameters
----------
keyword : str
Search term (e.g., 'breast cancer', 'liver', 'HeLa').
species : str
'human' or 'mouse'.
limit : int
Maximum number of samples to return.
"""
r = requests.get(
f"{ARCHS4_BASE}/samples/search",
params={"query": keyword, "species": species, "limit": limit},
timeout=30
)
r.raise_for_status()
data = r.json()
records = data.get("samples", [])
return pd.DataFrame(records)
samples = search_samples("pancreatic cancer", limit=50)
print(f"Samples matching 'pancreatic cancer': {len(samples)}")
if len(samples) > 0:
print(samples[["sample_id", "series_id", "title"]].head(5).to_string(index=False))
# Samples matching 'pancreatic cancer': 50
# sample_id series_id title
# GSM2345678 GSE123456 Pancreatic ductal adenocarcinoma - sample 1
Query 4: Gene-Level Metadata Summary
Retrieve summary statistics and metadata for a gene including the number of samples expressing it, expression percentile, and available annotation.
import requests
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def get_gene_metadata(gene_symbol: str, species: str = "human") -> dict:
"""Return metadata and expression summary for a gene."""
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene_symbol}",
params={"species": species},
timeout=30
)
r.raise_for_status()
return r.json()
meta = get_gene_metadata("GAPDH")
print(f"Gene: {meta.get('gene_symbol', 'N/A')}")
print(f"Species: {meta.get('species', 'N/A')}")
print(f"Ensembl ID: {meta.get('ensembl_gene_id', 'N/A')}")
print(f"Description: {meta.get('description', 'N/A')[:80]}")
# Compare metadata for a panel of housekeeping genes
import time
housekeeping = ["GAPDH", "ACTB", "B2M", "HPRT1", "RPLP0"]
for gene in housekeeping:
meta = get_gene_metadata(gene)
print(f" {gene:<8} {meta.get('ensembl_gene_id', 'N/A')}")
time.sleep(0.1)
Query 5: Visualization — Tissue Expression Barplot
Generate a publication-ready barplot of z-score expression across the top tissues for a gene.
import requests
import pandas as pd
import matplotlib.pyplot as plt
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def plot_tissue_expression(gene_symbol: str, top_n: int = 20,
species: str = "human",
output_file: str = None) -> None:
"""Plot top tissue z-score expression for a gene.
Parameters
----------
gene_symbol : str
HGNC gene symbol.
top_n : int
Number of top tissues to display.
species : str
'human' or 'mouse'.
output_file : str
If provided, save figure to this path.
"""
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene_symbol}/zscore",
params={"species": species},
timeout=30
)
r.raise_for_status()
records = r.json().get("values", [])
df = pd.DataFrame(records).sort_values("zscore", ascending=False).head(top_n)
fig, ax = plt.subplots(figsize=(10, 6))
colors = ["#D73027" if z > 0 else "#4575B4" for z in df["zscore"]]
bars = ax.barh(df["tissue"][::-1], df["zscore"][::-1], color=colors[::-1])
ax.axvline(0, color="black", linewidth=0.8, linestyle="--")
ax.set_xlabel("Expression Z-Score")
ax.set_title(f"ARCHS4 Tissue Expression: {gene_symbol} ({species})\nTop {top_n} tissues")
ax.bar_label(bars, fmt="%.2f", padding=3, fontsize=8)
plt.tight_layout()
fname = output_file or f"{gene_symbol}_tissue_expression.png"
plt.savefig(fname, dpi=150, bbox_inches="tight")
print(f"Saved {fname} ({len(df)} tissues plotted)")
plot_tissue_expression("BRCA1", top_n=15, output_file="BRCA1_tissue_expression.png")
Query 6: HDF5 Bulk Data Access
Download or stream from ARCHS4's precomputed HDF5 expression matrices for large-scale cross-sample analysis. The HDF5 files contain gene × sample count matrices for human and mouse.
import requests
# HDF5 files are available for bulk download from the ARCHS4 data portal
# URL pattern: https://maayanlab.cloud/archs4/download#expression
# Human gene-level: human_gene_v2.6.h5
# Mouse gene-level: mouse_gene_v2.6.h5
def get_h5_download_urls() -> dict:
"""Return download URLs for ARCHS4 HDF5 expression matrices."""
base = "https://maayanlab.cloud/archs4"
return {
"human_gene": f"{base}/files/human_gene_v2.6.h5",
"mouse_gene": f"{base}/files/mouse_gene_v2.6.h5",
"human_transcript": f"{base}/files/human_transcript_v2.6.h5",
"mouse_transcript": f"{base}/files/mouse_transcript_v2.6.h5",
}
urls = get_h5_download_urls()
for key, url in urls.items():
print(f" {key:<22} {url}")
# To work with a downloaded HDF5 file:
try:
import h5py
import numpy as np
h5_path = "human_gene_v2.6.h5" # after download
def extract_gene_from_h5(h5_path: str, gene_symbol: str,
n_samples: int = 1000) -> dict:
"""Extract expression values for a gene from the HDF5 matrix."""
with h5py.File(h5_path, "r") as f:
genes = [g.decode() for g in f["meta"]["genes"]["gene_symbol"][:]]
if gene_symbol not in genes:
raise ValueError(f"{gene_symbol} not found in HDF5")
idx = genes.index(gene_symbol)
expr = f["data"]["expression"][idx, :n_samples]
sample_ids = [s.decode() for s in f["meta"]["samples"]["geo_accession"][:n_samples]]
return {"gene": gene_symbol, "expression": expr, "sample_ids": sample_ids}
result = extract_gene_from_h5(h5_path, "TP53", n_samples=500)
print(f"TP53 expression: mean={result['expression'].mean():.2f},"
f" max={result['expression'].max():.2f} (n={len(result['expression'])} samples)")
except ImportError:
print("h5py not installed. Install with: pip install h5py")
except FileNotFoundError:
print("HDF5 file not downloaded yet. Use the URLs above to download first.")
Key Concepts
Z-Score Normalization
ARCHS4 reports gene expression as z-scores computed relative to all samples for that gene. A z-score of 0 means expression at the population mean; a z-score of 2.0 means expression 2 standard deviations above the mean. Z-scores are more interpretable across datasets than raw counts because they account for library size differences and batch effects introduced by uniform alignment across studies.
# Example: Positive z-score = above-average expression for that gene
# z > 2.0 → top ~2.5% of samples for that gene
# z < -2.0 → bottom ~2.5% of samples for that gene
# Use absolute z-score thresholds consistently when comparing across genes
HDF5 vs REST API
| Access method | Best for | Limitations |
|---|---|---|
REST API (/zscore, /correlations) |
Quick single-gene queries, exploration | Aggregated profiles only, no per-sample access |
REST API (/samples/search) |
Discovering relevant datasets | Returns metadata, not expression values |
| HDF5 download | Bulk analysis, custom co-expression, ML | Requires 30–60 GB disk; download once |
Species and Gene Symbol Conventions
ARCHS4 indexes human samples using HGNC gene symbols (uppercase, e.g., TP53) and mouse samples using MGI symbols (first letter uppercase, e.g., Trp53). The species parameter accepts "human" or "mouse". Mixed-case or ensemble IDs will return empty results.
Common Workflows
Workflow 1: Multi-Gene Tissue Expression Heatmap
Goal: Compare tissue expression profiles of a gene panel and visualize as a heatmap to identify tissue-specific vs ubiquitous expression patterns.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
gene_panel = ["MYC", "TP53", "BRCA1", "EGFR", "KRAS", "CDK4"]
top_n_tissues = 25
def get_tissue_zscores(gene: str) -> pd.Series:
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene}/zscore",
params={"species": "human"},
timeout=30
)
r.raise_for_status()
records = r.json().get("values", [])
df = pd.DataFrame(records).set_index("tissue")["zscore"]
return df
# Build expression matrix (genes × tissues)
all_data = {}
for gene in gene_panel:
try:
all_data[gene] = get_tissue_zscores(gene)
print(f" Fetched {gene}")
except Exception as e:
print(f" Warning: {gene} failed — {e}")
time.sleep(0.1)
matrix = pd.DataFrame(all_data).T # genes × tissues
# Select top tissues by max absolute z-score
tissue_importance = matrix.abs().max(axis=0).sort_values(ascending=False)
top_tissues = tissue_importance.head(top_n_tissues).index
matrix_subset = matrix[top_tissues]
# Plot heatmap
fig, ax = plt.subplots(figsize=(14, 5))
sns.heatmap(
matrix_subset,
cmap="RdBu_r",
center=0,
vmin=-3,
vmax=3,
ax=ax,
cbar_kws={"label": "Z-Score"},
linewidths=0.5
)
ax.set_title("ARCHS4 Tissue Expression Profiles — Gene Panel")
ax.set_xlabel("Tissue")
ax.set_ylabel("Gene")
plt.xticks(rotation=45, ha="right", fontsize=8)
plt.tight_layout()
plt.savefig("archs4_panel_heatmap.png", dpi=150, bbox_inches="tight")
print(f"Saved archs4_panel_heatmap.png ({matrix_subset.shape})")
Workflow 2: Co-expression Network Seed Expansion
Goal: Start from a seed gene, retrieve co-expressed partners, then query their co-expressed genes in turn to build a two-hop co-expression neighborhood.
import requests, time
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def get_coexp(gene: str, top_n: int = 20, species: str = "human") -> list:
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene}/correlations",
params={"species": species, "limit": top_n},
timeout=30
)
r.raise_for_status()
return [rec["gene"] for rec in r.json().get("values", [])]
seed_gene = "PCNA"
min_correlation = 0.80
# Hop 1: direct co-expressed partners
hop1_genes = get_coexp(seed_gene, top_n=30)
print(f"Hop 1 partners of {seed_gene}: {len(hop1_genes)}")
time.sleep(0.1)
# Hop 2: co-expressed genes of each partner
edges = set()
for gene in hop1_genes[:10]: # limit for demonstration
partners = get_coexp(gene, top_n=20)
for partner in partners:
if partner != seed_gene:
edges.add((gene, partner))
time.sleep(0.1)
# Summarize the network
network_df = pd.DataFrame(list(edges), columns=["source", "target"])
hub_counts = network_df["source"].value_counts()
print(f"\nTwo-hop network: {len(edges)} edges")
print(f"Top hub genes:")
print(hub_counts.head(5))
network_df.to_csv(f"{seed_gene}_coexp_network.csv", index=False)
print(f"\nSaved {seed_gene}_coexp_network.csv")
Workflow 3: Sample Discovery and Dataset Summary
Goal: Search for samples by disease keyword, summarize how many GEO series are available, and export sample metadata for downstream reanalysis selection.
import requests, time
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def search_and_summarize(keyword: str, species: str = "human",
limit: int = 200) -> pd.DataFrame:
"""Search samples and return a tidy metadata DataFrame."""
r = requests.get(
f"{ARCHS4_BASE}/samples/search",
params={"query": keyword, "species": species, "limit": limit},
timeout=30
)
r.raise_for_status()
records = r.json().get("samples", [])
return pd.DataFrame(records)
keyword = "colorectal cancer"
df = search_and_summarize(keyword, limit=150)
print(f"Samples matching '{keyword}': {len(df)}")
if len(df) > 0:
# Summarize by GEO series
series_counts = df["series_id"].value_counts()
print(f"\nTop GEO series (by sample count):")
print(series_counts.head(8).to_string())
# Export sample list
df.to_csv(f"{keyword.replace(' ', '_')}_samples.csv", index=False)
print(f"\nSaved {keyword.replace(' ', '_')}_samples.csv ({len(df)} samples)")
print(f"Unique GEO series: {df['series_id'].nunique()}")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
species |
All gene endpoints | "human" |
"human", "mouse" |
Selects the species-specific sample index |
limit |
/correlations, /samples/search |
100 |
1–500 |
Number of results returned |
gene_symbol (path) |
/meta/genes/{gene}/zscore, /correlations |
— | HGNC symbol (human) or MGI symbol (mouse) | Query gene; case-sensitive |
query |
/samples/search |
— | free-text string | Metadata keyword search across title, tissue, source fields |
offset |
/samples/search |
0 |
integer | Pagination offset for large result sets |
correlation (response field) |
/correlations |
— | -1.0–1.0 |
Pearson correlation coefficient; filter > 0.7 for high co-expression |
zscore (response field) |
/zscore |
— | continuous float | Expression z-score; > 2.0 = high expression |
page_size (HDF5) |
HDF5 slice | all | any integer | Number of samples to extract per read from HDF5 |
Best Practices
-
Use z-score thresholds consistently: Because z-scores are gene-specific, a z-score of 2.0 for a ubiquitous gene (GAPDH) and a tissue-restricted gene (TTR, liver) have different interpretive meaning. Always annotate which gene you are comparing and the tissue background.
-
Sleep between batch queries: ARCHS4 enforces a soft rate limit of ~10 requests/second. Add
time.sleep(0.1)between sequential gene queries to avoid429 Too Many Requestserrors. -
Download HDF5 for large-scale analyses: For queries covering 50+ genes or requiring per-sample expression values, the REST API is impractical. Download the HDF5 file once and use
h5pyslicing for fast matrix access; this avoids hitting rate limits and is 100× faster for bulk extraction. -
Match gene symbol conventions by species: Human queries require HGNC uppercase symbols (e.g.,
TP53); mouse queries require MGI-style symbols (e.g.,Trp53). Using the wrong case returns empty results without an error. -
Validate co-expression findings across datasets: ARCHS4 co-expression aggregates across all tissue types. A high correlation may be driven by a single tissue or study. Cross-check with tissue-specific queries or manually inspect the top contributing GEO series.
Common Recipes
Recipe: Quick Tissue Specificity Check
When to use: Rapidly determine whether a gene is broadly expressed (housekeeping) or tissue-restricted before designing experiments.
import requests
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def tissue_specificity_summary(gene_symbol: str) -> None:
"""Print a summary of high and low expression tissues for a gene."""
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene_symbol}/zscore",
params={"species": "human"},
timeout=30
)
r.raise_for_status()
records = r.json().get("values", [])
zscores = [rec["zscore"] for rec in records if rec.get("zscore") is not None]
top_high = sorted(records, key=lambda x: x.get("zscore", 0), reverse=True)[:5]
top_low = sorted(records, key=lambda x: x.get("zscore", float("inf")))[:3]
print(f"\n{gene_symbol} — {len(zscores)} tissues")
print(f" Range: [{min(zscores):.2f}, {max(zscores):.2f}] "
f"Mean: {sum(zscores)/len(zscores):.2f}")
print(" High expression:")
for t in top_high:
print(f" {t['tissue']:<35} z={t['zscore']:.2f}")
print(" Low expression:")
for t in top_low:
print(f" {t['tissue']:<35} z={t['zscore']:.2f}")
tissue_specificity_summary("TTR") # Transthyretin — liver-specific
Recipe: Batch Gene Co-Expression Table
When to use: Generate a pairwise correlation table for a gene panel from a list of differentially expressed genes.
import requests, time
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
def batch_coexpr_table(gene_list: list, top_n: int = 10) -> pd.DataFrame:
"""For each gene in gene_list, return its top co-expressed genes."""
rows = []
for gene in gene_list:
try:
r = requests.get(
f"{ARCHS4_BASE}/meta/genes/{gene}/correlations",
params={"species": "human", "limit": top_n},
timeout=30
)
r.raise_for_status()
for rec in r.json().get("values", []):
rows.append({
"query_gene": gene,
"coexp_gene": rec.get("gene"),
"correlation": rec.get("correlation"),
})
time.sleep(0.1)
except Exception as e:
print(f"Warning: {gene} skipped — {e}")
return pd.DataFrame(rows)
deg_list = ["MYC", "CCND1", "CDK4", "RB1", "E2F1"]
coexp_table = batch_coexpr_table(deg_list, top_n=10)
print(f"Co-expression entries: {len(coexp_table)}")
print(coexp_table.groupby("query_gene")["coexp_gene"].count())
coexp_table.to_csv("deg_coexpression_table.csv", index=False)
print("Saved deg_coexpression_table.csv")
Recipe: Export Sample IDs for GEO Download
When to use: Identify relevant GEO accessions to download raw count matrices for a meta-analysis.
import requests
import pandas as pd
ARCHS4_BASE = "https://maayanlab.cloud/archs4/api/v1"
keyword = "glioblastoma"
r = requests.get(
f"{ARCHS4_BASE}/samples/search",
params={"query": keyword, "species": "human", "limit": 200},
timeout=30
)
r.raise_for_status()
samples = pd.DataFrame(r.json().get("samples", []))
if len(samples) > 0:
# Get unique GEO series accessions
series = samples["series_id"].dropna().unique()
print(f"Unique GEO series for '{keyword}': {len(series)}")
for s in series[:10]:
n = (samples["series_id"] == s).sum()
print(f" {s} ({n} samples)")
# Export series list for GEO download script
pd.Series(series, name="geo_series").to_csv(
f"{keyword}_geo_series.txt", index=False
)
print(f"\nSaved {keyword}_geo_series.txt")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 for gene query |
Gene symbol not found in ARCHS4 index | Verify HGNC symbol spelling; check species parameter matches gene convention (human: uppercase, mouse: first-letter-upper) |
HTTP 429 Too Many Requests |
Exceeded ~10 req/s rate limit | Add time.sleep(0.1) between requests; for batch queries use a 0.5 s delay |
Empty values list in z-score response |
Gene is not expressed in any indexed tissue, or wrong species | Switch species; verify gene is protein-coding and has GEO coverage |
Empty samples list from search |
Keyword not matched in metadata fields | Try broader or alternative keywords (e.g., "liver" instead of "hepatic") |
| HDF5 gene not found | Symbol mismatch between HDF5 version and query | Check available genes in f["meta"]["genes"]["gene_symbol"][:]; try Ensembl ID or alias |
requests.exceptions.Timeout |
Slow API response under load | Increase timeout=60; retry with exponential backoff |
| Z-scores all near zero | Gene has very low or absent expression across tissues | Check the gene's expression in raw counts; the gene may be non-coding or very lowly expressed |
Related Skills
gnomad-database— Population variant frequencies; use after ARCHS4 to identify variants in highly expressed genesgget-genomic-databases— Enrichr pathway enrichment for ARCHS4 co-expression gene lists (gget enrichr)pydeseq2-differential-expression— Differential expression analysis on bulk RNA-seq; ARCHS4 HDF5 matrices can serve as reference cohorts
References
- ARCHS4 web portal — Interactive expression browser and dataset download
- ARCHS4 REST API documentation — Endpoint reference and parameters
- Lachmann et al., Nature Communications 2018 — ARCHS4 original publication describing uniform alignment pipeline
- ARCHS4 GitHub — Source code and HDF5 schema documentation
skills/genomics-bioinformatics/databases/bioservices-multi-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill bioservices-multi-database -g -y
SKILL.md
Frontmatter
{
"name": "bioservices-multi-database",
"license": "GPLv3",
"description": "Unified Python interface to 40+ bioinformatics web services: UniProt proteins, KEGG pathways, ChEMBL\/ChEBI\/PubChem, BLAST, cross-database ID mapping, GO annotations, PPI. For deep single-DB queries use dedicated tools (gget for Ensembl, pubchempy for PubChem); bioservices excels at cross-database workflows.\n"
}
BioServices Multi-Database Access
Overview
BioServices provides a unified Python interface to 40+ bioinformatics web services including UniProt, KEGG, ChEMBL, ChEBI, PubChem, UniChem, PSICQUIC, QuickGO, and BLAST. Each service is accessed through a consistent object-oriented API with built-in caching, rate limiting, and output format handling.
When to Use
- Querying protein information from UniProt (search, retrieve, ID mapping)
- Discovering KEGG pathways and extracting gene/interaction networks
- Cross-referencing compounds across ChEMBL, ChEBI, PubChem, and KEGG
- Running BLAST sequence similarity searches against UniProtKB
- Mapping identifiers between biological databases (UniProt, Ensembl, KEGG, RefSeq, PDB)
- Retrieving Gene Ontology annotations via QuickGO
- Finding protein-protein interactions via PSICQUIC (IntAct, MINT, BioGRID)
- Batch converting thousands of biological identifiers with error handling
- For single-database deep queries → use gget (Ensembl), pubchempy (PubChem), or chembl-database-bioactivity skill
- For pathway visualization → use pathway analysis tools (Cytoscape, NetworkX) after retrieving data with bioservices
Prerequisites
pip install bioservices
# Optional: pandas for tabular output, matplotlib for visualization
pip install pandas matplotlib
API Rate Limits: Most services have rate limits. bioservices handles basic throttling internally, but for batch operations add explicit delays:
- UniProt mapping: ~1 request/second for batch jobs
- KEGG: 10 requests/second (be conservative with pathway parsing)
- ChEMBL/ChEBI: 5-10 requests/second
- BLAST: 1 job at a time (async polling, ~30-300s per job)
Quick Start
from bioservices import UniProt, KEGG
import time
# Protein lookup
u = UniProt(verbose=False)
result = u.search("ABL1_HUMAN", frmt="tsv", columns="accession,gene_names,organism_name,length")
print(result[:200])
# Pathway discovery
k = KEGG(verbose=False)
pathways = k.get_pathway_by_gene("hsa:25", "hsa") # ABL1
print(f"ABL1 participates in {len(pathways)} pathways")
for pid, name in list(pathways.items())[:3]:
print(f" {pid}: {name}")
Core API
1. Protein Analysis (UniProt)
from bioservices import UniProt
u = UniProt(verbose=False)
# Search by protein name or gene
result = u.search("BRCA1 AND organism_id:9606", frmt="tsv",
columns="accession,gene_names,protein_name,length,go_p")
print(result[:300])
# Retrieve full entry
entry = u.retrieve("P38398", frmt="txt") # Swiss-Prot flat file
fasta = u.retrieve("P38398", frmt="fasta")
print(fasta[:200])
# ID mapping: gene names → UniProt accessions
result = u.mapping(fr="Gene_Name", to="UniProtKB", query="BRCA1 TP53 ABL1", taxId=9606)
print(f"Mapped {len(result['results'])} entries")
for r in result['results']:
print(f" {r['from']} → {r['to']['primaryAccession']}")
2. Pathway Discovery (KEGG)
from bioservices import KEGG
k = KEGG(verbose=False)
# List pathways for an organism
pathways = k.pathwayIds # All reference pathways
human_pathways = k.list("pathway", "hsa")
print(f"Human pathways: {len(human_pathways.strip().splitlines())}")
# Get pathway details
pathway_data = k.get("hsa04110") # Cell cycle
parsed = k.parse(pathway_data)
print(f"Pathway: {parsed.get('NAME', 'Unknown')}")
print(f"Genes: {len(parsed.get('GENE', {}))}")
# KGML parsing for interaction networks
from bioservices import KEGG
k = KEGG(verbose=False)
kgml = k.get("hsa04110", "kgml") # XML pathway representation
# Parse KGML for entries and relations
import xml.etree.ElementTree as ET
root = ET.fromstring(kgml)
entries = root.findall("entry")
relations = root.findall("relation")
print(f"Entries: {len(entries)}, Relations: {len(relations)}")
# Extract interaction types
from collections import Counter
rel_types = Counter()
for rel in relations:
for subtype in rel.findall("subtype"):
rel_types[subtype.get("name")] += 1
print(f"Interaction types: {dict(rel_types)}")
3. Compound Databases (ChEMBL, ChEBI, UniChem, PubChem)
from bioservices import ChEMBL, ChEBI, UniChem
import time
# ChEMBL compound lookup
chembl = ChEMBL(verbose=False)
result = chembl.get_molecule("CHEMBL25") # Aspirin
print(f"Name: {result['pref_name']}")
print(f"MW: {result['molecule_properties']['full_mwt']}")
print(f"SMILES: {result['molecule_structures']['canonical_smiles']}")
time.sleep(0.2)
# ChEBI entity lookup
chebi = ChEBI(verbose=False)
entity = chebi.getCompleteEntity("CHEBI:15365") # Aspirin
print(f"ChEBI Name: {entity.chebiAsciiName}")
print(f"Formula: {entity.formulae[0].data if entity.formulae else 'N/A'}")
# Cross-database compound mapping via UniChem
from bioservices import UniChem
uc = UniChem()
# Map ChEMBL ID to other databases
# Source IDs: 1=ChEMBL, 2=DrugBank, 3=PDB, 4=IUPHAR, 7=ChEBI, 22=PubChem
mappings = uc.get_mapping("CHEMBL25", 1) # From ChEMBL
for m in mappings[:5]:
print(f" Source {m['src_id']}: {m['src_compound_id']}")
4. Sequence Analysis (BLAST)
from bioservices import NCBIblast
import time
blast = NCBIblast(verbose=False)
sequence = ">query\nMKTAYIAKQRQISFVKSHFSRQLE..." # Truncated for brevity
job_id = blast.run(
program="blastp",
database="uniprotkb_swissprot",
sequence=sequence,
stype="protein",
email="user@example.com" # Required by NCBI
)
print(f"Job submitted: {job_id}")
# Poll for results (async)
while blast.getStatus(job_id) == "RUNNING":
time.sleep(10)
print("Waiting...")
result_types = blast.getResultTypes(job_id)
alignment = blast.getResult(job_id, "out") # Text alignment
print(alignment[:500])
5. Identifier Mapping
from bioservices import UniProt
u = UniProt(verbose=False)
# Batch mapping: UniProt → multiple databases
accessions = "P00520 P12931 P04637 P38398"
# UniProt → PDB
result = u.mapping(fr="UniProtKB_AC-ID", to="PDB", query=accessions)
for r in result.get('results', []):
print(f" {r['from']} → {r['to']}")
# UniProt → Ensembl
result = u.mapping(fr="UniProtKB_AC-ID", to="Ensembl", query=accessions)
print(f"Mapped {len(result.get('results', []))} entries to Ensembl")
# KEGG-based identifier extraction
from bioservices import KEGG
k = KEGG(verbose=False)
# Get cross-references from a KEGG entry
entry = k.get("hsa:25") # ABL1
parsed = k.parse(entry)
# Extract database links
for key in ['DBLINKS', 'PATHWAY']:
if key in parsed:
print(f"{key}: {parsed[key]}")
6. Gene Ontology & Protein Interactions
from bioservices import QuickGO
go = QuickGO(verbose=False)
# Get GO annotations for a protein
annotations = go.Annotation(geneProductId="UniProtKB:P00520", taxonId="9606")
if hasattr(annotations, 'shape'): # DataFrame
print(f"Annotations: {len(annotations)}")
for aspect in ['biological_process', 'molecular_function', 'cellular_component']:
subset = annotations[annotations['goAspect'] == aspect]
print(f" {aspect}: {len(subset)} terms")
# Protein-protein interactions via PSICQUIC
from bioservices import PSICQUIC
psicquic = PSICQUIC(verbose=False)
# Query IntAct for interactions
interactions = psicquic.query("intact", "identifier:P00520", frmt="tab25")
lines = interactions.strip().split("\n") if interactions else []
print(f"Interactions found: {len(lines)}")
for line in lines[:3]:
fields = line.split("\t")
print(f" {fields[0]} <-> {fields[1]}") # Interactor A <-> Interactor B
Key Concepts
Service Initialization & Verbosity
All services share a common pattern: Service(verbose=False) suppresses HTTP request logging. Set verbose=True during debugging to see full request/response details.
Output Format Handling
| Service | Default Format | Available Formats | Notes |
|---|---|---|---|
| UniProt | XML | tsv, fasta, json, txt, xml, gff |
Use tsv with columns= for tabular |
| KEGG | Text | kgml, image |
Parse text with k.parse() |
| ChEMBL | JSON | JSON only | Dict access on response |
| ChEBI | SOAP XML | Object attributes | Access via .chebiAsciiName etc. |
| NCBIblast | Text | out, xml, json, ids |
Async: submit → poll → retrieve |
| PSICQUIC | PSI-MI TAB | tab25, tab27, xml25, count |
Tab-separated interaction records |
| QuickGO | DataFrame | tsv, json |
Pandas DataFrame when available |
Common Organism Codes (KEGG)
| Code | Organism | Taxonomy ID |
|---|---|---|
hsa |
Homo sapiens | 9606 |
mmu |
Mus musculus | 10090 |
rno |
Rattus norvegicus | 10116 |
dme |
Drosophila melanogaster | 7227 |
sce |
Saccharomyces cerevisiae | 559292 |
eco |
Escherichia coli K-12 | 83333 |
ath |
Arabidopsis thaliana | 3702 |
UniProt Mapping Database Codes
Common database pairs for u.mapping(fr=, to=):
| Category | Database Code | Example |
|---|---|---|
| Protein | UniProtKB_AC-ID |
P00520 |
| Gene | Gene_Name, GeneID |
ABL1, 25 |
| Structure | PDB |
2HYY |
| Sequence | RefSeq_Protein, Ensembl |
NP_005148, ENSG00000097007 |
| Pathway | KEGG, Reactome |
hsa:25, R-HSA-123 |
| Ontology | GO |
GO:0006468 |
Full mapping database list: see references/identifier_mapping_guide.md.
Common Workflows
Workflow 1: Complete Protein Analysis Pipeline
from bioservices import UniProt, KEGG, QuickGO, PSICQUIC
import time
def analyze_protein(uniprot_id):
"""End-to-end protein analysis: metadata → pathways → GO → interactions."""
results = {}
# Step 1: UniProt metadata
u = UniProt(verbose=False)
entry = u.retrieve(uniprot_id, frmt="tsv",
columns="accession,gene_names,protein_name,length,organism_name")
results['uniprot'] = entry
print(f"[1] UniProt entry retrieved")
# Step 2: KEGG pathways
k = KEGG(verbose=False)
mapping = u.mapping(fr="UniProtKB_AC-ID", to="KEGG", query=uniprot_id)
kegg_ids = [r['to'] for r in mapping.get('results', [])]
if kegg_ids:
pathways = k.get_pathway_by_gene(kegg_ids[0], kegg_ids[0][:3])
results['pathways'] = pathways or {}
print(f"[2] Found {len(results['pathways'])} pathways")
time.sleep(0.3)
# Step 3: GO annotations
go = QuickGO(verbose=False)
annotations = go.Annotation(geneProductId=f"UniProtKB:{uniprot_id}")
results['go_annotations'] = annotations
print(f"[3] GO annotations retrieved")
time.sleep(0.3)
# Step 4: Protein interactions
psicquic = PSICQUIC(verbose=False)
interactions = psicquic.query("intact", f"identifier:{uniprot_id}", frmt="tab25")
lines = interactions.strip().split("\n") if interactions else []
results['interactions'] = len(lines)
print(f"[4] Found {len(lines)} interactions")
return results
# Usage
results = analyze_protein("P00520") # ABL1
Workflow 2: Cross-Database Compound Search
from bioservices import KEGG, ChEMBL, ChEBI, UniChem
import time
def search_compound(compound_name):
"""Search compound across KEGG → ChEBI → ChEMBL with cross-references."""
k = KEGG(verbose=False)
# Step 1: KEGG compound search
kegg_results = k.find("compound", compound_name)
if not kegg_results:
print(f"No KEGG results for '{compound_name}'")
return None
kegg_id = kegg_results.strip().split("\t")[0] # First hit
kegg_entry = k.parse(k.get(kegg_id))
print(f"[1] KEGG: {kegg_id} — {kegg_entry.get('NAME', ['Unknown'])[0]}")
print(f" Formula: {kegg_entry.get('FORMULA', 'N/A')}")
time.sleep(0.2)
# Step 2: ChEBI cross-reference
dblinks = kegg_entry.get('DBLINKS', {})
chebi_id = dblinks.get('ChEBI', [''])[0] if isinstance(dblinks, dict) else None
if chebi_id:
chebi = ChEBI(verbose=False)
entity = chebi.getCompleteEntity(f"CHEBI:{chebi_id}")
print(f"[2] ChEBI: {entity.chebiId} — {entity.chebiAsciiName}")
time.sleep(0.2)
# Step 3: ChEMBL via UniChem
uc = UniChem()
try:
chembl_mappings = uc.get_mapping(kegg_id.replace("cpd:", ""), 6) # 6=KEGG
if chembl_mappings:
chembl_id = [m for m in chembl_mappings if m['src_id'] == '1']
if chembl_id:
chembl = ChEMBL(verbose=False)
mol = chembl.get_molecule(chembl_id[0]['src_compound_id'])
print(f"[3] ChEMBL: {mol['molecule_chembl_id']} — MW: {mol['molecule_properties']['full_mwt']}")
except Exception as e:
print(f"[3] ChEMBL mapping failed: {e}")
return kegg_entry
result = search_compound("aspirin")
Workflow 3: Batch Identifier Conversion
from bioservices import UniProt
import time
def batch_convert_ids(ids, from_db, to_db, chunk_size=100):
"""Convert biological IDs in chunks with error handling."""
u = UniProt(verbose=False)
all_results = []
failed = []
for i in range(0, len(ids), chunk_size):
chunk = ids[i:i+chunk_size]
query = " ".join(chunk)
try:
result = u.mapping(fr=from_db, to=to_db, query=query)
mapped = result.get('results', [])
all_results.extend(mapped)
print(f" Chunk {i//chunk_size + 1}: {len(mapped)} mapped")
except Exception as e:
print(f" Chunk {i//chunk_size + 1} failed: {e}")
failed.extend(chunk)
time.sleep(1) # Rate limit between chunks
print(f"\nTotal mapped: {len(all_results)}, Failed: {len(failed)}")
return all_results, failed
# Usage: Gene names → UniProt accessions
gene_list = ["BRCA1", "TP53", "ABL1", "EGFR", "BRAF", "KRAS", "MYC", "PTEN"]
results, failed = batch_convert_ids(gene_list, "Gene_Name", "UniProtKB_AC-ID")
for r in results[:5]:
print(f" {r['from']} → {r['to']['primaryAccession']}")
Key Parameters
| Parameter | Function/Endpoint | Default | Range | Effect |
|---|---|---|---|---|
verbose |
All services | True |
bool | HTTP request logging |
frmt |
UniProt search/retrieve | xml |
tsv/fasta/json/txt | Output format |
columns |
UniProt search | — | comma-separated | TSV columns to return |
taxId |
UniProt mapping | — | NCBI taxonomy ID | Organism filter for mapping |
program |
NCBIblast | — | blastp/blastn/blastx/tblastn | BLAST program type |
database |
NCBIblast | — | uniprotkb_swissprot/nr/etc | Target database |
email |
NCBIblast | — | email address | Required by NCBI policy |
chunk_size |
Batch operations | 100 | 10-500 | IDs per batch request |
frmt |
PSICQUIC query | tab25 |
tab25/tab27/xml25/count | Interaction output format |
Best Practices
-
Always set
verbose=Falsein production — defaultTruefloods stdout with HTTP details. UseTrueonly for debugging failed requests -
Handle None/empty results gracefully — web services frequently return None, empty strings, or partial data. Always check before parsing:
result = k.get("hsa04110") if result and not isinstance(result, int): # KEGG returns 404 as int parsed = k.parse(result) -
Use explicit
time.sleep()between batch requests — even though bioservices has internal throttling, batch operations benefit from explicit delays (0.2-1.0s) to avoid 429 errors -
Cache service objects, not results — create one
UniProt()instance and reuse it across calls. Each constructor call initializes a new HTTP session -
Anti-pattern — parsing KEGG text manually: Always use
k.parse()to parse KEGG entry text into structured dicts. Manual string splitting breaks on multi-line fields -
Specify organism in UniProt searches — unfiltered searches return results across all species. Add
AND organism_id:9606for human-specific results
Common Recipes
Recipe 1: Pathway Network Statistics
from bioservices import KEGG
import xml.etree.ElementTree as ET
from collections import Counter
def pathway_stats(organism="hsa", limit=10):
"""Analyze pathway network statistics for an organism."""
k = KEGG(verbose=False)
pathway_list = k.list("pathway", organism).strip().split("\n")
stats = []
for line in pathway_list[:limit]:
pid = line.split("\t")[0]
kgml = k.get(pid, "kgml")
if not kgml or isinstance(kgml, int):
continue
root = ET.fromstring(kgml)
genes = len([e for e in root.findall("entry") if e.get("type") == "gene"])
rels = len(root.findall("relation"))
stats.append({"pathway": pid, "genes": genes, "relations": rels})
return sorted(stats, key=lambda x: x['relations'], reverse=True)
top = pathway_stats("hsa", limit=5)
for s in top:
print(f" {s['pathway']}: {s['genes']} genes, {s['relations']} interactions")
Recipe 2: GO Term Enrichment Preparation
from bioservices import QuickGO, UniProt
import time
def get_go_terms(uniprot_ids, aspect="biological_process"):
"""Collect GO annotations for a list of proteins."""
go = QuickGO(verbose=False)
all_terms = {}
for uid in uniprot_ids:
try:
annot = go.Annotation(geneProductId=f"UniProtKB:{uid}", taxonId="9606")
if hasattr(annot, 'shape') and len(annot) > 0:
subset = annot[annot['goAspect'] == aspect]
all_terms[uid] = list(subset['goId'].unique())
except Exception:
all_terms[uid] = []
time.sleep(0.3)
return all_terms
terms = get_go_terms(["P00520", "P12931", "P04637"])
for uid, go_ids in terms.items():
print(f" {uid}: {len(go_ids)} {go_ids[:3]}")
Recipe 3: Multi-Hop Identifier Mapping
from bioservices import UniProt, KEGG
import time
def gene_to_pathways(gene_name, organism="hsa"):
"""Map gene name → UniProt → KEGG → pathways (multi-hop)."""
u = UniProt(verbose=False)
# Hop 1: Gene name → UniProt
result = u.mapping(fr="Gene_Name", to="UniProtKB_AC-ID", query=gene_name, taxId=9606)
if not result.get('results'):
return None
uniprot_id = result['results'][0]['to']['primaryAccession']
print(f" {gene_name} → UniProt: {uniprot_id}")
time.sleep(0.3)
# Hop 2: UniProt → KEGG
kegg_result = u.mapping(fr="UniProtKB_AC-ID", to="KEGG", query=uniprot_id)
if not kegg_result.get('results'):
return None
kegg_id = kegg_result['results'][0]['to']
print(f" UniProt: {uniprot_id} → KEGG: {kegg_id}")
time.sleep(0.3)
# Hop 3: KEGG gene → pathways
k = KEGG(verbose=False)
pathways = k.get_pathway_by_gene(kegg_id, organism)
print(f" KEGG: {kegg_id} → {len(pathways or {})} pathways")
return pathways
pathways = gene_to_pathways("BRCA1")
if pathways:
for pid, name in list(pathways.items())[:5]:
print(f" {pid}: {name}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ConnectionError on service init |
Network/firewall blocking | Check internet; some services need port 443 |
| UniProt search returns empty | Query syntax error | Use AND/OR operators; check field names in UniProt docs |
KEGG get() returns 404 (int) |
Invalid pathway/gene ID | Verify ID format: hsa:25 (gene), hsa04110 (pathway), cpd:C00001 (compound) |
| BLAST job timeout | Large sequence or busy server | Increase polling interval; try uniprotkb_swissprot (smaller than nr) |
| UniProt mapping returns no results | Wrong database codes | Use exact codes: UniProtKB_AC-ID, Gene_Name, PDB, Ensembl (case-sensitive) |
AttributeError on ChEBI entity |
SOAP response parsing | Check entity is not None; some ChEBI IDs lack certain fields |
| PSICQUIC returns empty string | No interactions in database | Try multiple databases: intact, mint, biogrid |
429 Too Many Requests |
Rate limit exceeded | Add time.sleep(0.5) between requests; reduce batch size |
| Inconsistent mapping results | Multiple isoforms/entries | Filter by isReviewed=true for canonical UniProt entries |
Bundled Resources
references/services_catalog.md
Catalog of 20+ bioservices-accessible services organized by category: protein/gene resources (UniProt, KEGG, HGNC, MyGeneInfo), chemical compound resources (ChEBI, ChEMBL, UniChem, PubChem), sequence analysis (NCBIblast), pathway/interaction resources (Reactome, PSICQUIC, IntactComplex, OmniPath), gene ontology (QuickGO), genomic resources (BioMart, ArrayExpress, ENA), structural biology (PDB, Pfam), and specialized resources (BioModels, COG, BiGG). Includes method signatures, key parameters, and output formats for each service. General patterns (error handling, verbosity, caching) relocated to SKILL.md Best Practices. Original services_reference.md content partially relocated to Core API (UniProt, KEGG, ChEMBL, ChEBI, BLAST, PSICQUIC, QuickGO sections); remaining services retained in catalog.
references/identifier_mapping_guide.md
Comprehensive guide to cross-database identifier conversion: UniProt mapping service (100+ database pairs with database code tables), UniChem compound mapping (source database IDs), KEGG identifier conversions, 4 common mapping patterns with code, and troubleshooting. Original identifier_mapping.md content partially relocated to Core API section 5 (basic mapping examples); advanced patterns, full database code tables, and multi-hop strategies retained in guide.
Scripts disposition (4 original scripts, 1,444 lines total):
protein_analysis_workflow.py(409 lines, 7 functions: search_protein, retrieve_sequence, run_blast, discover_pathways, find_interactions, get_go_annotations, main): End-to-end pipeline → Common Workflow 1 "Complete Protein Analysis Pipeline"compound_cross_reference.py(379 lines, 7 functions: search_kegg_compound, get_kegg_info, get_chembl_id, get_chebi_info, get_chembl_info, save_results, main): Multi-DB compound search → Common Workflow 2 "Cross-Database Compound Search"batch_id_converter.py(348 lines, 8 functions: normalize_database_code, read_ids_from_file, batch_convert, save_mapping_csv, save_failed_ids, print_mapping_summary, list_common_databases, main): Batch conversion → Common Workflow 3 "Batch Identifier Conversion" + Core API section 5pathway_analysis.py(310 lines, 8 functions: get_all_pathways, analyze_pathway, analyze_all_pathways, save_pathway_summary, save_interactions_sif, save_detailed_pathway_info, print_statistics, main): KGML network analysis → Core API section 2 (KGML parsing) + Recipe 1 "Pathway Network Statistics"- CLI argument parsing, CSV/SIF file I/O, and summary printing utilities from all scripts: omitted — trivial boilerplate not essential for API understanding
Omitted original content: workflow_patterns.md (811 lines, 7 detailed workflows) — 4 workflows consolidated into Common Workflows 1-3 and Recipe 3; remaining 3 workflows (gene annotation pipeline, interaction network analysis, comparative species analysis) omitted as they combine patterns already shown in Core API modules without introducing new API calls.
Related Skills
- gget-genomic-databases — deeper Ensembl/BLAST/enrichment queries via gget (simpler API for single-database access)
- chembl-database-bioactivity — dedicated ChEMBL bioactivity queries beyond compound lookup
- pubchem-compound-search — dedicated PubChem compound/assay queries with similarity search
References
- BioServices documentation: https://bioservices.readthedocs.io/
- BioServices source: https://github.com/cokelaer/bioservices
- Cokelaer et al. (2013) BioServices: a common Python package to access biological Web Services programmatically. Bioinformatics 29(24): 3241-3242
- UniProt REST API: https://www.uniprot.org/help/api
- KEGG API: https://www.kegg.jp/kegg/rest/keggapi.html
- QuickGO API: https://www.ebi.ac.uk/QuickGO/api/index.html
skills/genomics-bioinformatics/databases/cbioportal-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cbioportal-database -g -y
SKILL.md
Frontmatter
{
"name": "cbioportal-database",
"license": "AGPL-3.0",
"description": "Cancer genomics (TCGA et al.) via cBioPortal REST API. Retrieve somatic mutations, CNAs, expression, clinical data (survival\/stage\/treatment) across thousands of studies. Use for TMB, oncoprints, survival analysis. For population frequencies use gnomad-database; for drug-gene interactions use opentargets-database."
}
cBioPortal Database
Overview
cBioPortal for Cancer Genomics is a public repository of cancer genomics data including TCGA, ICGC, and hundreds of curated studies spanning 100+ cancer types. It provides somatic mutation profiles, copy number alterations (CNA), gene expression, clinical data (survival, stage, treatment history), and methylation data for tens of thousands of patient samples. Data is accessible via a REST API at https://www.cbioportal.org/api/ with no authentication required.
When to Use
- Retrieving somatic mutation profiles (variant type, amino acid change) for a gene across TCGA studies
- Querying copy number alteration data (amplification, deep deletion) for candidate cancer driver genes
- Accessing clinical data — overall survival, disease-free survival, tumor stage — for survival curve analysis
- Identifying which cancer studies have molecular profiling data for a specific cancer type (e.g., breast, lung)
- Downloading gene expression (RNA-seq FPKM/RSEM) data from specific TCGA cohorts for differential expression analysis
- Correlating genomic alterations with clinical outcomes in a specific study
- Use
gnomad-databaseinstead when you need population-level variant allele frequencies in healthy individuals - For drug-gene interaction lookups use
opentargets-database; cBioPortal provides the genomic alteration data, not drug interaction annotations
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: Entrez gene symbols (e.g.,
TP53), cBioPortal study IDs (e.g.,tcga_brca), molecular profile IDs - Environment: internet connection; no API key required
- Rate limits: no strict rate limits; use
time.sleep(0.2)between batch requests for polite access
pip install requests pandas matplotlib
Quick Start
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
"""GET request to cBioPortal REST API, returns parsed JSON."""
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
# List available cancer types
cancer_types = cbio_get("cancer-types")
print(f"Total cancer types: {len(cancer_types)}")
# Total cancer types: 87
# Find TCGA breast cancer study
studies = cbio_get("studies", params={"keyword": "breast"})
brca = [s for s in studies if "tcga_brca" in s["studyId"]]
if brca:
s = brca[0]
print(f"Study: {s['name']}")
print(f" studyId: {s['studyId']}")
print(f" Samples: {s['allSampleCount']}")
# Study: Breast Invasive Carcinoma (TCGA, PanCancer Atlas)
# studyId: brca_tcga_pan_can_atlas_2018
# Samples: 1084
Core API
Query 1: Cancer Types and Studies
List available cancer types and find studies by cancer type or keyword.
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
# Get all cancer types
cancer_types = cbio_get("cancer-types")
ct_df = pd.DataFrame(cancer_types)[["cancerTypeId", "name", "dedicatedColor"]]
print(f"Cancer types: {len(ct_df)}")
print(ct_df.head(5).to_string(index=False))
# Find all studies for a cancer type
lung_studies = cbio_get("studies", params={"keyword": "lung adenocarcinoma"})
print(f"\nLung adenocarcinoma studies: {len(lung_studies)}")
for s in lung_studies[:3]:
print(f" {s['studyId']:40s} n={s['allSampleCount']}")
# Get detailed study metadata including available data types
study_id = "brca_tcga_pan_can_atlas_2018"
study = cbio_get(f"studies/{study_id}")
print(f"Study: {study['name']}")
print(f" Reference genome: {study.get('referenceGenome', 'n/a')}")
print(f" All sample count: {study['allSampleCount']}")
# List molecular profiles for the study
profiles = cbio_get("molecular-profiles", params={"studyId": study_id})
print(f"\nMolecular profiles ({len(profiles)} total):")
for p in profiles:
print(f" {p['molecularProfileId']:55s} [{p['molecularAlterationType']}]")
Query 2: Somatic Mutations
Retrieve mutation data for a gene or set of genes in a study's mutation profile.
import requests, json
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_post(endpoint, body):
"""POST request to cBioPortal REST API."""
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=60)
r.raise_for_status()
return r.json()
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
# Get all samples for a study
study_id = "brca_tcga_pan_can_atlas_2018"
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
print(f"Total samples: {len(sample_ids)}")
# Mutation profile ID follows pattern: {studyId}_mutations
profile_id = f"{study_id}_mutations"
# Fetch mutations for TP53 (Entrez gene ID = 7157)
body = {
"sampleIds": sample_ids[:200], # first 200 samples
"entrezGeneIds": [7157] # TP53
}
mutations = cbio_post(f"molecular-profiles/{profile_id}/mutations/fetch", body)
print(f"TP53 mutations in first 200 samples: {len(mutations)}")
# Summarize by mutation type
mut_df = pd.DataFrame(mutations)
print("\nMutation type distribution:")
print(mut_df["mutationType"].value_counts().head(8).to_string())
# Missense_Mutation 102
# Nonsense_Mutation 28
# Splice_Site 14
# Frame_Shift_Del 12
Query 3: Copy Number Alterations
Fetch discrete CNA data (amplification = 2, gain = 1, diploid = 0, loss = -1, deep deletion = -2).
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=60)
r.raise_for_status()
return r.json()
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
# CNA profile: discrete copy number data
cna_profile_id = f"{study_id}_gistic" # GISTIC-derived discrete CNA
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples][:300]
# Fetch CNA for ERBB2 (Entrez 2064) and MYC (Entrez 4609)
body = {
"sampleIds": sample_ids,
"entrezGeneIds": [2064, 4609] # ERBB2, MYC
}
cna_data = cbio_post(
f"molecular-profiles/{cna_profile_id}/molecular-data/fetch", body
)
print(f"CNA records retrieved: {len(cna_data)}")
cna_df = pd.DataFrame(cna_data)
# CNA values: 2=amplification, 1=gain, 0=diploid, -1=loss, -2=deep deletion
cna_label = {2: "AMP", 1: "GAIN", 0: "DIPLOID", -1: "LOSS", -2: "HOMDEL"}
print("\nERBB2 CNA distribution:")
erbb2 = cna_df[cna_df["entrezGeneId"] == 2064]
erbb2_counts = erbb2["value"].map(lambda x: cna_label.get(int(x), str(x))).value_counts()
print(erbb2_counts.to_string())
# DIPLOID 210
# AMP 62
# GAIN 18
# LOSS 10
Query 4: Clinical Data
Retrieve per-sample or per-patient clinical attributes including survival, tumor stage, and treatment.
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
# List available clinical attributes for this study
attrs = cbio_get(f"studies/{study_id}/clinical-attributes")
attr_df = pd.DataFrame(attrs)[["clinicalAttributeId", "displayName", "datatype", "patientAttribute"]]
print(f"Clinical attributes: {len(attr_df)}")
# Show survival-related attributes
survival_attrs = attr_df[attr_df["clinicalAttributeId"].str.contains("SURVIVAL|MONTHS|STATUS", na=False)]
print(survival_attrs[["clinicalAttributeId", "displayName"]].to_string(index=False))
# Fetch OS_STATUS and OS_MONTHS for all patients
clinical = cbio_get(f"studies/{study_id}/clinical-data",
params={"clinicalDataType": "PATIENT",
"projection": "DETAILED"})
clin_df = pd.DataFrame(clinical)
# Pivot to patient × attribute matrix
clin_pivot = clin_df.pivot_table(
index="patientId", columns="clinicalAttributeId",
values="value", aggfunc="first"
)
print(f"\nPatients: {len(clin_pivot)}")
if "OS_STATUS" in clin_pivot.columns:
print("OS status counts:")
print(clin_pivot["OS_STATUS"].value_counts().to_string())
# OS status counts:
# 0:LIVING 765
# 1:DECEASED 319
Query 5: Gene Expression Data
Retrieve mRNA expression values (RSEM or FPKM) from RNA-seq profiles.
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=60)
r.raise_for_status()
return r.json()
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
# RNA-seq profile (RSEM normalized values)
rna_profile_id = f"{study_id}_rna_seq_v2_mrna_median_normed_log2"
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples][:100]
# Fetch expression for ESR1 (Entrez 2099), ERBB2 (2064), PGR (5241)
body = {
"sampleIds": sample_ids,
"entrezGeneIds": [2099, 2064, 5241] # ESR1, ERBB2, PGR
}
expr_data = cbio_post(
f"molecular-profiles/{rna_profile_id}/molecular-data/fetch", body
)
expr_df = pd.DataFrame(expr_data)
print(f"Expression records: {len(expr_df)}")
# Pivot to gene × sample matrix
expr_pivot = expr_df.pivot_table(
index="sampleId", columns="entrezGeneId", values="value"
)
expr_pivot.columns = ["ERBB2", "ESR1", "PGR"] # rename by gene symbol
print(f"\nExpression matrix: {expr_pivot.shape}")
print(expr_pivot.describe().round(2))
Query 6: Gene Details and Batch Lookup
Look up gene metadata (symbol, Entrez ID, type) required to construct mutation and CNA queries.
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=30)
r.raise_for_status()
return r.json()
# Single gene lookup by Hugo symbol
gene = cbio_get("genes/TP53")
print(f"TP53: entrezGeneId={gene['entrezGeneId']}, type={gene['type']}")
# TP53: entrezGeneId=7157, type=protein-coding
# Batch gene lookup — convert Hugo symbols to Entrez IDs
gene_symbols = ["BRCA1", "BRCA2", "TP53", "PIK3CA", "PTEN", "KRAS", "EGFR"]
body = {"geneIds": gene_symbols, "geneIdType": "HUGO_GENE_SYMBOL"}
gene_list = cbio_post("genes/fetch", body)
gene_map = {g["hugoGeneSymbol"]: g["entrezGeneId"] for g in gene_list}
gene_df = pd.DataFrame(gene_list)[["hugoGeneSymbol", "entrezGeneId", "type"]]
print(f"\nResolved {len(gene_df)} genes:")
print(gene_df.to_string(index=False))
# hugoGeneSymbol entrezGeneId type
# BRCA1 672 protein-coding
# BRCA2 675 protein-coding
# TP53 7157 protein-coding
Query 7: Visualization — Mutation Frequency Barplot
Plot mutation frequency across TCGA studies for a cancer driver gene.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=60)
r.raise_for_status()
return r.json()
# Focus on a curated set of TCGA PanCancer Atlas studies
STUDIES = {
"brca_tcga_pan_can_atlas_2018": "BRCA",
"luad_tcga_pan_can_atlas_2018": "LUAD",
"coad_tcga_pan_can_atlas_2018": "COAD",
"prad_tcga_pan_can_atlas_2018": "PRAD",
"gbm_tcga_pan_can_atlas_2018": "GBM",
}
GENE_ENTREZ = 7157 # TP53
GENE_SYMBOL = "TP53"
rows = []
for study_id, label in STUDIES.items():
try:
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
n_total = len(sample_ids)
profile_id = f"{study_id}_mutations"
body = {"sampleIds": sample_ids, "entrezGeneIds": [GENE_ENTREZ]}
muts = cbio_post(f"molecular-profiles/{profile_id}/mutations/fetch", body)
mutated_samples = len({m["sampleId"] for m in muts})
rows.append({"study": label, "n_mutated": mutated_samples,
"n_total": n_total,
"freq": mutated_samples / n_total * 100})
time.sleep(0.2)
except Exception as e:
print(f" Skipping {study_id}: {e}")
df = pd.DataFrame(rows).sort_values("freq", ascending=True)
fig, ax = plt.subplots(figsize=(7, 4))
bars = ax.barh(df["study"], df["freq"], color="#C0392B", edgecolor="white")
ax.bar_label(bars, labels=[f"{v:.0f}% (n={n})" for v, n in zip(df["freq"], df["n_mutated"])],
padding=4, fontsize=9)
ax.set_xlabel(f"{GENE_SYMBOL} Mutation Frequency (%)")
ax.set_title(f"{GENE_SYMBOL} Somatic Mutation Frequency\nacross TCGA PanCancer Atlas Studies")
ax.set_xlim(0, df["freq"].max() * 1.3)
plt.tight_layout()
plt.savefig(f"{GENE_SYMBOL}_mutation_frequency.png", dpi=150, bbox_inches="tight")
print(f"Saved {GENE_SYMBOL}_mutation_frequency.png")
print(df[["study", "n_mutated", "n_total", "freq"]].to_string(index=False))
Key Concepts
cBioPortal Data Model
cBioPortal organizes data in a three-tier hierarchy: Cancer Studies → Molecular Profiles → Sample-level data. A single study (e.g., brca_tcga_pan_can_atlas_2018) contains multiple molecular profiles, each covering one data type. Before querying mutation or expression data, always retrieve the molecular profile list with GET /molecular-profiles?studyId={studyId} to confirm the correct profile ID.
Molecular Profile ID Conventions
| Data Type | Typical Profile ID Suffix | Alteration Type |
|---|---|---|
| Somatic mutations | _mutations |
MUTATION_EXTENDED |
| Discrete CNA (GISTIC) | _gistic |
COPY_NUMBER_ALTERATION |
| Continuous CNA (log2) | _log2CNA |
COPY_NUMBER_ALTERATION |
| RNA-seq (log2 RSEM) | _rna_seq_v2_mrna_median_normed_log2 |
MRNA_EXPRESSION |
| Methylation | _methylation_hm27 or _hm450 |
METHYLATION |
Not all studies have all profile types. Always verify with GET /molecular-profiles?studyId={studyId}.
Entrez Gene IDs
The REST API mutation and molecular data endpoints require Entrez Gene IDs (integers), not Hugo symbols. Use GET /genes/{hugoSymbol} or POST /genes/fetch to resolve symbols to IDs before batch queries.
Common Workflows
Workflow 1: Somatic Mutation Landscape for a Gene Panel
Goal: Retrieve mutations for multiple cancer driver genes across an entire TCGA study and export to CSV.
import requests, time
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=120)
r.raise_for_status()
return r.json()
study_id = "luad_tcga_pan_can_atlas_2018"
profile_id = f"{study_id}_mutations"
# Resolve gene symbols to Entrez IDs
gene_symbols = ["KRAS", "EGFR", "TP53", "BRAF", "STK11", "KEAP1", "RB1"]
gene_list = cbio_post("genes/fetch",
{"geneIds": gene_symbols, "geneIdType": "HUGO_GENE_SYMBOL"})
gene_map = {g["entrezGeneId"]: g["hugoGeneSymbol"] for g in gene_list}
entrez_ids = list(gene_map.keys())
# Fetch all samples
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
print(f"Study: {study_id} — {len(sample_ids)} samples")
# Batch mutations in chunks of 500 samples to avoid timeouts
chunk_size = 500
all_muts = []
for i in range(0, len(sample_ids), chunk_size):
chunk = sample_ids[i:i + chunk_size]
body = {"sampleIds": chunk, "entrezGeneIds": entrez_ids}
muts = cbio_post(f"molecular-profiles/{profile_id}/mutations/fetch", body)
all_muts.extend(muts)
time.sleep(0.1)
mut_df = pd.DataFrame(all_muts)
mut_df["hugoSymbol"] = mut_df["entrezGeneId"].map(gene_map)
print(f"Total mutations: {len(mut_df)}")
print("\nMutation counts per gene:")
print(mut_df.groupby("hugoSymbol")["sampleId"].nunique()
.sort_values(ascending=False).to_string())
mut_df.to_csv(f"{study_id}_driver_mutations.csv", index=False)
print(f"\nSaved: {study_id}_driver_mutations.csv")
Workflow 2: Survival Analysis — CNA Status vs. Overall Survival
Goal: Compare overall survival between patients with ERBB2 amplification vs. diploid/loss in TCGA BRCA.
import requests
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=60)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
cna_profile_id = f"{study_id}_gistic"
# Get all samples
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
# Fetch ERBB2 CNA (Entrez 2064)
cna_data = cbio_post(
f"molecular-profiles/{cna_profile_id}/molecular-data/fetch",
{"sampleIds": sample_ids, "entrezGeneIds": [2064]}
)
cna_df = pd.DataFrame(cna_data)[["sampleId", "value"]].rename(columns={"value": "erbb2_cna"})
cna_df["erbb2_cna"] = cna_df["erbb2_cna"].astype(int)
cna_df["erbb2_status"] = cna_df["erbb2_cna"].map(
{2: "Amplified", 1: "Gain", 0: "Diploid", -1: "Loss", -2: "Deep Deletion"})
# Fetch clinical data (OS_STATUS, OS_MONTHS)
clinical = cbio_get(f"studies/{study_id}/clinical-data",
params={"clinicalDataType": "PATIENT", "projection": "DETAILED"})
clin_df = pd.DataFrame(clinical)
clin_pivot = clin_df.pivot_table(
index="patientId", columns="clinicalAttributeId", values="value", aggfunc="first"
).reset_index()
# Map samples to patients
sample_patient = cbio_get(f"studies/{study_id}/samples", params={"projection": "DETAILED"})
sp_df = pd.DataFrame(sample_patient)[["sampleId", "patientId"]]
# Merge CNA + clinical via patient ID
merged = (cna_df
.merge(sp_df, on="sampleId")
.merge(clin_pivot[["patientId", "OS_STATUS", "OS_MONTHS"]],
on="patientId", how="inner"))
merged = merged.dropna(subset=["OS_STATUS", "OS_MONTHS"])
merged["OS_MONTHS"] = pd.to_numeric(merged["OS_MONTHS"], errors="coerce")
merged["event"] = (merged["OS_STATUS"] == "1:DECEASED").astype(int)
# Simple Kaplan-Meier-style plot (manual step function)
def km_curve(df, time_col="OS_MONTHS"):
times = sorted(df[time_col].dropna().values)
surv = []
s = 1.0
n = len(times)
for i, t in enumerate(times):
s *= (1 - 1 / (n - i))
surv.append((t, s))
return surv
fig, ax = plt.subplots(figsize=(8, 5))
colors = {"Amplified": "#C0392B", "Diploid": "#2980B9"}
for status, color in colors.items():
grp = merged[merged["erbb2_status"] == status]
if len(grp) < 10:
continue
km = km_curve(grp)
times = [0] + [x[0] for x in km]
surv = [1.0] + [x[1] for x in km]
ax.step(times, surv, where="post", color=color,
label=f"ERBB2 {status} (n={len(grp)})", lw=2)
ax.set_xlabel("Overall Survival (months)")
ax.set_ylabel("Survival Probability")
ax.set_title("ERBB2 CNA Status vs. Overall Survival\nTCGA BRCA (PanCancer Atlas)")
ax.legend()
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("erbb2_survival.png", dpi=150, bbox_inches="tight")
print(f"Saved erbb2_survival.png")
print(f"ERBB2 Amplified: {(merged['erbb2_status']=='Amplified').sum()} samples")
print(f"ERBB2 Diploid: {(merged['erbb2_status']=='Diploid').sum()} samples")
Workflow 3: Multi-Study Alteration Frequency Heatmap
Goal: Build a gene × cancer-type alteration frequency matrix across TCGA studies.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=90)
r.raise_for_status()
return r.json()
STUDIES = {
"brca_tcga_pan_can_atlas_2018": "BRCA",
"luad_tcga_pan_can_atlas_2018": "LUAD",
"coad_tcga_pan_can_atlas_2018": "COAD",
"gbm_tcga_pan_can_atlas_2018": "GBM",
}
GENE_SYMBOLS = ["TP53", "KRAS", "PIK3CA", "EGFR", "PTEN"]
# Resolve genes
gene_list = cbio_post("genes/fetch",
{"geneIds": GENE_SYMBOLS, "geneIdType": "HUGO_GENE_SYMBOL"})
gene_map = {g["entrezGeneId"]: g["hugoGeneSymbol"] for g in gene_list}
entrez_ids = list(gene_map.keys())
freq_matrix = pd.DataFrame(index=GENE_SYMBOLS, columns=list(STUDIES.values()), dtype=float)
for study_id, label in STUDIES.items():
try:
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
n_total = len(sample_ids)
profile_id = f"{study_id}_mutations"
body = {"sampleIds": sample_ids, "entrezGeneIds": entrez_ids}
muts = cbio_post(f"molecular-profiles/{profile_id}/mutations/fetch", body)
mut_df = pd.DataFrame(muts) if muts else pd.DataFrame()
for eid, symbol in gene_map.items():
if mut_df.empty:
freq_matrix.loc[symbol, label] = 0.0
else:
n_mut = mut_df[mut_df["entrezGeneId"] == eid]["sampleId"].nunique()
freq_matrix.loc[symbol, label] = n_mut / n_total * 100
time.sleep(0.2)
except Exception as e:
print(f" {label}: {e}")
freq_matrix = freq_matrix.fillna(0).astype(float)
fig, ax = plt.subplots(figsize=(7, 4))
im = ax.imshow(freq_matrix.values, cmap="YlOrRd", aspect="auto", vmin=0, vmax=80)
ax.set_xticks(range(len(freq_matrix.columns)))
ax.set_xticklabels(freq_matrix.columns, rotation=30, ha="right")
ax.set_yticks(range(len(freq_matrix.index)))
ax.set_yticklabels(freq_matrix.index)
for i in range(len(freq_matrix.index)):
for j in range(len(freq_matrix.columns)):
val = freq_matrix.iloc[i, j]
ax.text(j, i, f"{val:.0f}%", ha="center", va="center", fontsize=9,
color="white" if val > 40 else "black")
plt.colorbar(im, ax=ax, label="Mutation Frequency (%)")
ax.set_title("Somatic Mutation Frequency — TCGA PanCancer Atlas")
plt.tight_layout()
plt.savefig("mutation_frequency_heatmap.png", dpi=150, bbox_inches="tight")
print("Saved mutation_frequency_heatmap.png")
print(freq_matrix.to_string())
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
studyId |
All study endpoints | — | any valid cBioPortal study ID | Selects the cancer study |
molecularProfileId |
mutations/fetch, molecular-data/fetch | — | {studyId}_mutations, {studyId}_gistic, etc. |
Selects the data type profile |
entrezGeneIds |
mutations/fetch, molecular-data/fetch | — | list of integer Entrez IDs | Genes to query; use POST /genes/fetch to resolve symbols |
sampleIds |
mutations/fetch, molecular-data/fetch | — | list of sample ID strings | Samples to retrieve; use GET /studies/{id}/samples for all |
clinicalDataType |
clinical-data | "SAMPLE" |
"SAMPLE", "PATIENT" |
Whether to return sample-level or patient-level clinical attributes |
projection |
samples, clinical-data | "SUMMARY" |
"ID", "SUMMARY", "DETAILED", "META" |
Response verbosity; "ID" fastest for ID-only fetches |
keyword |
studies | "" |
free text | Filter studies by name/cancer type keyword |
Best Practices
-
Fetch sample IDs before data queries: All mutation and molecular data endpoints require explicit
sampleIds. Retrieve them withGET /studies/{studyId}/samples?projection=IDbefore each query. -
Verify profile IDs from the API: Profile IDs are not guaranteed to follow the
_mutations/_gisticpattern in every study. Always confirm withGET /molecular-profiles?studyId={studyId}rather than guessing. -
Chunk large sample sets: The API can time out on requests with thousands of sample IDs. Batch requests in chunks of 500 samples with
time.sleep(0.1)between chunks. -
Use Entrez IDs, not Hugo symbols, in data fetch endpoints: The mutation and molecular data endpoints accept
entrezGeneIds(integers). Resolve symbols first withPOST /genes/fetch. -
Don't hard-code Entrez IDs: Gene IDs can be looked up dynamically via the API. Hard-coded IDs become incorrect if the gene model changes. Use
POST /genes/fetchto resolve gene symbols at runtime.
Common Recipes
Recipe: List All Molecular Profiles for a Study
When to use: Before running any data query — verify which profile IDs are available.
import requests
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
profiles = cbio_get("molecular-profiles", params={"studyId": study_id})
for p in profiles:
print(f"{p['molecularProfileId']:55s} {p['molecularAlterationType']}")
# brca_tcga_pan_can_atlas_2018_mutations MUTATION_EXTENDED
# brca_tcga_pan_can_atlas_2018_gistic COPY_NUMBER_ALTERATION
# brca_tcga_pan_can_atlas_2018_log2CNA COPY_NUMBER_ALTERATION
# brca_tcga_pan_can_atlas_2018_rna_seq_v2_mrna_median_normed_log2 MRNA_EXPRESSION
Recipe: Download Full Mutation MAF for a Study
When to use: Export all somatic mutations from a study into MAF-compatible format for downstream analysis.
import requests, time
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=60)
r.raise_for_status()
return r.json()
def cbio_post(endpoint, body):
r = requests.post(f"{BASE_URL}/{endpoint}", json=body,
headers={"Accept": "application/json",
"Content-Type": "application/json"},
timeout=120)
r.raise_for_status()
return r.json()
study_id = "coad_tcga_pan_can_atlas_2018"
profile_id = f"{study_id}_mutations"
samples = cbio_get(f"studies/{study_id}/samples", params={"projection": "ID"})
sample_ids = [s["sampleId"] for s in samples]
all_mutations = []
for i in range(0, len(sample_ids), 300):
chunk = sample_ids[i:i + 300]
muts = cbio_post(f"molecular-profiles/{profile_id}/mutations/fetch",
{"sampleIds": chunk, "entrezGeneIds": []}) # empty = all genes
all_mutations.extend(muts)
time.sleep(0.1)
mut_df = pd.DataFrame(all_mutations)
cols = ["hugoGeneSymbol", "sampleId", "chr", "startPosition", "endPosition",
"referenceAllele", "variantAllele", "mutationType",
"proteinChange", "variantType"]
available = [c for c in cols if c in mut_df.columns]
mut_df[available].to_csv(f"{study_id}_mutations.csv", index=False)
print(f"Saved {len(mut_df)} mutations → {study_id}_mutations.csv")
Recipe: Query Patient-Level Clinical Attribute
When to use: Extract a specific clinical variable (e.g., tumor stage, age at diagnosis) for all patients.
import requests
import pandas as pd
BASE_URL = "https://www.cbioportal.org/api"
def cbio_get(endpoint, params=None):
r = requests.get(f"{BASE_URL}/{endpoint}", params=params,
headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
return r.json()
study_id = "brca_tcga_pan_can_atlas_2018"
# Fetch a specific clinical attribute for all patients
attr_id = "TUMOR_STAGE"
clinical = cbio_get(f"studies/{study_id}/clinical-data",
params={"clinicalDataType": "PATIENT",
"projection": "DETAILED"})
clin_df = pd.DataFrame(clinical)
if "clinicalAttributeId" in clin_df.columns:
stage_df = clin_df[clin_df["clinicalAttributeId"] == attr_id][["patientId", "value"]]
print(f"Patients with {attr_id} annotation: {len(stage_df)}")
print(stage_df["value"].value_counts().head(10).to_string())
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found on profile endpoint |
Molecular profile does not exist for study | List profiles with GET /molecular-profiles?studyId={id}; confirm the profile ID |
| Empty mutations list | Gene has no mutations in the selected samples/profile | Verify study has a mutation profile; check sample IDs belong to the same study |
requests.exceptions.Timeout |
Large sample set (>1000) in a single request | Chunk requests to 300–500 samples; increase timeout to 120s |
entrezGeneIds key error in response |
Hugo symbol passed instead of Entrez ID | Use POST /genes/fetch to resolve symbols to integer Entrez IDs first |
| CNA values returned as strings | value field is string in JSON |
Cast with pd.to_numeric() or int(value) before comparison |
| Expression profile not found | Study uses non-standard profile naming | Check profile list; look for MRNA_EXPRESSION alteration type in GET /molecular-profiles |
| Survival analysis has many NA values | Clinical attribute absent for some patients | Use dropna() on OS columns; check attribute availability with GET /studies/{id}/clinical-attributes |
Related Skills
gnomad-database— population variant allele frequencies for healthy cohorts (complement to cBioPortal somatic data)cnvkit-copy-number— CNVkit pipeline for generating SEG/CNA files that can be loaded into cBioPortalpydeseq2-differential-expression— differential expression analysis that can be applied to cBioPortal RNA-seq exports
References
- cBioPortal API Swagger UI — interactive REST API explorer with all endpoints
- cBioPortal GitHub — source code and issue tracker
- Cerami E et al., Cancer Discovery 2012 — original cBioPortal paper
- Gao J et al., Science Signaling 2013 — integrative analysis methods using cBioPortal
- TCGA PanCancer Atlas — primary data source for PanCancer Atlas studies
skills/genomics-bioinformatics/databases/clinvar-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill clinvar-database -g -y
SKILL.md
Frontmatter
{
"name": "clinvar-database",
"license": "CC0-1.0",
"description": "Query NCBI ClinVar via E-utilities for variant clinical significance, pathogenicity, disease associations. Search by gene\/rsID\/condition\/review status; returns ClinSig, submitter data, conditions, HGVS. For GWAS use gwas-database; for variant consequence prediction use Ensembl VEP."
}
ClinVar Clinical Variants Database
Overview
ClinVar is NCBI's public archive of interpretations of variants submitted by clinical laboratories, researchers, and expert panels. It contains 2M+ variants with clinical significance classifications (Pathogenic, Likely Pathogenic, VUS, Likely Benign, Benign) for over 6,000 conditions. Access is free and requires no authentication via NCBI E-utilities.
When to Use
- Checking whether a specific variant (rsID, HGVS, or genomic position) has a clinical significance classification
- Retrieving all pathogenic/likely-pathogenic variants in a gene of interest
- Identifying conflicting interpretations between submitting laboratories
- Pulling condition/phenotype associations for a variant (MIM, MeSH, HPO terms)
- Building variant filtering pipelines that prioritize clinically actionable variants
- For somatic cancer variants, also check
cosmic-database; for GWAS associations usegwas-database
Prerequisites
- Python packages:
requests,xml.etree.ElementTree(stdlib) - Data requirements: gene symbols, rsIDs, HGVS strings, or ClinVar Variation IDs
- Environment: internet connection; NCBI Entrez email required (set
emailparameter) - Rate limits: 3 requests/second unauthenticated; 10/second with API key (free at https://www.ncbi.nlm.nih.gov/account/)
pip install requests
# No additional packages required; xml.etree is part of Python stdlib
Quick Start
import requests
EMAIL = "your@email.com" # required by NCBI policy
def clinvar_search(query, retmax=10):
"""Search ClinVar and return a list of ClinVar Variation IDs."""
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "clinvar", "term": query, "retmax": retmax,
"retmode": "json", "email": EMAIL}
)
r.raise_for_status()
return r.json()["esearchresult"]["idlist"]
# Find pathogenic BRCA1 variants
ids = clinvar_search("BRCA1[gene] AND pathogenic[clinsig]", retmax=5)
print(f"Found variation IDs: {ids}")
Core API
Query 1: Search Variants by Gene and Clinical Significance
Use ESearch to find ClinVar Variation IDs matching a structured query.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def esearch(query, retmax=200):
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "clinvar", "term": query,
"retmax": retmax, "retmode": "json", "email": EMAIL})
r.raise_for_status()
result = r.json()["esearchresult"]
return result["idlist"], int(result["count"])
# Gene-specific pathogenic variants
ids, total = esearch("BRCA2[gene] AND (pathogenic[clinsig] OR likely pathogenic[clinsig])")
print(f"Pathogenic/LP BRCA2 variants: {total} total, retrieved {len(ids)}")
print(f"First 5 IDs: {ids[:5]}")
# By rsID
ids, _ = esearch("rs80357906[rs]")
print(f"Variant IDs for rs80357906: {ids}")
# By condition name
ids, total = esearch("breast cancer[dis] AND pathogenic[clinsig]")
print(f"Pathogenic variants for breast cancer: {total}")
Query 2: Fetch Variant Summary Records
Retrieve structured summary data (JSON) for a list of Variation IDs.
import requests, json
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def esummary(ids):
"""Fetch ESummary records for a list of ClinVar variation IDs."""
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "clinvar", "id": ",".join(ids),
"retmode": "json", "email": EMAIL})
r.raise_for_status()
return r.json()["result"]
ids, _ = esearch_func = lambda q: requests.get(
f"{BASE}/esearch.fcgi",
params={"db": "clinvar", "term": q, "retmax": 5, "retmode": "json", "email": EMAIL}
).json()["esearchresult"]["idlist"]
# Manual example with known IDs
sample_ids = ["12375", "17684", "54270"]
result = esummary(sample_ids)
for vid in result.get("uids", []):
rec = result[vid]
# ClinVar 2024 schema: clinical_significance was replaced by germline_classification
# (also: clinical_impact_classification, oncogenicity_classification — same shape, often empty)
gc = rec.get("germline_classification", {})
print(f"\nVariation {vid}: {rec.get('title')}")
print(f" ClinSig : {gc.get('description')}")
print(f" Review : {gc.get('review_status')}")
print(f" Gene : {rec.get('genes', [{}])[0].get('symbol')}")
Query 3: Fetch Full XML Records
Retrieve the complete variant record in XML for detailed submitter and condition data.
import requests
import xml.etree.ElementTree as ET
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def efetch_xml(variation_ids):
# ClinVar 2024 XML overhaul: "clinvarset" rettype returns an empty stub.
# Use rettype="vcv" + is_variationid="true" to get the new <VariationArchive> records.
r = requests.post(f"{BASE}/efetch.fcgi",
data={"db": "clinvar", "id": ",".join(variation_ids),
"rettype": "vcv", "is_variationid": "true",
"retmode": "xml", "email": EMAIL})
r.raise_for_status()
return ET.fromstring(r.text)
root = efetch_xml(["17677"]) # BRCA1 c.5266dupC (rs80357906)
# Aggregate (germline) classification — one per VariationArchive
for va in root.iter("VariationArchive"):
name = va.get("VariationName")
gc = va.find("./ClassifiedRecord/Classifications/GermlineClassification")
desc = gc.find("Description") if gc is not None else None
rstat = gc.find("ReviewStatus") if gc is not None else None
print(f"{name}: {desc.text if desc is not None else 'n/a'} "
f"({rstat.text if rstat is not None else 'n/a'})")
# Per-submitter assertions
for ca in va.iter("ClinicalAssertion"):
acc = ca.find("ClinVarAccession")
cls = ca.find("Classification/GermlineClassification")
if acc is not None and cls is not None:
print(f" {acc.get('SubmitterName', '?')}: {cls.text}")
Query 4: ClinVar FTP Bulk Data
For large-scale queries, download and parse the full variant summary file.
import urllib.request
import gzip, csv, io
# Full summary (tab-separated, ~300 MB compressed)
URL = "https://ftp.ncbi.nlm.nih.gov/pub/clinvar/tab_delimited/variant_summary.txt.gz"
# Stream and parse without full download
with urllib.request.urlopen(URL) as resp:
with gzip.open(resp, "rt", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter="\t")
pathogenic_brca1 = []
for row in reader:
if row["GeneSymbol"] == "BRCA1" and "Pathogenic" in row["ClinicalSignificance"]:
pathogenic_brca1.append({
"name": row["Name"],
"clinsig": row["ClinicalSignificance"],
"condition": row["PhenotypeList"],
"rsid": row["RS# (dbSNP)"],
})
print(f"Pathogenic BRCA1 variants: {len(pathogenic_brca1)}")
for v in pathogenic_brca1[:3]:
print(f" {v['name']} | {v['clinsig']} | rs{v['rsid']}")
Query 5: Review Status and Conflicting Interpretations
Filter variants by review status (evidence quality) and find conflicts.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
# Stars correspond to review levels:
# 0 = no assertion criteria, 1 = criteria provided (single),
# 2 = criteria provided (multiple), 3 = expert panel, 4 = practice guideline
def search_by_review_stars(gene, min_stars=2):
"""Search for variants with at least min_stars review status."""
star_terms = {1: "criteria provided, single submitter",
2: "criteria provided, multiple submitters, no conflicts",
3: "reviewed by expert panel",
4: "practice guideline"}
terms = [f'"{star_terms[s]}"[review status]' for s in range(min_stars, 5) if s in star_terms]
query = f"{gene}[gene] AND (" + " OR ".join(terms) + ")"
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "clinvar", "term": query, "retmax": 100,
"retmode": "json", "email": EMAIL})
return r.json()["esearchresult"]
result = search_by_review_stars("BRCA1", min_stars=3)
print(f"Expert-reviewed BRCA1 variants: {result['count']}")
Query 6: Variant-to-Condition Mapping
Extract condition (phenotype) data from ClinVar records.
import requests, json
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def get_conditions(variation_ids):
"""Return condition data for a list of ClinVar variation IDs."""
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "clinvar", "id": ",".join(variation_ids),
"retmode": "json", "email": EMAIL})
r.raise_for_status()
result = r.json()["result"]
conditions = {}
for vid in result.get("uids", []):
rec = result[vid]
# trait_set moved under germline_classification in the 2024 ClinVar JSON
trait_set = rec.get("germline_classification", {}).get("trait_set", [])
conditions[vid] = [t.get("trait_name") for t in trait_set]
return conditions
sample_ids = ["12375", "17684", "54270"]
cond_map = get_conditions(sample_ids)
for vid, conds in cond_map.items():
print(f"Variation {vid}: {', '.join(conds)}")
Key Concepts
ClinVar Variation ID vs. rsID
ClinVar assigns its own stable Variation ID (integer) to each interpreted variant record. This differs from dbSNP rsIDs. A single rsID can correspond to multiple ClinVar Variation IDs if different alleles or interpretations are submitted separately.
Review Stars and Evidence Quality
ClinVar's "review status" encodes the level of evidence:
- 0 stars: No assertion criteria provided
- 1 star: Criteria provided, single submitter
- 2 stars: Multiple submitters, no conflict
- 3 stars: Reviewed by expert panel (e.g., ENIGMA, ClinGen)
- 4 stars: Practice guideline
Common Workflows
Workflow 1: Gene Pathogenicity Report
Goal: Retrieve all high-confidence pathogenic variants in a gene and export to CSV.
import requests, json, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def search_gene_pathogenic(gene, clinsig="pathogenic"):
query = f"{gene}[gene] AND {clinsig}[clinsig]"
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "clinvar", "term": query, "retmax": 500,
"retmode": "json", "email": EMAIL})
return r.json()["esearchresult"]["idlist"]
def fetch_summaries(ids):
records = []
for i in range(0, len(ids), 100):
batch = ids[i:i+100]
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "clinvar", "id": ",".join(batch),
"retmode": "json", "email": EMAIL})
result = r.json()["result"]
for vid in result.get("uids", []):
rec = result[vid]
# ClinVar 2024 schema: clinical_significance → germline_classification; trait_set nested inside it
gc = rec.get("germline_classification", {})
records.append({
"variation_id": vid,
"name": rec.get("title"),
"clinsig": gc.get("description"),
"review_status": gc.get("review_status"),
"gene": ",".join(g.get("symbol", "") for g in rec.get("genes", [])),
"conditions": "; ".join(t.get("trait_name", "") for t in gc.get("trait_set", [])),
})
time.sleep(0.15)
return records
gene = "BRCA1"
ids = search_gene_pathogenic(gene)
print(f"Found {len(ids)} pathogenic variants in {gene}")
records = fetch_summaries(ids)
df = pd.DataFrame(records)
df.to_csv(f"{gene}_pathogenic_variants.csv", index=False)
print(f"Saved {len(df)} records → {gene}_pathogenic_variants.csv")
print(df[["name", "clinsig", "review_status"]].head())
Workflow 2: Variant Classification Check
Goal: Check ClinVar status for a list of user-provided rsIDs or HGVS notations.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
variants = ["rs80357906", "rs80357220", "rs28897672"]
results = []
for rsid in variants:
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "clinvar", "term": f"{rsid}[rs]",
"retmax": 5, "retmode": "json", "email": EMAIL})
ids = r.json()["esearchresult"]["idlist"]
if not ids:
results.append({"rsid": rsid, "variation_id": None, "clinsig": "Not in ClinVar"})
continue
r2 = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "clinvar", "id": ",".join(ids[:1]),
"retmode": "json", "email": EMAIL})
rec = r2.json()["result"][ids[0]]
gc = rec.get("germline_classification", {}) # 2024 ClinVar JSON
results.append({
"rsid": rsid,
"variation_id": ids[0],
"clinsig": gc.get("description", "Unknown"),
"review_status": gc.get("review_status"),
})
time.sleep(0.15)
df = pd.DataFrame(results)
print(df.to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
retmax |
ESearch | 20 |
1–10000 |
Max records returned per query |
retmode |
ESearch/ESummary | "xml" |
"json", "xml" |
Response format |
rettype |
EFetch | "vcv" |
"vcv" |
Record type for XML fetch (legacy clinvarset returns empty stub since 2024) |
is_variationid |
EFetch | "false" |
"true"/"false" |
Set to "true" when fetching by ClinVar Variation ID with rettype=vcv |
clinsig query field |
ESearch | — | "pathogenic", "likely pathogenic", "VUS" |
Filter by clinical significance |
review status query field |
ESearch | — | 0–4 star terms | Filter by evidence quality |
email |
All | required | valid email | NCBI policy; prevents blocking |
Best Practices
-
Always set
email: NCBI requires an email in all E-utility calls for rate-limit attribution and policy compliance. -
Use FTP bulk download for large queries: For more than ~1000 variants, download
variant_summary.txt.gzfrom the ClinVar FTP rather than looping over EFetch — it's faster and avoids rate limits. -
Filter by review status: Automated pipelines should filter to ≥2-star variants to reduce noise from single-submitter assertions without peer review.
-
Use API key for production: Register at https://www.ncbi.nlm.nih.gov/account/ to get a free API key (
api_keyparameter) and triple your rate limit (3 → 10 req/s). -
Handle VUS separately: "Conflicting interpretations of pathogenicity" is its own ClinSig category — don't combine it with "VUS" in filters; they have different implications for clinical decision-making.
Common Recipes
Recipe: Check if rsID Is in ClinVar
When to use: Quick lookup for a single known variant.
import requests
EMAIL = "your@email.com"
rsid = "rs80357906"
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "clinvar", "term": f"{rsid}[rs]",
"retmax": 1, "retmode": "json", "email": EMAIL}
)
count = int(r.json()["esearchresult"]["count"])
print(f"{rsid}: {'found' if count else 'NOT'} in ClinVar ({count} records)")
Recipe: Download Variant Summary TSV
When to use: Bulk analysis — load entire ClinVar into a pandas DataFrame.
import pandas as pd
url = "https://ftp.ncbi.nlm.nih.gov/pub/clinvar/tab_delimited/variant_summary.txt.gz"
# Only human GRCh38 pathogenic variants
df = pd.read_csv(url, sep="\t", compression="gzip",
usecols=["#AlleleID", "Name", "GeneSymbol", "ClinicalSignificance",
"ReviewStatus", "PhenotypeList", "Assembly", "RS# (dbSNP)"])
df = df[(df["Assembly"] == "GRCh38") & (df["ClinicalSignificance"].str.contains("Pathogenic", na=False))]
print(f"Pathogenic variants (GRCh38): {len(df)}")
df.to_csv("clinvar_pathogenic_grch38.csv", index=False)
Recipe: Search by OMIM Disease ID
When to use: Find all ClinVar variants associated with a specific OMIM condition.
import requests
EMAIL = "your@email.com"
omim_id = "604370" # BRCA1-associated breast-ovarian cancer
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "clinvar", "term": f"{omim_id}[MIM]",
"retmax": 20, "retmode": "json", "email": EMAIL}
)
result = r.json()["esearchresult"]
print(f"Variants for OMIM {omim_id}: {result['count']} total")
print(f"First IDs: {result['idlist'][:5]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 429 or no response |
Rate limit exceeded | Add time.sleep(0.35) between requests; use API key |
Empty idlist for rsID query |
rsID not indexed in ClinVar | Try HGVS notation or gene+position query instead |
Missing clinsig in summary |
Variant has no interpretation | Check review_status; "no interpretation for the single variant" means no ClinSig yet |
| XML parse error in EFetch | Incomplete response (timeout) | Set requests.get(..., timeout=30) and retry once |
<ClinVarResult-Set><set/></ClinVarResult-Set> empty stub |
Using legacy rettype="clinvarset" (deprecated in 2024) |
Switch to rettype="vcv" + is_variationid="true"; parse <VariationArchive> root |
KeyError: clinical_significance in ESummary parsing |
Field renamed in 2024 ClinVar JSON | Use rec["germline_classification"] (also clinical_impact_classification, oncogenicity_classification); trait_set now nested inside germline_classification |
| Conflicting results for same rsID | Multiple submissions with different interpretations | Group by review_status and prefer higher-star entries |
| FTP download fails | Large file / slow connection | Use pandas.read_csv with chunksize=100000 or pre-filter with grep |
Related Skills
gwas-database— GWAS Catalog for population-level SNP-trait associations (complement to ClinVar's clinical assertions)ensembl-database— Ensembl VEP for predicting variant consequences without requiring prior clinical curationcosmic-database— Somatic cancer variant database (complementary to ClinVar's germline focus)pubmed-database— Retrieve supporting publications cited in ClinVar submissions
References
- ClinVar official site — Browse and download ClinVar data
- NCBI E-utilities documentation — Full E-utilities API reference
- ClinVar FTP downloads — Bulk data files (variant_summary.txt.gz, etc.)
- ClinVar data model — Understanding review status stars and ClinSig categories
skills/genomics-bioinformatics/databases/cosmic-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cosmic-database -g -y
SKILL.md
Frontmatter
{
"name": "cosmic-database",
"license": "CC-BY-NC-SA-4.0",
"description": "Query COSMIC for cancer somatic mutations, gene census, mutational signatures, drug resistance variants. REST API v3.1 supports gene\/sample\/variant queries; free registration. For germline use clinvar-database; for drug-target data use opentargets-database or chembl-database-bioactivity."
}
COSMIC Somatic Cancer Mutations Database
Overview
COSMIC (Catalogue Of Somatic Mutations In Cancer) is the world's largest expert-curated database of somatic mutations in cancer, covering 6.7M+ coding mutations, 40,000+ cancer samples, 19,000+ genes across all cancer types. It includes the Cancer Gene Census (critical cancer genes), mutational signatures (SBS, DBS, ID), drug resistance variants, copy number data, gene expression, and methylation. The REST API v3.1 enables programmatic queries; most features are freely accessible after registration.
When to Use
- Checking whether a specific somatic variant in a cancer gene is annotated in COSMIC (frequency, cancer type distribution)
- Retrieving all somatic mutations in a gene of interest across COSMIC cancer samples
- Accessing COSMIC Cancer Gene Census classifications (Tier 1/2, role: oncogene/TSG/fusion)
- Looking up mutational signature attributions for samples or cancer types
- Identifying drug resistance variants (pharmacogenomic data) from COSMIC drug resistance database
- Building cancer driver gene lists for bioinformatic pipelines
- For germline/inherited variants use
clinvar-database; for drug-target associations useopentargets-database
Prerequisites
- Python packages:
requests,pandas - Data requirements: gene symbols (HGNC), COSMIC mutation IDs (COSM), sample IDs, or genomic coordinates
- Environment: internet connection; free account registration at https://cancer.sanger.ac.uk/cosmic/register
- Rate limits: authenticated requests only; 10 requests/second max; API key required
pip install requests pandas
# Register at https://cancer.sanger.ac.uk/cosmic/register to obtain API credentials
Quick Start
import requests
import base64
# COSMIC API requires base64-encoded email:password authentication
EMAIL = "your_registered@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
# Get mutations for KRAS gene
r = requests.get(f"{BASE}/mutations",
headers=HEADERS,
params={"gene_name": "KRAS", "limit": 5})
r.raise_for_status()
data = r.json()
print(f"Total KRAS mutations: {data['meta']['total']}")
for m in data["data"][:3]:
print(f" {m['mutation_id']:15s} AA: {m.get('mutation_aa')} | Cancer: {m.get('primary_site')}")
Core API
Query 1: Gene Mutations Search
Retrieve all COSMIC somatic mutations for a gene, with cancer type and amino acid change.
import requests, base64, pandas as pd
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
def get_gene_mutations(gene, limit=100, cancer_site=None):
params = {"gene_name": gene, "limit": limit}
if cancer_site:
params["primary_site"] = cancer_site
r = requests.get(f"{BASE}/mutations", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
data = get_gene_mutations("TP53", limit=20)
print(f"Total TP53 mutations in COSMIC: {data['meta']['total']}")
rows = []
for m in data["data"][:10]:
rows.append({
"mutation_id": m.get("mutation_id"),
"mutation_aa": m.get("mutation_aa"),
"mutation_cds": m.get("mutation_cds"),
"primary_site": m.get("primary_site"),
"histology": m.get("primary_histology"),
"count": m.get("count"),
})
df = pd.DataFrame(rows)
print(df.head())
# Filter by cancer site
data_lung = get_gene_mutations("TP53", cancer_site="lung", limit=20)
print(f"\nTP53 mutations in lung cancer: {data_lung['meta']['total']}")
Query 2: Cancer Gene Census
Retrieve the COSMIC Cancer Gene Census — classified cancer driver genes.
import requests, base64, pandas as pd
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
r = requests.get(f"{BASE}/genes", headers=HEADERS, params={"limit": 100})
r.raise_for_status()
data = r.json()
print(f"Total genes in COSMIC: {data['meta']['total']}")
# Get Cancer Gene Census genes
r_cgc = requests.get(f"{BASE}/genes",
headers=HEADERS,
params={"cgc_tier": "1", "limit": 50})
cgc_data = r_cgc.json()
print(f"\nCGC Tier 1 genes: {cgc_data['meta']['total']}")
rows = []
for g in cgc_data["data"][:15]:
rows.append({
"gene": g.get("gene_name"),
"tier": g.get("cgc_tier"),
"role": g.get("role_in_cancer"),
"mutation_types": g.get("mutation_types"),
"tumour_types": str(g.get("tumour_types_somatic", []))[:80],
})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
Query 3: Specific Mutation Lookup
Retrieve details for a known COSMIC mutation ID (COSM…).
import requests, base64
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
# KRAS G12D mutation
mutation_id = "COSM521"
r = requests.get(f"{BASE}/mutations/{mutation_id}", headers=HEADERS)
r.raise_for_status()
m = r.json()
print(f"Mutation ID : {m.get('mutation_id')}")
print(f"Gene : {m.get('gene_name')}")
print(f"AA change : {m.get('mutation_aa')}")
print(f"CDS change : {m.get('mutation_cds')}")
print(f"Substitution: {m.get('mutation_description')}")
print(f"Count : {m.get('count')} samples")
print(f"Cancer types: {str(m.get('cancer_types', []))[:100]}")
Query 4: Sample-Level Mutation Data
Retrieve all somatic mutations for a specific cancer sample.
import requests, base64, pandas as pd
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
# Search for a specific sample
r = requests.get(f"{BASE}/samples",
headers=HEADERS,
params={"primary_site": "breast", "limit": 5})
r.raise_for_status()
samples = r.json()["data"]
print(f"Example breast cancer samples:")
for s in samples[:3]:
print(f" {s.get('sample_id')}: {s.get('sample_name')} | {s.get('primary_histology')}")
# Get mutations for a specific sample
if samples:
sample_id = samples[0]["sample_id"]
r2 = requests.get(f"{BASE}/samples/{sample_id}/mutations", headers=HEADERS)
if r2.ok:
muts = r2.json()["data"]
print(f"\nMutations in sample {sample_id}: {len(muts)}")
for m in muts[:5]:
print(f" {m.get('gene_name'):10s} {m.get('mutation_aa')}")
Query 5: Mutational Signatures
Retrieve COSMIC mutational signature data for cancer types.
import requests, base64, pandas as pd
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
# List available mutational signatures
r = requests.get(f"{BASE}/signatures", headers=HEADERS)
r.raise_for_status()
sigs = r.json()["data"]
print(f"COSMIC mutational signatures: {len(sigs)}")
for s in sigs[:5]:
print(f" {s.get('signature_name')}: {s.get('aetiology', '')[:80]}")
# Get signature attributions by cancer type
r2 = requests.get(f"{BASE}/signatures/attributions",
headers=HEADERS,
params={"cancer_type": "Breast", "limit": 10})
if r2.ok:
attributions = r2.json()["data"]
for a in attributions[:5]:
print(f" {a.get('signature_name')}: {a.get('attribution_proportion'):.2%} in breast cancer")
Query 6: Drug Resistance Variants
Query the COSMIC drug resistance database for variants conferring drug resistance.
import requests, base64, pandas as pd
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
# Get drug resistance variants
r = requests.get(f"{BASE}/resistance_mutations",
headers=HEADERS,
params={"gene": "EGFR", "limit": 20})
if r.ok:
data = r.json()
print(f"EGFR drug resistance variants: {data['meta'].get('total', 'n/a')}")
for v in data.get("data", [])[:5]:
print(f" {v.get('mutation_aa'):20s} Drug: {v.get('drug')} | Resistance: {v.get('resistance_type')}")
else:
print(f"Drug resistance API: {r.status_code} - endpoint may require specific access level")
Key Concepts
Cancer Gene Census Tiers
COSMIC's Cancer Gene Census classifies genes into:
- Tier 1: Well-established cancer drivers with documented mutations and molecular mechanisms in cancer
- Tier 2: Genes with strong evidence for roles in cancer but less functional characterization
Mutation ID Stability
COSMIC mutation IDs (COSM…) are stable identifiers for specific amino acid changes in a gene. The same COSM ID appears across all samples with that mutation, allowing cross-study comparison.
Common Workflows
Workflow 1: Gene Hotspot Mutation Analysis
Goal: Identify the most frequently occurring somatic mutations in a cancer gene.
import requests, base64, pandas as pd
from collections import Counter
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
def get_all_gene_mutations(gene, max_records=1000):
"""Paginate through all COSMIC mutations for a gene."""
all_muts = []
skip = 0
limit = 200
while len(all_muts) < max_records:
r = requests.get(f"{BASE}/mutations",
headers=HEADERS,
params={"gene_name": gene, "limit": limit, "skip": skip})
r.raise_for_status()
batch = r.json()["data"]
if not batch:
break
all_muts.extend(batch)
total = r.json()["meta"]["total"]
skip += limit
if skip >= total:
break
return all_muts
# Get hotspots for KRAS
mutations = get_all_gene_mutations("KRAS", max_records=500)
print(f"Retrieved {len(mutations)} KRAS somatic mutations")
# Rank by amino acid change frequency
aa_counter = Counter(m["mutation_aa"] for m in mutations if m.get("mutation_aa"))
hotspots = pd.DataFrame(aa_counter.most_common(15), columns=["mutation_aa", "sample_count"])
print("\nKRAS hotspot mutations:")
print(hotspots.head(10).to_string(index=False))
hotspots.to_csv("KRAS_hotspots.csv", index=False)
Workflow 2: Cancer Gene Census Export
Goal: Export the full Cancer Gene Census as a structured table for downstream pipeline use.
import requests, base64, pandas as pd, time
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
BASE = "https://cancer.sanger.ac.uk/cosmic/api"
HEADERS = {"Authorization": f"Basic {token}"}
all_genes = []
for tier in [1, 2]:
skip = 0
while True:
r = requests.get(f"{BASE}/genes",
headers=HEADERS,
params={"cgc_tier": str(tier), "limit": 100, "skip": skip})
r.raise_for_status()
batch = r.json()["data"]
if not batch:
break
all_genes.extend(batch)
if len(batch) < 100:
break
skip += 100
time.sleep(0.1)
rows = [{
"gene": g.get("gene_name"),
"tier": g.get("cgc_tier"),
"role_in_cancer": g.get("role_in_cancer"),
"mutation_types": g.get("mutation_types"),
"somatic_tumours": str(g.get("tumour_types_somatic", [])),
"germline_tumours": str(g.get("tumour_types_germline", [])),
"chr": g.get("chromosomal_location"),
} for g in all_genes]
df = pd.DataFrame(rows)
df.to_csv("COSMIC_cancer_gene_census.csv", index=False)
print(f"Exported {len(df)} Cancer Gene Census genes → COSMIC_cancer_gene_census.csv")
print(df.groupby("tier")["gene"].count())
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
gene_name |
Mutations | — | HGNC symbol | Filter mutations by gene |
primary_site |
Mutations/Samples | — | tissue type string | Filter by primary tumor site |
limit |
All | 10 |
1–200 |
Records per page |
skip |
All | 0 |
integer | Pagination offset |
cgc_tier |
Genes | — | "1", "2" |
Cancer Gene Census tier |
mutation_id |
Mutations | — | COSM ID string | Lookup specific mutation |
Best Practices
-
Authenticate via Base64: COSMIC uses HTTP Basic Auth with base64-encoded
email:password. Store credentials in environment variables, not in code. -
Paginate large gene queries: Popular cancer genes (TP53, KRAS) have 100,000+ mutation records; use
skip/limitpagination and cache results locally. -
Use COSM IDs for cross-study comparison: Amino acid change strings may have formatting variations (p.G12D vs G12D); use COSMIC mutation IDs (COSM…) for unambiguous references.
-
Check data license for commercial use: COSMIC data is free for academic use but requires a commercial license for industry applications. Verify at https://cancer.sanger.ac.uk/cosmic/license.
-
Complement with clinical data: COSMIC captures somatic mutations from cancer sequencing; complement with
clinvar-databasefor germline pathogenicity andopentargets-databasefor therapeutic significance.
Common Recipes
Recipe: Top Mutated Genes in a Cancer Type
When to use: Identify frequently mutated genes in a specific cancer type.
import requests, base64, pandas as pd
from collections import Counter
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
HEADERS = {"Authorization": f"Basic {token}"}
r = requests.get("https://cancer.sanger.ac.uk/cosmic/api/mutations",
headers=HEADERS,
params={"primary_site": "lung", "limit": 200})
data = r.json()["data"]
gene_counts = Counter(m.get("gene_name") for m in data if m.get("gene_name"))
df = pd.DataFrame(gene_counts.most_common(10), columns=["gene", "mutations"])
print(df.to_string(index=False))
Recipe: Check if a Variant Is in COSMIC
When to use: Look up whether a specific amino acid change is recorded in COSMIC.
import requests, base64
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
HEADERS = {"Authorization": f"Basic {token}"}
gene = "KRAS"
aa_change = "p.G12D"
r = requests.get("https://cancer.sanger.ac.uk/cosmic/api/mutations",
headers=HEADERS,
params={"gene_name": gene, "limit": 200})
all_muts = r.json()["data"]
matches = [m for m in all_muts if aa_change in (m.get("mutation_aa") or "")]
print(f"{gene} {aa_change}: {'FOUND' if matches else 'NOT FOUND'} in COSMIC ({len(matches)} records)")
if matches:
print(f" Sample count: {sum(m.get('count', 0) for m in matches)}")
Recipe: Download CGC Tier 1 Gene List
When to use: Get a simple list of Tier 1 cancer driver genes for filtering.
import requests, base64
EMAIL = "your@email.com"
PASSWORD = "your_password"
token = base64.b64encode(f"{EMAIL}:{PASSWORD}".encode()).decode()
HEADERS = {"Authorization": f"Basic {token}"}
r = requests.get("https://cancer.sanger.ac.uk/cosmic/api/genes",
headers=HEADERS,
params={"cgc_tier": "1", "limit": 200})
genes = [g["gene_name"] for g in r.json()["data"]]
print(f"CGC Tier 1 genes ({len(genes)}): {', '.join(genes[:10])}...")
with open("cosmic_tier1_genes.txt", "w") as f:
f.write("\n".join(genes))
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 401 Unauthorized |
Missing or incorrect API credentials | Check base64 encoding: base64.b64encode(f"{email}:{password}".encode()) |
HTTP 403 Forbidden |
Access requires different tier | Some endpoints need commercial license; check COSMIC license page |
Empty data array |
No records match filter | Broaden query; check spelling of gene symbol or site name |
| Very slow for large genes | TP53/KRAS have 100K+ records | Paginate with small limit=200; cache results to local CSV |
| Rate limit errors | >10 req/s | Add time.sleep(0.15) between requests |
| Different AA notation format | Various mutation string formats | Normalize with RDKit or use COSM IDs for exact matching |
Related Skills
clinvar-database— Germline pathogenicity classifications complementing COSMIC's somatic focusopentargets-database— Drug-target associations for COSMIC cancer driver genesensembl-database— Variant consequence predictions (VEP) for COSMIC variantsgwas-database— Population-level SNP associations for cancer risk (vs. COSMIC's somatic mutations)
References
- COSMIC website — Official COSMIC database and downloads
- COSMIC REST API v3 documentation — API endpoint reference
- Cancer Gene Census — Curated cancer driver gene catalog
- COSMIC mutational signatures (Alexandrov et al. 2020) — Reference paper for COSMIC v3 signatures
skills/genomics-bioinformatics/databases/dbsnp-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill dbsnp-database -g -y
SKILL.md
Frontmatter
{
"name": "dbsnp-database",
"license": "CC0-1.0",
"description": "Query NCBI dbSNP for SNP records by rsID, gene, or region via E-utilities and Variation Services REST API. Retrieve alleles, MAF, variant class (SNV\/indel\/MNV), clinical links, cross-DB IDs (ClinVar, dbVar, 1000G). Free; 3 req\/sec (10 with key). For clinical pathogenicity use clinvar-database; for population frequencies use gnomad-database."
}
dbSNP Database
Overview
NCBI dbSNP is the primary public repository for short human genetic variants, cataloguing over 1 billion SNPs, indels, and MNVs with allele frequencies, functional annotations, and cross-references to ClinVar, gnomAD, and 1000 Genomes. Variants are identified by stable rsIDs (reference SNP cluster IDs). Access is free via two APIs: the legacy NCBI E-utilities and the newer NCBI Variation Services REST API, which returns structured JSON.
When to Use
- Looking up allele frequencies and variant class for a known rsID
- Searching all dbSNP variants in a gene or chromosomal region by name or coordinates
- Resolving rsIDs to genomic coordinates (GRCh38/GRCh37) and HGVS notation
- Checking whether a variant of interest has clinical significance links to ClinVar entries
- Batch-fetching hundreds of rsIDs efficiently using epost+efetch history server
- Cross-referencing a list of variant positions to dbSNP rsIDs for downstream annotation
- For clinical pathogenicity classifications use
clinvar-database; dbSNP provides IDs and frequency but not curated clinical significance - For population frequency stratified by ancestry use
gnomad-database; dbSNP MAF is a single aggregate frequency
Prerequisites
- Python packages:
requests,pandas,matplotlib,xml.etree.ElementTree(stdlib) - Data requirements: rsIDs (
rs80357906), gene symbols, or chromosomal coordinates - Environment: internet connection; NCBI Entrez email required for E-utilities (set
emailparameter) - Rate limits: 3 requests/second without API key; 10 requests/second with free NCBI API key. Register at https://www.ncbi.nlm.nih.gov/account/ — add
&api_key=YOUR_KEYto all requests
pip install requests pandas matplotlib
# xml.etree.ElementTree is part of Python stdlib — no additional install needed
Quick Start
import requests
import json
EMAIL = "your@email.com" # required by NCBI policy
BASE_EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
BASE_VARIATION = "https://api.ncbi.nlm.nih.gov/variation/v0"
def fetch_snp_by_rsid(rsid: str) -> dict:
"""Fetch a dbSNP record by rsID using the NCBI Variation Services API (structured JSON)."""
rs_num = str(rsid).lstrip("rs")
r = requests.get(f"{BASE_VARIATION}/refsnp/{rs_num}", timeout=15)
r.raise_for_status()
return r.json()
record = fetch_snp_by_rsid("rs1800497") # DRD2 Taq1A
print(f"rsID: rs{record['refsnp_id']}")
print(f"Variant type: {record['primary_snapshot_data'].get('variant_type')}")
# Top-level keys: citations, create_date, dbsnp1_merges, last_update_build_id,
# last_update_date, lost_obs_movements, mane_select_ids, present_obs_movements,
# primary_snapshot_data, refsnp_id. (No top-level `organism` field.)
# rsID: rs1800497
# Variant type: snv
Core API
Query 1: rsID Lookup via E-utilities
Fetch the full SNP record for a single rsID using efetch with db=snp. Returns an XML document with alleles, placements, and frequency data.
import requests
import xml.etree.ElementTree as ET
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def efetch_snp_xml(rsid: str) -> ET.Element:
"""Fetch dbSNP XML record for a single rsID via the docsum rettype.
Note: rettype="xml" returns a namespaced ExchangeSet; rettype="docsum"
returns the simpler eSummaryResult/DocumentSummary tree without namespaces."""
rs_num = str(rsid).lstrip("rs")
r = requests.get(f"{BASE}/efetch.fcgi",
params={"db": "snp", "id": rs_num,
"rettype": "docsum", "retmode": "xml",
"email": EMAIL},
timeout=20)
r.raise_for_status()
return ET.fromstring(r.text)
root = efetch_snp_xml("rs80357906")
# Parse the DocumentSummary record (MAF/MAFALLELE were removed in 2024;
# GLOBAL_MAFS is a sub-tree — use ESummary JSON below for easier access)
for docsum in root.iter("DocumentSummary"):
rs_id = docsum.get("uid")
snp_class = docsum.findtext("SNP_CLASS", "Unknown")
chr_pos = docsum.findtext("CHRPOS", "N/A")
clin_sig = docsum.findtext("CLINICAL_SIGNIFICANCE", "N/A")
print(f"rs{rs_id} | Class: {snp_class} | Position: {chr_pos}")
print(f" ClinSig: {clin_sig}")
# rs80357906 | Class: delins | Position: 17:43057062
# ClinSig: pathogenic,risk-factor,uncertain-significance
# Fetch using ESummary for structured JSON (preferred for batch)
def esummary_snp(rsid: str) -> dict:
rs_num = str(rsid).lstrip("rs")
r = requests.get(f"{BASE}/esummary.fcgi",
params={"db": "snp", "id": rs_num,
"retmode": "json", "email": EMAIL},
timeout=15)
r.raise_for_status()
result = r.json()["result"]
return result.get(rs_num, {})
rec = esummary_snp("rs80357906")
print(f"rs{rec.get('snp_id')}:")
print(f" Class : {rec.get('snp_class')}") # e.g., 'delins'
# `maf`/`mafallele` were removed from ESummary in 2024 — use `global_mafs`
# (list of {study, freq}) and pick a study (e.g., 'GnomAD_genomes') or the
# global aggregate ('TOPMED'/'1000Genomes').
for m in rec.get('global_mafs', [])[:4]:
print(f" MAF[{m['study']:18s}]: {m['freq']}")
print(f" ChrPos : {rec.get('chrpos')}") # 17:43057062
print(f" ClinSig : {rec.get('clinical_significance')}")
print(f" FxnClass : {rec.get('fxn_class')}")
Query 2: Gene Variant Search
Search dbSNP for all variants in a gene using esearch. Returns a list of rsIDs matching the gene.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def esearch_snp(query: str, retmax: int = 100) -> tuple[list, int]:
"""Search dbSNP using a query string. Returns (id_list, total_count)."""
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "snp", "term": query,
"retmax": retmax, "retmode": "json",
"email": EMAIL},
timeout=15)
r.raise_for_status()
result = r.json()["esearchresult"]
return result["idlist"], int(result["count"])
# All variants in BRCA1
ids, total = esearch_snp("BRCA1[gene] AND human[orgn]", retmax=20)
print(f"BRCA1 variants in dbSNP: {total:,} total")
print(f"First 5 rsIDs: {['rs' + i for i in ids[:5]]}")
# Only clinical variants (linked to ClinVar)
ids_clin, total_clin = esearch_snp(
"BRCA1[gene] AND human[orgn] AND clinsig[filter]", retmax=50)
print(f"BRCA1 variants with clinical significance: {total_clin:,}")
Query 3: Chromosomal Region Search
Search for all variants in a genomic region using chromosome coordinates.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def search_region(chrom: str, start: int, stop: int,
assembly: str = "GRCh38", retmax: int = 200) -> tuple[list, int]:
"""Find all dbSNP variants in a chromosomal region."""
query = f"{chrom}[CHR] AND {start}:{stop}[CHRPOS37]" if assembly == "GRCh37" else \
f"{chrom}[CHR] AND {start}:{stop}[CHRPOS]"
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "snp", "term": query,
"retmax": retmax, "retmode": "json",
"email": EMAIL},
timeout=20)
r.raise_for_status()
result = r.json()["esearchresult"]
return result["idlist"], int(result["count"])
# PCSK9 exon 4 region (GRCh38)
ids, total = search_region("1", 55039700, 55040200)
print(f"Variants in chr1:55039700-55040200: {total:,} total")
print(f"Retrieved {len(ids)} rsIDs: {['rs' + i for i in ids[:5]]}")
Query 4: Variant Summary — MAF, Alleles, Clinical Significance
Retrieve structured summary data for variant records using ESummary, extracting MAF, alleles, and database cross-links.
import requests, json
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def fetch_snp_summaries(rsids: list) -> dict:
"""Fetch ESummary records for a list of rsIDs. Returns dict keyed by rs number."""
ids_str = ",".join(str(r).lstrip("rs") for r in rsids)
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "snp", "id": ids_str,
"retmode": "json", "email": EMAIL},
timeout=20)
r.raise_for_status()
return r.json()["result"]
rsids = ["rs80357906", "rs80357220", "rs28897672", "rs1801133"]
result = fetch_snp_summaries(rsids)
for rs_num in rsids:
uid = str(rs_num).lstrip("rs")
rec = result.get(uid, {})
# `global_mafs` is a list of {study, freq}; pick the first or filter by study
gmafs = rec.get("global_mafs", [])
maf_str = gmafs[0]["freq"] if gmafs else "N/A"
maf_study = gmafs[0]["study"] if gmafs else ""
print(f"\n{rs_num}:")
print(f" Class : {rec.get('snp_class', 'N/A')}")
print(f" MAF : {maf_str} (from {maf_study})")
print(f" Location : {rec.get('chrpos', 'N/A')}")
print(f" ClinSig : {rec.get('clinical_significance', 'N/A')}")
print(f" Function : {rec.get('fxn_class', 'N/A')}")
Query 5: Batch rsID Query with EPost+EFetch
Efficiently upload hundreds of rsIDs to the NCBI history server using EPost, then retrieve them in batches with EFetch.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def epost_ids(id_list: list) -> tuple[str, str]:
"""Upload rsIDs to NCBI history server. Returns (WebEnv, query_key)."""
ids_str = ",".join(str(i).lstrip("rs") for i in id_list)
r = requests.post(f"{BASE}/epost.fcgi",
data={"db": "snp", "id": ids_str, "email": EMAIL},
timeout=30)
r.raise_for_status()
import xml.etree.ElementTree as ET
root = ET.fromstring(r.text)
webenv = root.findtext("WebEnv")
query_key = root.findtext("QueryKey")
return webenv, query_key
def efetch_history(webenv: str, query_key: str,
retstart: int = 0, retmax: int = 100) -> dict:
"""Retrieve records from NCBI history server using ESummary."""
r = requests.get(f"{BASE}/esummary.fcgi",
params={"db": "snp", "WebEnv": webenv,
"query_key": query_key, "retstart": retstart,
"retmax": retmax, "retmode": "json",
"email": EMAIL},
timeout=30)
r.raise_for_status()
return r.json()["result"]
rsid_batch = ["rs80357906", "rs80357220", "rs28897672", "rs1801133",
"rs429358", "rs7412", "rs1800497"]
webenv, query_key = epost_ids(rsid_batch)
print(f"Posted {len(rsid_batch)} IDs | WebEnv: {webenv[:40]}...")
records = []
for start in range(0, len(rsid_batch), 100):
result = efetch_history(webenv, query_key, retstart=start, retmax=100)
for uid in result.get("uids", []):
rec = result[uid]
# global_mafs replaced maf/mafallele in 2024 — flatten the first entry
gmafs = rec.get("global_mafs", [])
records.append({
"rsid": f"rs{uid}",
"snp_class": rec.get("snp_class"),
"maf": gmafs[0]["freq"] if gmafs else None,
"maf_study": gmafs[0]["study"] if gmafs else None,
"chrpos": rec.get("chrpos"),
"clinical_sig": rec.get("clinical_significance"),
})
time.sleep(0.5)
df = pd.DataFrame(records)
print(f"\nRetrieved {len(df)} records:")
print(df.to_string(index=False))
Query 6: NCBI Variation Services API
The newer REST API returns structured JSON with detailed allele placements, frequencies, and variant type annotations. Preferred for programmatic rsID resolution.
import requests
import pandas as pd
BASE_VARIATION = "https://api.ncbi.nlm.nih.gov/variation/v0"
def fetch_refsnp(rsid: str) -> dict:
"""Fetch structured JSON from NCBI Variation Services API."""
rs_num = str(rsid).lstrip("rs")
r = requests.get(f"{BASE_VARIATION}/refsnp/{rs_num}", timeout=15)
r.raise_for_status()
return r.json()
def parse_allele_frequencies(record: dict) -> list:
"""Extract allele frequencies from a Variation Services record.
freq[i] keys: study_name, study_version, local_row_id, observation, allele_count, total_count.
The asserted allele identifier lives in freq.observation.inserted_sequence (NOT at ann.allele)."""
freqs = []
snapshot = record.get("primary_snapshot_data", {})
for ann in snapshot.get("allele_annotations", []):
for f in ann.get("frequency", []):
obs = f.get("observation", {})
freqs.append({
"allele": obs.get("inserted_sequence"),
"study": f.get("study_name"),
"allele_count": f.get("allele_count"),
"total_count": f.get("total_count"),
"freq": (f["allele_count"] / f["total_count"]
if f.get("total_count") else None),
})
return freqs
record = fetch_refsnp("rs1800497") # DRD2 Taq1A variant
print(f"rsID: rs{record['refsnp_id']}")
print(f"Variant type: {record['primary_snapshot_data'].get('variant_type')}") # 'snv'
placements = record["primary_snapshot_data"].get("placements_with_allele", [])
for placement in placements[:2]:
seq_id = placement.get("seq_id")
for allele in placement.get("alleles", []):
spdi = allele.get("allele", {}).get("spdi", {})
print(f" Placement: {seq_id} | SPDI: {spdi.get('inserted_sequence')}")
freqs = parse_allele_frequencies(record)
if freqs:
df_freq = pd.DataFrame(freqs[:5])
print(f"\nAllele frequencies ({len(freqs)} entries):")
print(df_freq.to_string(index=False))
Key Concepts
rsID vs. ss ID (Submitted SNP)
dbSNP uses two IDs: rs IDs (Reference SNP cluster IDs) are stable public identifiers assigned after clustering submitted variants. ss IDs (Submitted SNP IDs) are assigned to individual laboratory submissions before clustering. Use rs IDs for all queries — ss IDs are internal and submission-specific. A single rs ID may cluster multiple ss IDs from different submissions.
MAF vs. Clinical Significance
- MAF (Minor Allele Frequency): The aggregate frequency of the minor allele across all dbSNP submissions. This is a population-level statistic aggregated from 1000 Genomes, gnomAD, TOPMED, and other studies. It does not indicate whether the variant is pathogenic.
- Clinical significance: A link field pointing to ClinVar classifications (Pathogenic, VUS, Benign, etc.). A variant can have a very low MAF (rare) yet be classified as Benign, or a moderate MAF yet be Pathogenic in a specific context (e.g., founder variants). Use
clinvar-databasefor the full pathogenicity record.
Variant Classes in dbSNP
| Class | Description | Example |
|---|---|---|
snv |
Single nucleotide variant (A>T) | rs80357906 |
indel |
Insertion or deletion | rs786201005 |
mnv |
Multi-nucleotide variant | rs1057519737 |
ins |
Pure insertion | rs113993960 |
del |
Pure deletion | rs66767301 |
microsatellite |
STR (short tandem repeat) | rs5030655 |
Common Workflows
Workflow 1: Batch rsID Annotation from a Variant List
Goal: Given a list of rsIDs from a variant call pipeline, retrieve MAF, position, and clinical significance for all variants in one run.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def epost_snp(rsids):
ids = ",".join(str(r).lstrip("rs") for r in rsids)
r = requests.post(f"{BASE}/epost.fcgi",
data={"db": "snp", "id": ids, "email": EMAIL}, timeout=30)
r.raise_for_status()
import xml.etree.ElementTree as ET
root = ET.fromstring(r.text)
return root.findtext("WebEnv"), root.findtext("QueryKey")
def esummary_history(webenv, query_key, start, retmax=100):
r = requests.get(f"{BASE}/esummary.fcgi",
params={"db": "snp", "WebEnv": webenv,
"query_key": query_key, "retstart": start,
"retmax": retmax, "retmode": "json", "email": EMAIL},
timeout=30)
r.raise_for_status()
return r.json()["result"]
# Example: VCF post-processing — annotate a list of called variants
variant_rsids = [
"rs80357906", "rs80357220", "rs28897672", "rs1801133",
"rs429358", "rs7412", "rs1800497", "rs2230199",
]
print(f"Posting {len(variant_rsids)} rsIDs to NCBI history server...")
webenv, query_key = epost_snp(variant_rsids)
records = []
for start in range(0, len(variant_rsids), 100):
result = esummary_history(webenv, query_key, start=start, retmax=100)
for uid in result.get("uids", []):
rec = result[uid]
gmafs = rec.get("global_mafs", []) # 2024 schema (replaced maf/mafallele)
records.append({
"rsid": f"rs{uid}",
"snp_class": rec.get("snp_class"),
"maf": gmafs[0]["freq"] if gmafs else None,
"maf_study": gmafs[0]["study"] if gmafs else None,
"chrpos_grch38": rec.get("chrpos"),
"gene": rec.get("genes", [{}])[0].get("name") if rec.get("genes") else None,
"clinical_significance": rec.get("clinical_significance"),
"fxn_class": rec.get("fxn_class"),
})
time.sleep(0.5)
df = pd.DataFrame(records)
df.to_csv("variant_annotations.csv", index=False)
print(f"Annotated {len(df)} variants → variant_annotations.csv")
print(df[["rsid", "snp_class", "maf", "chrpos_grch38", "clinical_significance"]].to_string(index=False))
Workflow 2: Gene Variant Class Distribution Visualization
Goal: Search all dbSNP variants in a gene and plot their variant class distribution.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def search_gene_variants(gene: str, retmax: int = 500) -> list:
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "snp", "term": f"{gene}[gene] AND human[orgn]",
"retmax": retmax, "retmode": "json", "email": EMAIL},
timeout=20)
r.raise_for_status()
return r.json()["esearchresult"]["idlist"]
def fetch_summaries_batch(ids: list, batch_size: int = 100) -> list:
records = []
for i in range(0, len(ids), batch_size):
batch = ids[i:i+batch_size]
ids_str = ",".join(batch)
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "snp", "id": ids_str,
"retmode": "json", "email": EMAIL},
timeout=30)
r.raise_for_status()
result = r.json()["result"]
for uid in result.get("uids", []):
rec = result[uid]
records.append({"rsid": f"rs{uid}", "snp_class": rec.get("snp_class", "unknown")})
time.sleep(0.5)
return records
gene = "CFTR"
print(f"Searching dbSNP for {gene} variants...")
ids = search_gene_variants(gene, retmax=300)
print(f"Found {len(ids)} IDs; fetching summaries...")
records = fetch_summaries_batch(ids)
df = pd.DataFrame(records)
class_counts = df["snp_class"].value_counts()
# Plot variant class distribution
fig, ax = plt.subplots(figsize=(8, 5))
colors = ["#4472C4", "#ED7D31", "#A9D18E", "#FF0000", "#FFC000", "#7030A0"]
bars = ax.bar(class_counts.index, class_counts.values,
color=colors[:len(class_counts)], edgecolor="white")
ax.bar_label(bars, padding=3, fontsize=9)
ax.set_xlabel("Variant Class")
ax.set_ylabel("Count")
ax.set_title(f"dbSNP Variant Classes in {gene} (n={len(df)})")
plt.tight_layout()
plt.savefig(f"{gene}_variant_classes.png", dpi=150, bbox_inches="tight")
print(f"Saved {gene}_variant_classes.png")
print(class_counts.to_string())
# snv 241
# indel 38
# mnv 12
# del 7
# ins 2
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
db |
All E-utilities | required | "snp" |
Database selector; must be "snp" for dbSNP queries |
id |
efetch, esummary, epost | required | rsID number(s) without rs prefix |
Variant identifier(s) to fetch |
term |
esearch | required | dbSNP query string | Search expression with field tags: [gene], [CHR], [CHRPOS], [rs], clinsig[filter] |
retmax |
esearch | 20 |
1–10000 |
Maximum records returned per search |
retmode |
esearch, esummary | "xml" |
"json", "xml" |
Response format; use "json" for easy parsing |
rettype |
efetch | "docsum" |
"docsum", "xml" |
Record type for efetch responses |
WebEnv + query_key |
esummary, efetch | — | from epost response | History server tokens for batch retrieval |
email |
All E-utilities | required | valid email string | NCBI policy; used for rate attribution |
api_key |
All E-utilities | optional | NCBI API key string | Raises rate limit from 3 to 10 req/sec |
Best Practices
-
Register for a free NCBI API key: Adds
api_key=YOUR_KEYto requests and triples your rate limit (3 → 10 req/sec) with no other changes. Register at https://www.ncbi.nlm.nih.gov/account/. -
Use epost+esummary for batches of more than 10 rsIDs: Avoid looping individual efetch calls. EPost uploads all IDs in one request to the history server; subsequent ESummary calls retrieve them in configurable batches of up to 500.
-
Prefer NCBI Variation Services API for structured JSON: The
/variation/v0/refsnp/{rs_num}endpoint returns a fully structured JSON with SPDI allele representations, placements, and frequency tables. Easier to parse than E-utilities XML for modern applications. -
Check
snp_classbefore interpreting MAF: Indels and MNVs use different allele counting conventions than SNVs. Treat multi-allelic sites carefully — the reported MAF may refer to one allele among several. -
Combine dbSNP with ClinVar lookups: dbSNP records the
clinical_significancefield as a string (e.g.,"pathogenic") but does not contain submitter details, review status, or HGVS details. Useclinvar-databasefor the full pathogenicity record when clinical interpretation is required.
Common Recipes
Recipe: Quick rsID Existence and Class Check
When to use: Check whether a variant is registered in dbSNP before downstream annotation.
import requests
EMAIL = "your@email.com"
def check_rsid(rsid: str) -> dict:
"""Check if an rsID exists in dbSNP and return basic info."""
rs_num = str(rsid).lstrip("rs")
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi",
params={"db": "snp", "id": rs_num, "retmode": "json", "email": EMAIL},
timeout=10
)
result = r.json().get("result", {})
rec = result.get(rs_num, {})
if not rec or "error" in rec:
return {"rsid": rsid, "found": False}
gmafs = rec.get("global_mafs", [])
return {
"rsid": rsid,
"found": True,
"snp_class": rec.get("snp_class"),
"chrpos": rec.get("chrpos"),
"maf": gmafs[0]["freq"] if gmafs else None,
"maf_study": gmafs[0]["study"] if gmafs else None,
"clinical_significance": rec.get("clinical_significance"),
}
for rsid in ["rs80357906", "rs9999999999", "rs1800497"]:
info = check_rsid(rsid)
if info["found"]:
print(f"{rsid}: {info['snp_class']} | pos={info['chrpos']} | MAF={info['maf']} ({info['maf_study']})")
else:
print(f"{rsid}: NOT FOUND in dbSNP")
# rs80357906: delins | pos=17:43057062 | MAF=G=0.0008929/4 (Estonian)
# rs9999999999: NOT FOUND in dbSNP
# rs1800497: snv | pos=11:113400106 | MAF=G=0.42... (study varies)
Recipe: Resolve Gene Variants to rsIDs and Coordinates
When to use: Convert a gene name to a list of rsIDs for use in downstream tools (PLINK, ANNOVAR, etc.).
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def gene_to_rsids(gene: str, max_variants: int = 200) -> pd.DataFrame:
"""Search dbSNP for a gene and return rsIDs with GRCh38 coordinates."""
# Step 1: search
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "snp", "term": f"{gene}[gene] AND human[orgn]",
"retmax": max_variants, "retmode": "json", "email": EMAIL},
timeout=15)
r.raise_for_status()
ids = r.json()["esearchresult"]["idlist"]
if not ids:
return pd.DataFrame()
time.sleep(0.4)
# Step 2: fetch summaries
r2 = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "snp", "id": ",".join(ids),
"retmode": "json", "email": EMAIL},
timeout=30)
r2.raise_for_status()
result = r2.json()["result"]
rows = []
for uid in result.get("uids", []):
rec = result[uid]
gmafs = rec.get("global_mafs", [])
rows.append({
"rsid": f"rs{uid}",
"chrpos_grch38": rec.get("chrpos"),
"snp_class": rec.get("snp_class"),
"maf": gmafs[0]["freq"] if gmafs else None,
})
return pd.DataFrame(rows)
df = gene_to_rsids("APOE", max_variants=50)
print(f"APOE variants retrieved: {len(df)}")
print(df.head(8).to_string(index=False))
df.to_csv("APOE_rsids.csv", index=False)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 429 or connection refused |
Rate limit exceeded (3 req/sec) | Add time.sleep(0.35) between requests; register for API key to get 10 req/sec |
ESummary returns {"error": "Invalid uid"} |
rsID does not exist in dbSNP | Check rsID spelling; verify with NCBI browser; variant may be a novel call not yet in dbSNP |
esearch returns 0 results for a gene |
Gene symbol mismatch or missing human[orgn] filter |
Try adding AND human[orgn]; check NCBI gene symbol at https://www.ncbi.nlm.nih.gov/gene |
KeyError: 'maf' / 'mafallele' in ESummary parsing |
Fields removed in the 2024 dbSNP ESummary schema | Use rec["global_mafs"] — list of {"study": ..., "freq": "<allele>=<value>/<count>"}; pick a study (GnomAD_genomes, TOPMED, ALFA) and parse the freq string |
global_mafs empty |
Variant has no aggregated population frequency in dbSNP | Use gnomAD via gnomad-database directly for population frequencies |
root.iter("DocumentSummary") returns 0 with rettype="xml" |
EFetch XML root is namespaced ({https://www.ncbi.nlm.nih.gov/SNP/docsum}ExchangeSet) |
Use rettype="docsum" (no namespace) or pass the full namespaced tag to iter() |
| Variation Services API returns 404 | rsID not found or wrong URL format | Confirm integer rs number (no rs prefix) in /refsnp/{rs_num} endpoint |
| EPost XML parsing fails | Non-XML response (rate limit HTML error page) | Check response status code first; add retry logic with time.sleep(1) |
| Batch efetch returns fewer records than posted | Some rsIDs were merged or retired | Cross-check against NCBI merge history; retired rsIDs redirect to current active rs |
Related Skills
clinvar-database— ClinVar pathogenicity classifications for variants identified by rsID (complement to dbSNP)gnomad-database— Population allele frequencies by ancestry group (more detailed than dbSNP MAF)gwas-database— GWAS Catalog for SNP-trait associations from published GWAS studiesensembl-database— Ensembl REST API for variant consequences and gene annotationssnpeff-variant-annotation— Annotate VCF files with SnpEff and SnpSift, which adds dbSNP rsIDs and functional predictions
References
- NCBI E-utilities for dbSNP — E-utilities field tags and query syntax specific to dbSNP
- NCBI Variation Services API — REST API documentation and interactive Swagger UI
- Sherry et al., Nucleic Acids Research 2001 — dbSNP original description paper
- NCBI E-utilities Reference — Full E-utilities API reference (applies to all NCBI databases)
skills/genomics-bioinformatics/databases/depmap-crispr-essentiality/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill depmap-crispr-essentiality -g -y
SKILL.md
Frontmatter
{
"name": "depmap-crispr-essentiality",
"license": "CC-BY-4.0",
"description": "DepMap CRISPR gene effect (Chronos) analysis: sign convention for essentiality, per-gene NaN-safe Spearman correlation, data loading\/alignment. For general NaN-safe correlation see nan-safe-correlation; for quality filtering see degenerate-input-filtering."
}
DepMap CRISPR Gene Effect Analysis Guide
Overview
This guide covers the correct interpretation and analysis of DepMap CRISPR gene effect (Chronos) data. The most critical and common error in DepMap analyses is failing to negate the CRISPR scores when computing correlations with "essentiality." A secondary but equally damaging mistake is using bulk correlation shortcuts that mishandle per-gene NaN patterns. This guide provides the mandatory sign convention, the correct per-gene NaN-safe Spearman correlation implementation, and data loading/alignment procedures.
Key Concepts
DepMap CRISPR Score Convention
The CRISPR gene effect score (produced by the Chronos algorithm) quantifies how gene knockout affects cell viability:
- Negative score: gene knockout reduces cell viability -- the gene is essential for that cell line
- Zero score: no measurable effect on viability
- Positive score: gene knockout increases viability (rare, may indicate tumor-suppressive behavior)
The DepMap portal distributes these scores in the file CRISPRGeneEffect.csv. Each row is a cell line (DepMap ID, e.g., ACH-000001) and each column is a gene in the format GENE_NAME (ENTREZ_ID), e.g., A1BG (1).
Essentiality Sign Interpretation
Because negative raw scores indicate essentiality, any analysis that asks about "essentiality" or "dependency" requires negating the raw CRISPR scores:
- "Correlation with essentiality" = correlation with
-CRISPRGeneEffect(negated) - "Higher essentiality" = more negative raw score = more positive negated score
- "Most essential gene" = gene with the most negative raw score
If you correlate expression with raw CRISPR scores and find 3 genes with correlation <= -0.6 and 0 genes with correlation >= 0.6, then the correct answer for "genes with strong positive correlation with essentiality" is 3, not 0. The negative correlations with raw scores ARE the positive correlations with essentiality.
Data Structure: CRISPRGeneEffect Format
The standard DepMap data files use a consistent structure:
- Index: DepMap cell line identifiers (
ACH-XXXXXX) - Columns: Gene identifiers in
GENE_NAME (ENTREZ_ID)format - Values: Floating-point scores (may contain NaN for genes not screened in a given cell line)
- Companion files: Expression data (
OmicsExpressionProteinCodingGenesTPMLogp1BatchCorrected.csv) uses the same index/column format, enabling direct alignment
Different genes have different patterns of missing data across cell lines. This is because not all genes are screened in all cell lines, and quality control may remove specific gene-cell line combinations.
Decision Framework
Question: How should I compute correlations with DepMap CRISPR data?
├── Does the question mention "essentiality" or "dependency"?
│ ├── Yes → Negate CRISPR scores before correlating (see Best Practices #1)
│ └── No (raw gene effect) → Use raw scores directly
├── How should I compute correlations?
│ ├── Per-gene correlation → scipy.stats.spearmanr in a loop (see Best Practices #2)
│ └── Matrix-wide correlation → AVOID; use per-gene loop instead
└── How should I handle missing data?
├── Pairwise NaN removal → CORRECT (see Best Practices #3)
└── Global row/column dropping → INCORRECT; loses too much data
| Scenario | Recommended Approach | Rationale |
|---|---|---|
| Correlating expression with "essentiality" | Negate CRISPR scores, then per-gene Spearman | Sign convention requires negation; per-gene handles NaN correctly |
| Correlating expression with raw gene effect | Per-gene Spearman on raw scores | No negation needed, but NaN-safe per-gene loop still required |
| Ranking genes by essentiality across cell lines | Rank by most negative mean raw score | More negative = more essential across the panel |
| Identifying selectively essential genes | Compare score distributions across subgroups | Use per-subgroup mean/median of raw scores, then compare |
| Filtering genes before correlation | Require minimum 10 valid cell line pairs | Genes with too few observations yield unreliable correlations |
Best Practices
-
Always negate CRISPR scores when the analysis asks about "essentiality": The raw DepMap convention is that negative = essential. When a question or hypothesis refers to "essentiality," "dependency," or "gene importance," negate the scores so that higher values mean more essential. Explicitly state the sign convention in your results.
-
Use scipy.stats.spearmanr per gene in a loop: Bulk matrix shortcuts (
DataFrame.corrwith,DataFrame.rank().corrwith()) handle NaN inconsistently across columns. The only reliable method is to compute Spearman correlation gene by gene usingscipy.stats.spearmanrwith pairwise-complete observations. -
Apply pairwise NaN removal, not global dropping: Different genes have different missing-data patterns. Dropping rows globally (any NaN in any column) discards far too much data. Instead, for each gene, mask out only the cell lines where either the expression or CRISPR value is NaN.
-
Set a minimum valid-pair threshold: Genes with very few non-NaN cell line pairs produce unreliable correlation estimates. Require at least 10 (preferably 20+) valid pairs before computing a correlation. Skip genes below this threshold.
-
Report NaN summary before analysis: Before computing correlations, print the total NaN count per dataset, the number of common cell lines, and the number of common genes. This provides an audit trail and helps catch data loading errors early.
-
Verify dataset alignment before computation: Always intersect cell line IDs and gene columns between datasets before analysis. Misaligned indices produce silent errors -- correlations computed on mismatched rows are meaningless.
-
State the sign convention explicitly in results: When reporting correlation results, always include a statement like "CRISPR scores were negated so that positive values represent higher essentiality." This prevents downstream misinterpretation.
Common Pitfalls
-
Forgetting to negate CRISPR scores for essentiality analysis: The raw DepMap score convention (negative = essential) is counterintuitive. Omitting the negation reverses every correlation sign, leading to completely inverted conclusions.
- How to avoid: Always check whether the analysis question uses the word "essentiality" or "dependency." If so, negate the CRISPR scores. Add a comment in the code:
# Negate: in DepMap, negative = essential.
- How to avoid: Always check whether the analysis question uses the word "essentiality" or "dependency." If so, negate the CRISPR scores. Add a comment in the code:
-
Using bulk DataFrame correlation methods: Methods like
DataFrame.corrwith(method='spearman')orDataFrame.rank().corrwith()silently mishandle NaN values, potentially shifting correlations enough to push genes above or below significance thresholds.- How to avoid: Always use a per-gene loop with
scipy.stats.spearmanr. See the reference implementation in the Workflow section below.
- How to avoid: Always use a per-gene loop with
-
Dropping rows globally instead of pairwise: Calling
dropna()on the entire DataFrame before correlation removes all cell lines that have any NaN in any gene, drastically reducing sample size.- How to avoid: Apply NaN masking inside the per-gene loop:
mask = ~(np.isnan(x) | np.isnan(y)). This preserves the maximum number of observations per gene.
- How to avoid: Apply NaN masking inside the per-gene loop:
-
Not checking for sufficient valid pairs: Computing Spearman correlation on fewer than 10 observations produces unstable, unreliable estimates that may appear significant by chance.
- How to avoid: Add a guard clause:
if mask.sum() < 10: continue. Adjust the threshold upward (e.g., 20) for more conservative analysis.
- How to avoid: Add a guard clause:
-
Misinterpreting correlation signs without stated convention: Reporting "positive correlation" without stating whether CRISPR scores were negated leaves results ambiguous. Reviewers cannot tell if "positive" means "higher expression associates with more essential" or "less essential."
- How to avoid: Always include a sentence in the results section stating the sign convention used. For example: "CRISPR scores were negated so that positive correlation indicates higher expression is associated with greater essentiality."
-
Failing to align datasets before computation: Expression and CRISPR datasets may have different cell lines or different gene sets. Computing correlations without explicit alignment can silently match wrong rows or produce index errors.
- How to avoid: Always compute
common_lines = expr.index.intersection(crispr.index)andcommon_genes = expr.columns.intersection(crispr.columns), then subset both DataFrames before any computation.
- How to avoid: Always compute
-
Ignoring the gene column format: DepMap gene columns use the format
GENE_NAME (ENTREZ_ID). Attempting to match against plain gene symbols (e.g.,TP53instead ofTP53 (7157)) will produce empty intersections.- How to avoid: Inspect column formats with
df.columns[:5]before attempting any join or intersection. Parse gene names if needed:df.columns.str.extract(r'^(.+?)\s*\(')[0].
- How to avoid: Inspect column formats with
Workflow
-
Step 1: Load DepMap data
import pandas as pd # Load CRISPR gene effect data crispr = pd.read_csv('CRISPRGeneEffect.csv', index_col=0) # Load expression data expr = pd.read_csv( 'OmicsExpressionProteinCodingGenesTPMLogp1BatchCorrected.csv', index_col=0 ) # Column format: "GENE_NAME (ENTREZ_ID)" e.g., "A1BG (1)" # Index: DepMap cell line IDs e.g., "ACH-000001" -
Step 2: Align datasets
# Find common cell lines and genes common_lines = crispr.index.intersection(expr.index) common_genes = crispr.columns.intersection(expr.columns) print(f"Common cell lines: {len(common_lines)}") print(f"Common genes: {len(common_genes)}") # Subset to common crispr_aligned = crispr.loc[common_lines, common_genes] expr_aligned = expr.loc[common_lines, common_genes] -
Step 3: Report NaN summary
expr_nan = expr_aligned.isna().sum().sum() crispr_nan = crispr_aligned.isna().sum().sum() print(f"Expression NaN count: {expr_nan}") print(f"CRISPR NaN count: {crispr_nan}") -
Step 4: Negate CRISPR scores if computing essentiality correlations
# Negate: in DepMap, negative raw score = essential # After negation, positive = essential essentiality = -crispr_aligned -
Step 5: Compute per-gene NaN-safe Spearman correlation
from scipy.stats import spearmanr import numpy as np def compute_per_gene_spearman(expression_df, crispr_df, negate_crispr=True): """Compute Spearman correlation per gene with proper NaN handling. Args: expression_df: DataFrame (cell_lines x genes) crispr_df: DataFrame (cell_lines x genes) negate_crispr: If True, negate CRISPR scores to represent essentiality Returns: Series of Spearman correlations indexed by gene name """ # Align cell lines and genes common_lines = expression_df.index.intersection(crispr_df.index) common_genes = expression_df.columns.intersection(crispr_df.columns) expr = expression_df.loc[common_lines, common_genes] crispr = crispr_df.loc[common_lines, common_genes] if negate_crispr: crispr = -crispr # Print NaN summary BEFORE analysis expr_nan = expr.isna().sum().sum() crispr_nan = crispr.isna().sum().sum() print(f"Expression NaN count: {expr_nan}") print(f"CRISPR NaN count: {crispr_nan}") print(f"Common cell lines: {len(common_lines)}") print(f"Common genes: {len(common_genes)}") # Per-gene Spearman correlation with pairwise NaN removal correlations = {} for gene in common_genes: x = expr[gene].values y = crispr[gene].values # Remove pairs where either value is NaN mask = ~(np.isnan(x) | np.isnan(y)) if mask.sum() < 10: # Skip genes with too few valid pairs continue rho, pval = spearmanr(x[mask], y[mask]) correlations[gene] = rho return pd.Series(correlations).sort_values(ascending=False) -
Step 6: Apply threshold and report results
correlations = compute_per_gene_spearman(expr_aligned, crispr_aligned, negate_crispr=True) threshold = 0.6 strong_positive = correlations[correlations >= threshold] strong_negative = correlations[correlations <= -threshold] print(f"Genes with correlation >= {threshold}: {len(strong_positive)}") print(f"Genes with correlation <= -{threshold}: {len(strong_negative)}") print(f"\nNote: CRISPR scores were negated so that positive correlation") print(f"indicates higher expression associated with greater essentiality.") -
Step 7: Validate -- check for anti-patterns
Verify that none of these bulk shortcuts were used anywhere in the analysis:
# WRONG: Bulk rank-then-correlate shortcut ranked_expr = expression_df.rank() ranked_crispr = crispr_df.rank() correlations = ranked_expr.corrwith(ranked_crispr) # NaN handling is unreliable # WRONG: Bulk corrwith with method='spearman' correlations = expression_df.corrwith(crispr_df, method='spearman') # Same issueIf any of these patterns appear in the code, replace them with the per-gene loop from Step 5.
Further Reading
- DepMap Portal -- Primary data source for CRISPR gene effect scores, expression data, and other omics datasets across cancer cell lines
- Dempster et al. (2019) -- Chronos algorithm -- Original paper describing the Chronos computational method for estimating gene effect from CRISPR screen data
- DepMap Documentation and Data Downloads -- Detailed file format descriptions, release notes, and download links for all DepMap datasets
Related Skills
nan-safe-correlation-- General techniques for NaN-safe correlation computation across omics datasets; this guide applies those principles specifically to DepMap CRISPR datadegenerate-input-filtering-- Upstream data quality filtering to remove low-variance or degenerate features before correlation analysis; recommended as a preprocessing step before DepMap essentiality correlation
skills/genomics-bioinformatics/databases/ena-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill ena-database -g -y
SKILL.md
Frontmatter
{
"name": "ena-database",
"license": "Unknown",
"description": "ENA REST API for sequences, reads, assemblies, and annotations. Portal API search, Browser API retrieval (XML\/FASTA\/EMBL), file reports for FASTQ\/BAM URLs, taxonomy, cross-refs. For multi-DB Python use bioservices; for NCBI-only use pubmed-database or Biopython Entrez."
}
ENA Database — European Nucleotide Archive Programmatic Access
Overview
The European Nucleotide Archive (ENA) is EMBL-EBI's comprehensive nucleotide sequence database, encompassing raw sequencing reads, genome assemblies, annotated sequences, and associated metadata. It mirrors and extends INSDC data (GenBank, DDBJ). All access is via REST APIs with no authentication required.
When to Use
- Searching for sequencing studies, samples, or experiments by organism, project, or keyword
- Downloading raw FASTQ/BAM files for reanalysis of public sequencing datasets
- Retrieving genome assemblies with quality statistics (N50, contig count, genome size)
- Fetching nucleotide sequences in FASTA or EMBL flat-file format by accession
- Exploring taxonomic lineage and finding organisms by partial name
- Cross-referencing ENA records with external databases (ArrayExpress, UniProt, PDB)
- Building bulk download lists for large-scale sequencing projects
- For multi-database Python queries (ENA + UniProt + KEGG), prefer
bioservicesinstead - For NCBI-specific queries (PubMed literature, GenBank records), use
pubmed-databaseor Biopython Entrez
Prerequisites
pip install requests
API constraints:
- Rate limit: 50 requests per second across all ENA APIs
- No authentication required
- Large result sets: use pagination (
limit+offset) or streaming (limit=0for TSV download) - Portal API base:
https://www.ebi.ac.uk/ena/portal/api - Browser API base:
https://www.ebi.ac.uk/ena/browser/api - Taxonomy API base:
https://www.ebi.ac.uk/ena/taxonomy/rest - Cross-ref API base:
https://www.ebi.ac.uk/ena/xref/rest
Quick Start
import requests
import time
BASE_PORTAL = "https://www.ebi.ac.uk/ena/portal/api"
BASE_BROWSER = "https://www.ebi.ac.uk/ena/browser/api"
BASE_TAXONOMY = "https://www.ebi.ac.uk/ena/taxonomy/rest"
BASE_XREF = "https://www.ebi.ac.uk/ena/xref/rest"
def ena_query(endpoint, params=None, base=BASE_PORTAL):
"""Reusable ENA API caller with rate-limit compliance."""
resp = requests.get(f"{base}/{endpoint}", params=params)
resp.raise_for_status()
time.sleep(0.02) # 50 req/sec limit
return resp
# Search for human RNA-seq studies
resp = ena_query("search", params={
"result": "study",
"query": 'tax_tree(9606)', # `library_strategy` is a `read_run`/`read_experiment` field, not a `study` field
"fields": "study_accession,study_title",
"format": "json",
"limit": 3,
})
studies = resp.json()
for s in studies:
print(f"{s['study_accession']}: {s['study_title'][:60]}")
# PRJEB12345: Transcriptome analysis of human liver tissue...
Core API
Module 1: Portal API Search
The Portal API provides advanced metadata search across all ENA data types with boolean query syntax, field selection, and pagination.
# Search read runs for a specific study
resp = ena_query("search", params={
"result": "read_run",
"query": 'study_accession="PRJEB1787"',
"fields": "run_accession,sample_accession,instrument_model,read_count,base_count",
"format": "json",
"limit": 5,
})
runs = resp.json()
for r in runs:
print(f"{r['run_accession']} — {r.get('instrument_model', 'N/A')}, "
f"{int(r.get('read_count', 0)):,} reads")
# ERR123456 — Illumina HiSeq 2000, 45,231,890 reads
# Count total results without fetching data
count_resp = ena_query("count", params={
"result": "read_run",
"query": 'study_accession="PRJEB1787"',
})
print(f"Total runs: {count_resp.text.strip()}")
# Total runs: 142
Module 2: Browser API Retrieval
Fetch individual records by accession in multiple formats: XML, FASTA, EMBL flat-file, or plain text.
# Retrieve XML metadata for a study
resp = ena_query("xml/PRJEB1787", base=BASE_BROWSER)
print(resp.text[:300])
# <?xml version="1.0" encoding="UTF-8"?><PROJECT_SET>...
# Retrieve FASTA sequence for a coding sequence
resp = ena_query("fasta/M10051.1", base=BASE_BROWSER)
print(resp.text[:200])
# >ENA|M10051|M10051.1 Human insulin mRNA, complete cds.
# AGCCCTCCAGGACAGGCTGCAT...
# Retrieve EMBL flat-file format
resp = ena_query("embl/M10051.1", base=BASE_BROWSER)
print(resp.text[:300])
# ID M10051; SV 1; linear; mRNA; STD; HUM; 786 BP.
# ...
Module 3: File Reports and Downloads
Get download URLs for FASTQ, submitted, and analysis files. File reports return FTP and Aspera paths.
# Get FASTQ file URLs for specific runs
resp = ena_query("filereport", params={
"accession": "ERR000589",
"result": "read_run",
"fields": "run_accession,fastq_ftp,fastq_bytes,fastq_md5",
"format": "json",
})
files = resp.json()
for f in files:
ftp_urls = f.get("fastq_ftp", "").split(";")
sizes = f.get("fastq_bytes", "").split(";")
for url, size in zip(ftp_urls, sizes):
if url:
print(f"ftp://{url} ({int(size)/1e6:.1f} MB)")
# ftp://ftp.sra.ebi.ac.uk/vol1/fastq/ERR000/ERR000589/ERR000589_1.fastq.gz (234.5 MB)
# ftp://ftp.sra.ebi.ac.uk/vol1/fastq/ERR000/ERR000589/ERR000589_2.fastq.gz (241.2 MB)
Module 4: Taxonomy Queries
Look up organisms by taxonomy ID, scientific name, or partial name match.
# Lookup by taxonomy ID
resp = ena_query("tax-id/9606", base=BASE_TAXONOMY)
tax = resp.json()
print(f"{tax['scientificName']} (taxId: {tax['taxId']}, rank: {tax['rank']})")
# Homo sapiens (taxId: 9606, rank: species)
print(f"Lineage: {tax['lineage'][:80]}...")
# Search by scientific name — endpoint returns a list (one entry per matching taxon)
resp = ena_query("scientific-name/Arabidopsis thaliana", base=BASE_TAXONOMY)
matches = resp.json()
result = matches[0] if isinstance(matches, list) else matches
print(f"Tax ID: {result['taxId']}, Common: {result.get('commonName', 'N/A')}")
# Tax ID: 3702, Common: thale cress
# Suggest organisms by partial name
resp = ena_query("suggest-for-search/salmo", base=BASE_TAXONOMY)
suggestions = resp.json()
for s in suggestions[:3]:
print(f" {s['scientificName']} (taxId: {s['taxId']})")
# Salmo salar (taxId: 8030)
# Salmo trutta (taxId: 8032)
# Salmonella enterica (taxId: 28901)
Module 5: Cross-Reference Service
Find links between ENA records and external databases (ArrayExpress, UniProt, PDB, etc.).
# Find cross-references for an ENA accession
resp = ena_query("json/search", base=BASE_XREF, params={
"accession": "M10051",
})
xrefs = resp.json()
for x in xrefs[:5]:
print(f" {x['Source']} → {x['Source Primary Accession']} "
f"({x.get('Source Description', '')[:50]})")
# UniProt → P01308 (Insulin precursor)
# PDB → 1A7F (Crystal structure of human insulin)
# Search cross-references by external database
resp = ena_query("json/search", base=BASE_XREF, params={
"source": "UniProt",
"accession": "P01308",
})
xrefs = resp.json()
for x in xrefs[:3]:
print(f" ENA: {x['Target Primary Accession']} — {x.get('Target Description', '')[:60]}")
Module 6: CRAM Reference Registry
Retrieve reference sequences used in CRAM files by MD5 or SHA1 checksum. Essential for CRAM decompression.
# Look up reference by MD5 checksum
md5 = "aef131c3b4b05d8e2b3f907faba5af9b" # example
try:
resp = ena_query(
f"cram/md5/{md5}",
base="https://www.ebi.ac.uk/ena/cram"
)
print(f"Reference found: {len(resp.content)} bytes")
except requests.HTTPError as e:
if e.response.status_code == 404:
print("Reference not found — check MD5 checksum")
else:
raise
Key Concepts
ENA Data Hierarchy
| Level | Accession Prefix | Description | Contains |
|---|---|---|---|
| Study | PRJEB/ERP | Research project | Samples, Experiments |
| Sample | ERS/SAMEA | Biological sample | Metadata, taxonomy |
| Experiment | ERX | Library/sequencing setup | Runs |
| Run | ERR | Sequencing run | Raw read files (FASTQ) |
| Analysis | ERZ | Derived analysis | Assemblies, alignments |
| Assembly | GCA | Genome assembly | Contigs, scaffolds |
| Sequence | Accession.version | Annotated sequence | Features, coding seqs |
Query Syntax Operators
| Operator | Example | Description |
|---|---|---|
| Equality | instrument_model="Illumina NovaSeq 6000" |
Exact match |
| Wildcard | study_title="*melanoma*" |
Partial match |
| Range | base_count>=1000000 |
Numeric comparison |
| Taxonomy tree | tax_tree(9606) |
Taxon and all descendants |
| Exact taxon | tax_eq(9606) |
Exact taxon only |
| Date range | first_public>=2023-01-01 |
Date filtering |
| Boolean | AND, OR, NOT |
Combine conditions |
| Grouping | (A OR B) AND C |
Parenthetical grouping |
Result Types
| Result Type | Description | Key Fields |
|---|---|---|
study |
Research projects | study_accession, study_title, center_name |
sample |
Biological samples | sample_accession, tax_id, scientific_name |
read_run |
Sequencing runs | run_accession, read_count, base_count, fastq_ftp |
read_experiment |
Experiments | experiment_accession, library_strategy, instrument_model |
analysis |
Derived analyses | analysis_accession, analysis_type |
assembly |
Genome assemblies | assembly_accession, assembly_level, genome_representation |
sequence |
Annotated sequences | accession, sequence_length, mol_type |
wgs_set |
WGS scaffold sets | set_accession, set_size |
tsa_set |
Transcriptome assemblies | set_accession, set_size |
coding |
Coding sequences | accession, gene, product |
noncoding |
Non-coding features | accession, description |
taxon |
Taxonomy entries | tax_id, scientific_name, lineage |
Discoverable Fields
Use the returnFields endpoint to discover available fields for any result type:
resp = ena_query("returnFields", params={"result": "read_run"})
fields = resp.text.strip().split("\n")
print(f"Available fields for read_run: {len(fields)}")
print(fields[:10])
# ['accession', 'altitude', 'assembly_quality', 'assembly_software', ...]
Common Workflows
Workflow 1: Study Exploration Pipeline
Search for a study, list its samples, then retrieve run metadata.
import json
# Step 1: Find studies by organism — `study_title="*…*"` wildcards no longer match;
# use `tax_tree()` against the species tax ID (SARS-CoV-2 = 2697049).
resp = ena_query("search", params={
"result": "study",
"query": 'tax_tree(2697049) AND first_public>=2023-01-01',
"fields": "study_accession,study_title,center_name",
"format": "json",
"limit": 3,
})
studies = resp.json()
study_acc = studies[0]["study_accession"]
print(f"Selected: {study_acc} — {studies[0]['study_title'][:60]}")
# Step 2: List samples in the study
resp = ena_query("search", params={
"result": "sample",
"query": f'study_accession="{study_acc}"',
"fields": "sample_accession,scientific_name,collection_date",
"format": "json",
"limit": 5,
})
samples = resp.json()
print(f"Found {len(samples)} samples (showing first 5)")
for s in samples:
print(f" {s['sample_accession']} — {s.get('scientific_name', 'N/A')}")
# Step 3: Get run metadata for each sample
for s in samples[:2]:
resp = ena_query("search", params={
"result": "read_run",
"query": f'sample_accession="{s["sample_accession"]}"',
"fields": "run_accession,instrument_model,read_count,library_strategy",
"format": "json",
})
runs = resp.json()
for r in runs:
print(f" {r['run_accession']}: {r.get('library_strategy','N/A')}, "
f"{int(r.get('read_count',0)):,} reads")
time.sleep(0.02)
Workflow 2: Bulk FASTQ Download URL Collection
Search for runs matching criteria and collect download URLs.
# Step 1: Search for Illumina RNA-Seq runs from a specific organism
resp = ena_query("search", params={
"result": "read_run",
"query": ('tax_tree(10090) AND library_strategy="RNA-Seq" '
'AND instrument_platform="ILLUMINA" AND read_count>=10000000'),
"fields": "run_accession,study_accession,read_count",
"format": "json",
"limit": 10,
})
runs = resp.json()
print(f"Found {len(runs)} runs meeting criteria")
# Step 2: Get file reports with download URLs
download_list = []
for run in runs[:5]:
acc = run["run_accession"]
resp = ena_query("filereport", params={
"accession": acc,
"result": "read_run",
"fields": "run_accession,fastq_ftp,fastq_bytes,fastq_md5",
"format": "json",
})
for f in resp.json():
urls = f.get("fastq_ftp", "").split(";")
md5s = f.get("fastq_md5", "").split(";")
for url, md5 in zip(urls, md5s):
if url:
download_list.append({"url": f"ftp://{url}", "md5": md5, "run": acc})
time.sleep(0.02)
print(f"\nDownload list: {len(download_list)} files")
for d in download_list[:4]:
print(f" {d['run']}: {d['url'].split('/')[-1]}")
Workflow 3: Taxonomic Assembly Exploration
Find organisms, search their assemblies, and check quality statistics.
# Step 1: Resolve organism by exact scientific name (returns a list, take the first match).
# `suggest-for-search` only returns prefix matches and may not include the species you want.
resp = ena_query("scientific-name/Drosophila melanogaster", base=BASE_TAXONOMY)
matches = resp.json()
target_tax = matches[0]["taxId"]
print(f"Selected: {matches[0]['scientificName']} (taxId={target_tax})")
# Step 2: Search assemblies for this organism. `n50` is no longer a valid `assembly`
# field — use `base_count` and `program` for what's available.
resp = ena_query("search", params={
"result": "assembly",
"query": f'tax_eq({target_tax}) AND assembly_level="chromosome"',
"fields": ("assembly_accession,assembly_name,assembly_level,"
"genome_representation,base_count"),
"format": "json",
"limit": 5,
})
assemblies = resp.json()
for a in assemblies:
size = int(a.get("base_count", 0))
print(f" {a['assembly_accession']}: {a.get('assembly_name','N/A')}, "
f"Size={size/1e6:.1f} Mb")
# GCA_000001215.4: Release 6 plus ISO1 MT, Size=143.7 Mb
Key Parameters
| Endpoint | Parameter | Default | Description |
|---|---|---|---|
search |
result |
(required) | Result type: study, sample, read_run, assembly, etc. |
search |
query |
(required) | Boolean query string with field operators |
search |
fields |
all | Comma-separated field names to return |
search |
format |
tsv | Output format: json, tsv, xml |
search |
limit |
100000 | Max results (0 = unlimited streaming) |
search |
offset |
0 | Skip first N results (pagination) |
search |
sortFields |
— | Field(s) to sort by |
filereport |
accession |
(required) | Study, sample, or run accession |
filereport |
result |
(required) | read_run, analysis, etc. |
xml/ |
accession path | (required) | Any ENA accession (Browser API) |
fasta/ |
accession path | (required) | Sequence accession (Browser API) |
Best Practices
- Use
tax_tree()overtax_eq()for organism queries — it includes subspecies and strains automatically - Request only needed fields — reduces response size and server load significantly
- Prefer JSON format for programmatic access; TSV for large bulk exports (lower overhead)
- Use
limit=0for streaming large result sets directly to file, avoiding memory issues - Check
fastq_ftpandsubmitted_ftp— some runs have submitted files but no processed FASTQ - Verify downloads with MD5 — file reports include
fastq_md5for integrity checking - Anti-pattern: Do not fetch all fields then filter client-side — use query syntax server-side
Common Recipes
Recipe: Assembly Quality Filtering
# Find chromosome-level, full-representation assemblies.
# `n50` is no longer a valid `assembly` field — filter by base_count instead.
resp = ena_query("search", params={
"result": "assembly",
"query": ('tax_tree(7742) AND assembly_level="chromosome" '
'AND genome_representation="full"'),
"fields": "assembly_accession,scientific_name,base_count,assembly_level",
"format": "json",
"limit": 10,
})
for a in resp.json():
size = int(a.get("base_count", 0))
if size > 100_000_000: # >100 Mb (proxy for "well-assembled")
print(f"{a['assembly_accession']}: {a.get('scientific_name','?')}, Size={size/1e6:.0f} Mb")
Recipe: Cross-Database Linking
# Find UniProt/PDB cross-references for an ENA sequence
resp = ena_query("json/search", base=BASE_XREF, params={
"accession": "M10051",
})
xrefs = resp.json()
by_source = {}
for x in xrefs:
src = x.get("Source", "unknown")
by_source.setdefault(src, []).append(x["Source Primary Accession"])
for src, accs in by_source.items():
print(f" {src}: {', '.join(accs[:5])}")
# UniProt: P01308
# PDB: 1A7F, 1AI0, 1BEN
Recipe: Retry Session with Exponential Backoff
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def ena_session():
"""Create a requests session with retry logic for ENA APIs."""
session = requests.Session()
retry = Retry(
total=3, backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504],
)
session.mount("https://", HTTPAdapter(max_retries=retry))
return session
session = ena_session()
resp = session.get(f"{BASE_PORTAL}/search", params={
"result": "study",
"query": 'tax_tree(9606)',
"fields": "study_accession",
"format": "json",
"limit": 5,
})
print(f"Status: {resp.status_code}, results: {len(resp.json())}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
400 Bad Request on search |
Invalid query syntax or unknown field name | Use returnFields endpoint to verify field names; check query operator syntax |
400 with result param |
Invalid result type | Check result types table above; common: read_run not run |
| Empty results for known data | Wrong taxonomy operator | Use tax_tree() (includes descendants) not tax_eq() (exact only) |
fastq_ftp field is empty |
Submitted files not processed to FASTQ | Check submitted_ftp field instead; some datasets only have BAM/CRAM |
429 Too Many Requests |
Exceeded 50 req/sec rate limit | Add time.sleep(0.02) between requests; use retry session with backoff |
| Timeout on large queries | Result set too large for single request | Use limit + offset pagination, or limit=0 with streaming to file |
| XML parsing errors | Malformed XML for some records | Use JSON format instead (format=json) when available |
| Wrong sequence version | Accession without version suffix | Always use versioned accessions (e.g., M10051.1 not M10051) for Browser API |
| CRAM reference not found | MD5 checksum mismatch or non-INSDC reference | Verify MD5; check if reference is from a custom genome (not in registry) |
Bundled Resources
This skill is self-contained. The original entry had a separate references/api_reference.md (490 lines) covering all 6 API endpoints in detail. That content has been fully consolidated inline:
- Portal API (search, count, returnFields, filereport) — Core API Modules 1 and 3, Key Parameters table
- Browser API (XML, FASTA, EMBL retrieval) — Core API Module 2
- Taxonomy REST API (tax-id, scientific-name, suggest-for-search) — Core API Module 4
- Cross-Reference Service (json/search) — Core API Module 5
- CRAM Reference Registry (md5/sha1 lookup) — Core API Module 6
- Rate limiting and error handling — Prerequisites, Troubleshooting, Recipe 3 (retry session)
- Query syntax and result types — Key Concepts section (3 tables)
- Pagination and bulk download — Key Parameters, Best Practices, Workflow 2
- Omitted: detailed EMBL format field-by-field breakdown (rarely needed programmatically); Aspera download command examples (tool-specific, not requests-based)
Related Skills
- bioservices-multi-database — unified Python interface covering ENA via bioservices; prefer for multi-database workflows
- pubmed-database — PubMed literature search via NCBI E-utilities
- pysam-genomic-files — downstream processing of FASTQ/BAM/CRAM files retrieved from ENA
- biopython-molecular-biology — NCBI Entrez access and sequence parsing (GenBank/FASTA)
- ncbi-blast (planned) — BLAST sequence similarity search
References
- ENA Portal API docs: https://www.ebi.ac.uk/ena/portal/api/doc
- ENA Browser API docs: https://www.ebi.ac.uk/ena/browser/api/doc
- ENA Taxonomy REST API: https://www.ebi.ac.uk/ena/taxonomy/rest/
- ENA Cross-Reference Service: https://www.ebi.ac.uk/ena/xref/rest/
- ENA data model guide: https://ena-docs.readthedocs.io/en/latest/submit/general-guide/data-model.html
- INSDC standards: https://www.insdc.org/
skills/genomics-bioinformatics/databases/encode-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill encode-database -g -y
SKILL.md
Frontmatter
{
"name": "encode-database",
"license": "CC-BY-4.0",
"description": "ENCODE Portal REST API for regulatory genomics: TF ChIP-seq, ATAC-seq\/DNase-seq peaks, histone marks, and RNA-seq across 1000+ cell types. Search experiments by assay\/biosample\/target; download BED\/bigWig; retrieve SCREEN cCREs by region or gene. Use to annotate variants with regulatory tracks, find open chromatin in a cell type, or fetch peak files for ChIP\/ATAC analysis. For regulatory variant scoring use regulomedb-database; for GWAS associations use gwas-database."
}
ENCODE Database
Overview
The ENCODE (Encyclopedia of DNA Elements) Project has generated thousands of functional genomics experiments — TF ChIP-seq, ATAC-seq, DNase-seq, histone ChIP-seq, and RNA-seq — across 1000+ human and mouse cell types and tissues. The ENCODE Portal REST API provides structured JSON access to experiment metadata, file download URLs, and SCREEN cCRE (candidate cis-Regulatory Elements) annotations. All data is freely accessible without authentication for most endpoints.
When to Use
- Downloading TF ChIP-seq peak files (BED) for a specific transcription factor and cell type to annotate regulatory regions
- Finding ATAC-seq or DNase-seq peaks in a cell type to identify open chromatin regions near a gene of interest
- Retrieving cCREs (candidate cis-Regulatory Elements) overlapping a genomic region from ENCODE SCREEN
- Building reference regulatory tracks for variant annotation pipelines (e.g., annotating VCF variants against ENCODE peak sets)
- Exploring which experiments are available for a biosample (cell line, tissue, developmental stage) before planning a wet-lab experiment
- Querying all ChIP-seq experiments for a transcription factor across multiple cell types for comparative regulatory analysis
- Use
regulomedb-databaseinstead when you want pre-computed regulatory scores for specific SNPs — RegulomeDB integrates ENCODE data with eQTL and motif evidence into a single score - Use
deeptools-ngs-analysisinstead when you have your own BAM files and need to generate bigWig coverage tracks; ENCODE database is for retrieving existing deposited data
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: experiment accessions (e.g.,
ENCSR000AKC), biosample names (e.g.,K562), TF target names (e.g.,CTCF,TP53), or genomic regions (chr7:117548628-117748628) - Environment: internet connection; no authentication required for public data; add
Authorization: Bearer {api_key}header for submitter access - Rate limits: no published hard limit; add
time.sleep(0.5)for large batch queries to avoid connection resets
pip install requests pandas matplotlib
Quick Start
import requests
BASE = "https://www.encodeproject.org"
def search_experiments(assay="TF ChIP-seq", target="CTCF", biosample="K562", limit=5):
"""Find ENCODE experiments matching assay type, target, and biosample."""
params = {
"type": "Experiment",
"assay_title": assay,
"target.label": target,
"biosample_ontology.term_name": biosample, # `biosample_summary` is a verbose freetext string; filter by ontology term name
"status": "released",
"format": "json",
"limit": limit,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
data = r.json()
experiments = data.get("@graph", [])
print(f"Found {data.get('total', 0)} experiments for {target} ChIP-seq in {biosample}")
for exp in experiments:
print(f" {exp['accession']} {exp.get('biosample_summary', '')} {exp.get('lab', {}).get('title', '')}")
return experiments
exps = search_experiments(assay="TF ChIP-seq", target="CTCF", biosample="K562")
Core API
Query 1: Experiment Search — Find Experiments by Assay, Biosample, Target
Search the ENCODE Portal for experiments matching structured criteria.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def search_experiments(assay_title=None, target=None, biosample=None,
organism="Homo sapiens", status="released", limit=50):
"""
Search ENCODE experiments with flexible filters.
Returns: pd.DataFrame of matching experiments.
"""
params = {
"type": "Experiment",
"status": status,
"replicates.library.biosample.donor.organism.scientific_name": organism,
"format": "json",
"limit": limit,
}
if assay_title:
params["assay_title"] = assay_title
if target:
params["target.label"] = target
if biosample:
params["biosample_ontology.term_name"] = biosample # filter by ontology term, not the freetext `biosample_summary`
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
data = r.json()
total = data.get("total", 0)
print(f"Total matching experiments: {total} (showing {min(limit, total)})")
records = []
for exp in data.get("@graph", []):
records.append({
"accession": exp.get("accession"),
"assay": exp.get("assay_title"),
"biosample": exp.get("biosample_summary"),
"target": exp.get("target", {}).get("label", ""),
"lab": exp.get("lab", {}).get("title", ""),
"date_released": exp.get("date_released", ""),
})
df = pd.DataFrame(records)
print(df.to_string(index=False))
return df
# CTCF ChIP-seq in HCT116 colon cancer cells
df = search_experiments(assay_title="TF ChIP-seq", target="CTCF", biosample="HCT116")
# ATAC-seq experiments in multiple cell types
df_atac = search_experiments(assay_title="ATAC-seq", limit=20)
print(f"\nUnique cell types: {df_atac['biosample'].nunique()}")
Query 2: File Download — Get Metadata and Download URLs for BED/bigWig Files
Retrieve file metadata for a specific experiment and obtain download URLs.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def get_experiment_files(accession, file_format="bed", output_type="peaks",
assembly="GRCh38"):
"""
Get file download URLs for a specific ENCODE experiment.
accession: experiment accession, e.g. 'ENCSR000AKC'
file_format: 'bed', 'bigWig', 'fastq', 'bam'
output_type: 'peaks', 'signal', 'alignments', 'reads'
Returns: pd.DataFrame of matching files with download URLs.
"""
params = {
"type": "File",
"dataset": f"/experiments/{accession}/",
"file_format": file_format,
"output_type": output_type,
"assembly": assembly,
"status": "released",
"format": "json",
"limit": 50,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
data = r.json()
files = data.get("@graph", [])
print(f"Found {len(files)} {file_format} files ({output_type}) for {accession}")
records = []
for f in files:
records.append({
"file_accession": f.get("accession"),
"file_format": f.get("file_format"),
"output_type": f.get("output_type"),
"assembly": f.get("assembly"),
"file_size_mb": round(f.get("file_size", 0) / 1e6, 2),
"download_url": BASE + f.get("href", ""),
"biological_replicate": str(f.get("biological_replicates", [])),
})
df = pd.DataFrame(records)
print(df[["file_accession", "output_type", "assembly", "file_size_mb"]].to_string(index=False))
return df
# Get peak BED files for a CTCF ChIP-seq experiment
df_files = get_experiment_files("ENCSR000AKC", file_format="bed", output_type="peaks")
import urllib.request
def download_encode_file(download_url, output_path):
"""Download an ENCODE file from its href URL."""
print(f"Downloading {download_url} → {output_path}")
urllib.request.urlretrieve(download_url, output_path)
import os
size_mb = os.path.getsize(output_path) / 1e6
print(f"Downloaded {size_mb:.1f} MB → {output_path}")
# Download the first peak file
if len(df_files) > 0:
url = df_files.iloc[0]["download_url"]
download_encode_file(url, "CTCF_peaks.bed.gz")
Query 3: cCRE Region Query — ENCODE SCREEN Candidate cis-Regulatory Elements
Query ENCODE SCREEN for candidate cis-Regulatory Elements in a genomic region using the SCREEN API.
import requests, pandas as pd
SCREEN_BASE = "https://api.encodeproject.org/screen"
def search_ccres_by_region(chrom, start, end, assembly="GRCh38", limit=500):
"""
Find candidate cis-Regulatory Elements (cCREs) in a genomic region.
cCRE groups: PLS (promoter-like), pELS (proximal enhancer-like),
dELS (distal enhancer-like), DNase-H3K4me3, CTCF-only.
Returns: pd.DataFrame of cCREs with scores.
"""
payload = {
"assembly": assembly,
"coord_chrom": chrom,
"coord_start": start,
"coord_end": end,
"limit": limit,
}
r = requests.post(f"{SCREEN_BASE}/search/", json=payload, timeout=30)
r.raise_for_status()
data = r.json()
ccres = data.get("results", data.get("cCREs", []))
print(f"Found {len(ccres)} cCREs in {chrom}:{start}-{end}")
records = []
for c in ccres:
records.append({
"accession": c.get("accession"),
"chrom": c.get("chrom"),
"start": c.get("start"),
"end": c.get("end"),
"group": c.get("group"),
"dnase_zscore": round(c.get("dnase_zscore", 0), 2),
"h3k4me3_zscore": round(c.get("h3k4me3_zscore", 0), 2),
"h3k27ac_zscore": round(c.get("h3k27ac_zscore", 0), 2),
"ctcf_zscore": round(c.get("ctcf_zscore", 0), 2),
})
df = pd.DataFrame(records)
if len(df):
print(df["group"].value_counts().to_string())
return df
# cCREs in the TP53 locus (GRCh38)
df_ccres = search_ccres_by_region("chr17", 7_661_779, 7_887_538)
print(f"\nTotal cCREs: {len(df_ccres)}")
print(df_ccres.sort_values("h3k27ac_zscore", ascending=False).head(5).to_string(index=False))
Query 4: Gene cCRE Lookup — Find cCREs Near a Gene
Retrieve cCREs in the vicinity of a specific gene using SCREEN's gene-centric endpoint.
import requests, pandas as pd
SCREEN_BASE = "https://api.encodeproject.org/screen"
ENCODE_BASE = "https://www.encodeproject.org"
def get_gene_ccres(gene_symbol, assembly="GRCh38", window_kb=50):
"""
Get cCREs near a gene using ENCODE SCREEN gene search.
Returns: pd.DataFrame of nearby cCREs.
"""
# Step 1: resolve gene to genomic coordinates via ENCODE gene search
r = requests.get(
f"{ENCODE_BASE}/search/",
params={"type": "Gene", "symbol": gene_symbol, "assembly": assembly,
"format": "json", "limit": 1},
timeout=30
)
r.raise_for_status()
genes = r.json().get("@graph", [])
if not genes:
print(f"Gene {gene_symbol} not found in ENCODE")
return pd.DataFrame()
gene = genes[0]
chrom = gene.get("locations", [{}])[0].get("chromosome", "")
start = gene.get("locations", [{}])[0].get("start", 0)
end = gene.get("locations", [{}])[0].get("end", 0)
print(f"{gene_symbol}: {chrom}:{start}-{end}")
# Step 2: search cCREs in expanded window
window = window_kb * 1000
df = search_ccres_by_region(chrom, max(0, start - window), end + window, assembly=assembly)
df["distance_to_gene"] = df.apply(
lambda row: max(0, max(start - row["end"], row["start"] - end)), axis=1
)
return df.sort_values("distance_to_gene")
df_brca1_ccres = get_gene_ccres("BRCA1", window_kb=100)
print(f"\ncCREs near BRCA1: {len(df_brca1_ccres)}")
print(df_brca1_ccres[["accession", "group", "start", "end", "h3k27ac_zscore"]].head(10).to_string(index=False))
Query 5: Biosample Browser — List Available Cell Types and Tissues
Enumerate biosample terms (cell lines, primary tissues, developmental stages) available in ENCODE.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def list_biosamples(organism="Homo sapiens", biosample_type=None, limit=200):
"""
List biosamples available in ENCODE.
biosample_type: 'cell line', 'primary cell', 'tissue', 'in vitro differentiated cells'
Returns: pd.DataFrame of biosample terms with experiment counts.
"""
params = {
"type": "Biosample",
"donor.organism.scientific_name": organism,
"format": "json",
"limit": limit,
}
if biosample_type:
params["biosample_ontology.classification"] = biosample_type
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
data = r.json()
biosamples = data.get("@graph", [])
print(f"Found {data.get('total', 0)} biosamples (showing {len(biosamples)})")
records = []
for bs in biosamples:
records.append({
"biosample_name": bs.get("biosample_ontology", {}).get("term_name", bs.get("accession")),
"classification": bs.get("biosample_ontology", {}).get("classification"),
"tissue": bs.get("biosample_ontology", {}).get("organ_slims", [""])[0] if bs.get("biosample_ontology", {}).get("organ_slims") else "",
"accession": bs.get("accession"),
})
df = pd.DataFrame(records).drop_duplicates("biosample_name").reset_index(drop=True)
print(df["classification"].value_counts().to_string())
return df
df_bs = list_biosamples(organism="Homo sapiens", biosample_type="cell line")
print(f"\nUnique human cell lines: {len(df_bs)}")
print(df_bs.head(10).to_string(index=False))
Query 6: Target/TF Query — All ChIP-seq Experiments for a Transcription Factor
Retrieve all released ChIP-seq experiments for a given TF across all available cell types.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def get_tf_experiments(tf_name, assay="TF ChIP-seq", assembly="GRCh38", limit=200):
"""
Find all ENCODE experiments for a given transcription factor.
Returns: pd.DataFrame of experiments sorted by biosample.
"""
params = {
"type": "Experiment",
"assay_title": assay,
"target.label": tf_name,
"files.assembly": assembly,
"status": "released",
"format": "json",
"limit": limit,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
data = r.json()
total = data.get("total", 0)
print(f"{tf_name} {assay}: {total} total experiments (assembly {assembly})")
records = []
for exp in data.get("@graph", []):
records.append({
"accession": exp.get("accession"),
"biosample": exp.get("biosample_summary"),
"lab": exp.get("lab", {}).get("title", ""),
"date_released": exp.get("date_released", ""),
})
df = pd.DataFrame(records).sort_values("biosample")
print(f"Showing {len(df)} experiments across {df['biosample'].nunique()} cell types/tissues")
print(df.head(10).to_string(index=False))
return df
df_tp53 = get_tf_experiments("TP53", assay="TF ChIP-seq")
Query 7: Peak Set Retrieval — Get Optimal Peak Set for an Experiment
Retrieve the optimal replicated peak set (IDR-filtered or pooled) from an experiment.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def get_optimal_peaks(accession, assembly="GRCh38"):
"""
Retrieve the optimal peak file (IDR-filtered or pooled) from a ChIP-seq experiment.
Returns: dict with file accession, download URL, and file metadata.
"""
r = requests.get(f"{BASE}/experiments/{accession}/?format=json", timeout=30)
r.raise_for_status()
exp = r.json()
# Preferred output types in order of preference
preferred = ["IDR thresholded peaks", "optimal IDR thresholded peaks",
"peaks", "replicated peaks"]
matching = []
for f in exp.get("files", []):
if (f.get("file_format") == "bed"
and f.get("assembly") == assembly
and f.get("status") == "released"
and f.get("output_type") in preferred):
matching.append({
"file_accession": f.get("accession"),
"output_type": f.get("output_type"),
"file_size_mb": round(f.get("file_size", 0) / 1e6, 2),
"download_url": BASE + f.get("href", ""),
"biological_replicates": f.get("biological_replicates"),
})
if not matching:
print(f"No peak BED files found for {accession} ({assembly})")
return None
# Pick the highest-preference output type
for pref in preferred:
for f in matching:
if f["output_type"] == pref:
print(f"Selected: {f['file_accession']} ({f['output_type']}, {f['file_size_mb']} MB)")
print(f"URL: {f['download_url']}")
return f
best = matching[0]
print(f"Fallback: {best['file_accession']} ({best['output_type']})")
return best
peak_file = get_optimal_peaks("ENCSR000AKC", assembly="GRCh38")
Key Concepts
ENCODE Data Tiers and File Hierarchy
Each ENCODE experiment has multiple associated files in a hierarchy:
Experiment (e.g., ENCSR000AKC: CTCF ChIP-seq in K562)
├── Raw data: FASTQ files (reads, per replicate)
├── Alignments: BAM files (per replicate, GRCh38)
├── Signal tracks: bigWig (fold-change over control, p-value signal)
└── Peak calls (BED):
├── Replicate 1 peaks (narrow/broad)
├── Replicate 2 peaks
├── Pooled peaks
├── IDR thresholded peaks ← optimal for most analyses
└── Optimal IDR thresholded peaks ← preferred default
For most downstream analyses, use IDR thresholded peaks or optimal IDR thresholded peaks — these are the reproducibility-filtered, high-confidence peak sets.
cCRE Classification System
ENCODE SCREEN classifies cCREs into five functional groups based on epigenomic signal z-scores:
| Group | Full Name | Signals | Interpretation |
|---|---|---|---|
| PLS | Promoter-Like Sequence | High DNase + High H3K4me3 + High H3K27ac | Active promoter region |
| pELS | Proximal Enhancer-Like Sequence | High DNase + High H3K27ac, ≤2 kb from TSS | Proximal enhancer |
| dELS | Distal Enhancer-Like Sequence | High DNase + High H3K27ac, >2 kb from TSS | Distal enhancer |
| DNase-H3K4me3 | — | High DNase + High H3K4me3, low H3K27ac | Unusual promoter signature |
| CTCF-only | — | High CTCF, low H3K27ac | Insulator/boundary element |
Z-scores >1.64 (p < 0.05) are considered significant in each signal category.
Common Workflows
Workflow 1: Download TF Peak Files for a Cell Type
Goal: Retrieve CTCF ChIP-seq peak BED files for a specific cell line and load them into a DataFrame for variant annotation.
import requests, pandas as pd, time, gzip, io
BASE = "https://www.encodeproject.org"
def download_tf_peaks_for_cell_type(tf_name, biosample, assembly="GRCh38"):
"""Find and download IDR peak BED for a TF in a specific cell type."""
# Step 1: find experiments
params = {
"type": "Experiment",
"assay_title": "TF ChIP-seq",
"target.label": tf_name,
"biosample_ontology.term_name": biosample, # filter by ontology term, not the freetext `biosample_summary`
"files.assembly": assembly,
"status": "released",
"format": "json",
"limit": 5,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
exps = r.json().get("@graph", [])
if not exps:
print(f"No {tf_name} experiments found for {biosample}")
return None
accession = exps[0]["accession"]
print(f"Using experiment {accession}")
# Step 2: find optimal peak file
peak = get_optimal_peaks(accession, assembly=assembly)
if not peak:
return None
# Step 3: download and parse BED
r2 = requests.get(peak["download_url"], timeout=120, stream=True)
r2.raise_for_status()
content = r2.content
if peak["download_url"].endswith(".gz"):
content = gzip.decompress(content)
lines = content.decode("utf-8").strip().split("\n")
rows = [l.split("\t") for l in lines if l and not l.startswith("#")]
# Narrow peak BED6+4 columns
cols = ["chrom", "start", "end", "name", "score", "strand",
"signalValue", "pValue", "qValue", "peak"]
df = pd.DataFrame(rows, columns=cols[:len(rows[0])] if rows else cols)
df["start"] = pd.to_numeric(df["start"])
df["end"] = pd.to_numeric(df["end"])
print(f"Loaded {len(df):,} peaks for {tf_name} in {biosample} ({assembly})")
print(f"Genome coverage: {(df['end'] - df['start']).sum() / 1e6:.1f} Mb")
return df
df_peaks = download_tf_peaks_for_cell_type("CTCF", "K562")
if df_peaks is not None:
df_peaks.to_csv("CTCF_K562_peaks.bed", sep="\t", index=False, header=False)
print("Saved CTCF_K562_peaks.bed")
Workflow 2: cCRE Category Bar Chart for a Genomic Region
Goal: Query SCREEN for cCREs in a disease locus and visualize the distribution of regulatory element types.
import requests, pandas as pd
import matplotlib.pyplot as plt
SCREEN_BASE = "https://api.encodeproject.org/screen"
def ccre_category_chart(chrom, start, end, assembly="GRCh38", label="Region"):
"""Query SCREEN cCREs and plot category distribution."""
payload = {"assembly": assembly, "coord_chrom": chrom,
"coord_start": start, "coord_end": end, "limit": 1000}
r = requests.post(f"{SCREEN_BASE}/search/", json=payload, timeout=30)
r.raise_for_status()
data = r.json()
ccres = data.get("results", data.get("cCREs", []))
if not ccres:
print(f"No cCREs found in {chrom}:{start}-{end}")
return pd.DataFrame()
df = pd.DataFrame(ccres)
print(f"cCREs in {chrom}:{start}-{end}: {len(df)}")
# Count by group
group_counts = df["group"].value_counts()
group_colors = {
"PLS": "#d62728", # red — promoters
"pELS": "#ff7f0e", # orange — proximal enhancers
"dELS": "#1f77b4", # blue — distal enhancers
"DNase-H3K4me3": "#9467bd", # purple
"CTCF-only": "#2ca02c", # green — insulators
}
colors = [group_colors.get(g, "#aec7e8") for g in group_counts.index]
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Bar chart of category counts
axes[0].bar(group_counts.index, group_counts.values, color=colors, edgecolor="black")
axes[0].set_xlabel("cCRE Category")
axes[0].set_ylabel("Count")
axes[0].set_title(f"cCRE Categories — {label}\n({chrom}:{start}-{end})")
for i, (cat, cnt) in enumerate(group_counts.items()):
axes[0].text(i, cnt + 0.3, str(cnt), ha="center", va="bottom", fontsize=9)
# H3K27ac z-score distribution by category
if "h3k27ac_zscore" in df.columns:
groups_present = [g for g in ["PLS", "pELS", "dELS"] if g in df["group"].values]
data_to_plot = [df.loc[df["group"] == g, "h3k27ac_zscore"].dropna().tolist()
for g in groups_present]
axes[1].boxplot(data_to_plot, labels=groups_present,
patch_artist=True,
boxprops=dict(facecolor="lightsteelblue"))
axes[1].set_xlabel("cCRE Group")
axes[1].set_ylabel("H3K27ac Z-score")
axes[1].set_title("H3K27ac Signal Strength by cCRE Group")
axes[1].axhline(1.64, color="red", linestyle="--", label="p<0.05 threshold")
axes[1].legend()
plt.tight_layout()
plt.savefig("ccre_category_chart.png", dpi=150, bbox_inches="tight")
print(f"Saved ccre_category_chart.png")
print(group_counts.to_string())
return df
# BRCA1/BRCA2 locus — chr17 region
df_ccres = ccre_category_chart("chr17", 43_044_295, 43_170_000, label="BRCA1 locus")
Workflow 3: Build a TF Peak Atlas Across Multiple Cell Types
Goal: Systematically collect peak BED accessions for one TF across all available cell types for a comparative regulatory analysis.
import requests, pandas as pd, time
BASE = "https://www.encodeproject.org"
def build_tf_peak_atlas(tf_name, assembly="GRCh38", max_experiments=50):
"""
Collect peak file metadata for a TF across all available cell types.
Returns: pd.DataFrame with one row per experiment (download URL included).
"""
params = {
"type": "Experiment",
"assay_title": "TF ChIP-seq",
"target.label": tf_name,
"files.assembly": assembly,
"status": "released",
"format": "json",
"limit": max_experiments,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
exps = r.json().get("@graph", [])
print(f"{tf_name}: {len(exps)} experiments across {assembly}")
atlas_records = []
for exp in exps:
acc = exp.get("accession")
peak = get_optimal_peaks(acc, assembly=assembly)
if peak:
atlas_records.append({
"experiment": acc,
"biosample": exp.get("biosample_summary", ""),
"lab": exp.get("lab", {}).get("title", ""),
"file_accession": peak["file_accession"],
"output_type": peak["output_type"],
"file_size_mb": peak["file_size_mb"],
"download_url": peak["download_url"],
})
time.sleep(0.3)
df = pd.DataFrame(atlas_records)
print(f"Peak files found: {len(df)} / {len(exps)} experiments")
df.to_csv(f"{tf_name}_peak_atlas_{assembly}.csv", index=False)
print(f"Saved {tf_name}_peak_atlas_{assembly}.csv")
return df
# Collect CTCF peak atlas
df_atlas = build_tf_peak_atlas("CTCF", max_experiments=20)
print(f"\nCell types covered: {df_atlas['biosample'].nunique()}")
Key Parameters
| Parameter | Endpoint / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
type |
All search endpoints | required | "Experiment", "File", "Gene", "Biosample" |
Result type to search |
assay_title |
Experiment search | — | "TF ChIP-seq", "ATAC-seq", "DNase-seq", "Histone ChIP-seq", "RNA-seq" |
Filter by assay type |
target.label |
Experiment search | — | TF name string, e.g. "CTCF", "TP53" |
Filter by target protein |
biosample_summary |
Experiment search | — | Cell type string, e.g. "K562", "HeLa-S3" |
Filter by biosample |
files.assembly |
Experiment search | — | "GRCh38", "GRCh37", "mm10" |
Filter by genome assembly |
output_type |
File search | — | "peaks", "IDR thresholded peaks", "signal", "alignments" |
Filter by file output type |
status |
All search endpoints | — | "released", "in progress", "archived" |
Filter by release status |
limit |
All search endpoints | 25 |
1–500 |
Max results per page |
coord_chrom |
SCREEN cCRE search | required | chromosome string, e.g. "chr17" |
Chromosome for region query |
coord_start |
SCREEN cCRE search | required | integer | Region start coordinate |
coord_end |
SCREEN cCRE search | required | integer | Region end coordinate |
Best Practices
-
Use IDR thresholded peaks for reproducible analyses: Raw replicate peaks contain many false positives. Always prefer
"output_type": "IDR thresholded peaks"or"optimal IDR thresholded peaks"for any variant annotation or motif analysis. -
Filter by
status=releasedand specifyassembly: ENCODE stores data for multiple genome builds. Always includefiles.assembly=GRCh38(or your target assembly) to avoid mixing coordinate systems from archived experiments. -
Add
time.sleep(0.5)in loops over experiments: The ENCODE Portal does not publish a hard rate limit, but aggressive sequential requests will trigger connection resets. Polite delays prevent this in atlas-building workflows. -
Prefer the SCREEN API for cCRE queries, not the Portal search: The ENCODE Portal search can find cCRE-related files, but the dedicated SCREEN API (
api.encodeproject.org/screen) is purpose-built for region-based cCRE lookup and returns structured z-score data. -
Check
biosample_summaryvsbiosample_ontology.term_name: Thebiosample_summaryfield (used in search) sometimes differs from the official ontology term name. If a biosample search returns zero results, try browsing ENCODE to find the exact summary string used.
Common Recipes
Recipe: Find All ENCODE Assay Types Available
When to use: Explore what assay types are available before planning a targeted search.
import requests
BASE = "https://www.encodeproject.org"
r = requests.get(
f"{BASE}/search/",
params={"type": "Experiment", "status": "released", "format": "json",
"limit": 0, "field": "assay_title"},
timeout=30
)
r.raise_for_status()
# Facets contain aggregated counts per assay type
for facet in r.json().get("facets", []):
if facet.get("field") == "assay_title":
for term in sorted(facet.get("terms", []), key=lambda x: -x["doc_count"])[:20]:
print(f" {term['doc_count']:6d} {term['key']}")
Recipe: Get Experiment Metadata by Accession
When to use: Retrieve full details for a known ENCODE accession (e.g., from a published paper).
import requests, json
BASE = "https://www.encodeproject.org"
def get_experiment_metadata(accession):
r = requests.get(f"{BASE}/experiments/{accession}/?format=json", timeout=30)
r.raise_for_status()
exp = r.json()
print(f"Accession : {exp['accession']}")
print(f"Assay : {exp.get('assay_title')}")
print(f"Biosample : {exp.get('biosample_summary')}")
print(f"Target : {exp.get('target', {}).get('label', 'N/A')}")
print(f"Lab : {exp.get('lab', {}).get('title')}")
print(f"Files : {len(exp.get('files', []))}")
print(f"Released : {exp.get('date_released')}")
return exp
exp = get_experiment_metadata("ENCSR000AKC")
Recipe: List bigWig Signal Files for Visualization
When to use: Download signal bigWig tracks to load into IGV or UCSC Genome Browser.
import requests, pandas as pd
BASE = "https://www.encodeproject.org"
def get_signal_bigwigs(accession, assembly="GRCh38"):
"""Get fold-change and p-value signal bigWig files for an experiment."""
params = {
"type": "File",
"dataset": f"/experiments/{accession}/",
"file_format": "bigWig",
"output_type": "fold change over control",
"assembly": assembly,
"status": "released",
"format": "json",
"limit": 10,
}
r = requests.get(f"{BASE}/search/", params=params, timeout=30)
r.raise_for_status()
files = r.json().get("@graph", [])
print(f"Found {len(files)} bigWig signal files for {accession}")
for f in files:
print(f" {f['accession']} {f['output_type']} {f.get('biological_replicates')} "
f"{round(f.get('file_size', 0)/1e6, 1)} MB")
print(f" URL: {BASE}{f['href']}")
return files
bigwigs = get_signal_bigwigs("ENCSR000AKC")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 on experiment endpoint |
Accession does not exist or was revoked | Verify accession on https://www.encodeproject.org; check status field in search results |
Empty @graph in search results |
Overly restrictive filters or no matching data | Relax one filter at a time; check total field to see if data exists before pagination |
HTTP 503 or connection timeout |
Server overload from rapid sequential requests | Add time.sleep(0.5) between requests; retry with exponential backoff |
cCRE search returns [] |
SCREEN API endpoint path changed or region on non-canonical chromosome | Verify API URL; use canonical chromosomes (chr1–22, chrX, chrY) only |
| bigWig/BED files not found | Assembly mismatch (GRCh37 vs GRCh38) | Always include files.assembly in queries; verify the experiment has files in your target assembly |
target.label filter returns no results |
Exact label mismatch (case-sensitive) | Browse ENCODE to find the exact string (e.g., "eGFP-TP53" vs "TP53") |
| File download fails mid-transfer | Large file + network timeout | Use stream=True in requests with chunked writing; increase timeout to 300 |
Related Skills
regulomedb-database— Pre-computed regulatory scores for variants integrating ENCODE ChIP-seq, DNase-seq, eQTL, and motif datagwas-database— NHGRI-EBI GWAS Catalog for published SNP-trait associations; combine with ENCODE peaks for functional annotation of GWAS hitsmacs3-peak-calling— Call peaks from your own ChIP-seq or ATAC-seq BAM files; use ENCODE peaks as reference comparison setsdeeptools-ngs-analysis— Generate bigWig coverage tracks, correlation heatmaps, and profile plots from your own aligned BAM files
References
- ENCODE Portal REST API Documentation — Full API reference with query examples
- ENCODE SCREEN Browser — Interactive cCRE browser and documentation
- ENCODE Project Consortium. "An integrated encyclopedia of DNA elements in the human genome." Nature 489: 57–74 (2012). https://doi.org/10.1038/nature11247
- ENCODE Project Consortium et al. "Perspectives on ENCODE." Nature 583: 693–698 (2020). https://doi.org/10.1038/s41586-020-2449-8
- Moore JE et al. "Expanded encyclopaedias of DNA elements in the human and mouse genomes." Nature 583: 699–710 (2020). https://doi.org/10.1038/s41586-020-2493-4
skills/genomics-bioinformatics/databases/ensembl-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill ensembl-database -g -y
SKILL.md
Frontmatter
{
"name": "ensembl-database",
"license": "Apache-2.0",
"description": "Ensembl REST API for gene\/transcript\/variant annotations in 300+ species. Gene info by symbol\/ID, sequence, cross-refs (HGNC, RefSeq, UniProt), regulatory features. For bulk local use pyensembl; for pathways use kegg-database."
}
Ensembl Genome Database
Overview
Ensembl is a comprehensive genome annotation database covering 300+ vertebrate and non-vertebrate species. The Ensembl REST API provides programmatic access to gene models, transcript/protein sequences, variant annotations, cross-references, regulatory features, and comparative genomics without requiring any login or API key.
When to Use
- Retrieving official gene and transcript annotations (stable IDs, biotype, genomic coordinates) for human or model organism genes
- Converting between gene identifier namespaces (HGNC symbol ↔ Ensembl ID ↔ RefSeq ↔ UniProt)
- Fetching genomic or cDNA/CDS/protein sequences for a gene or transcript
- Looking up variant consequences and functional impact (VEP) for a list of SNPs
- Querying regulatory features (promoters, enhancers, CTCF sites) in a genomic region
- Performing comparative genomics queries (orthologs, paralogs, gene trees) across species
- For local offline access to large genomic annotations, use
pyensemblinstead - For pathway and metabolic annotations, use
kegg-databaseorreactome-databaseinstead
Prerequisites
- Python packages:
requests - Data requirements: gene symbols, Ensembl stable IDs (ENSG…/ENST…/ENSP…), or genomic coordinates
- Environment: internet connection required; no API key needed
- Rate limits: max ~15 requests/second; use
expand=1and batch endpoints to minimize calls
pip install requests
Quick Start
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
def ensembl_get(endpoint, params=None):
r = requests.get(f"{BASE}{endpoint}", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
# Look up human BRCA1
gene = ensembl_get("/lookup/symbol/homo_sapiens/BRCA1", params={"expand": 1})
print(f"ID: {gene['id']}, Chr: {gene['seq_region_name']}:{gene['start']}-{gene['end']}")
print(f"Transcripts: {len(gene.get('Transcript', []))}")
Core API
Query 1: Gene Lookup by Symbol or Stable ID
Retrieve gene metadata from a gene symbol or Ensembl stable ID.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# By gene symbol
r = requests.get(
f"{BASE}/lookup/symbol/homo_sapiens/TP53",
headers=HEADERS,
params={"expand": 1}
)
gene = r.json()
print(f"Ensembl ID : {gene['id']}")
print(f"Location : {gene['seq_region_name']}:{gene['start']}-{gene['end']} ({gene['strand']})")
print(f"Biotype : {gene['biotype']}")
print(f"Transcripts: {len(gene.get('Transcript', []))}")
# By stable ID (works for genes, transcripts, proteins)
r = requests.get(
f"{BASE}/lookup/id/ENSG00000141510",
headers=HEADERS,
params={"expand": 0}
)
obj = r.json()
print(f"Symbol: {obj.get('display_name')}, Species: {obj.get('species')}")
Query 2: Batch Lookup
Retrieve information for multiple IDs in one call (POST endpoint).
import requests, json
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# Batch lookup by symbols
symbols = ["BRCA1", "BRCA2", "TP53", "EGFR", "MYC"]
r = requests.post(
f"{BASE}/lookup/symbol/homo_sapiens",
headers=HEADERS,
data=json.dumps({"symbols": symbols})
)
results = r.json()
for sym, data in results.items():
if data:
print(f"{sym}: {data['id']} ({data['seq_region_name']}:{data['start']}-{data['end']})")
Query 3: Sequence Retrieval
Fetch genomic, cDNA, CDS, or protein sequences.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "text/plain"}
# Protein sequence for canonical transcript
r = requests.get(
f"{BASE}/sequence/id/ENST00000269305",
headers=HEADERS,
params={"type": "protein"}
)
seq = r.text
print(f"Protein sequence ({len(seq)} aa): {seq[:60]}...")
# Genomic region sequence
HEADERS_JSON = {"Content-Type": "application/json"}
r = requests.get(
f"{BASE}/sequence/region/human/17:43044295..43125364",
headers=HEADERS_JSON,
params={"coord_system_version": "GRCh38"}
)
result = r.json()
print(f"Retrieved {len(result['seq'])} bp of genomic sequence")
Query 4: Cross-References (ID Mapping)
Map Ensembl IDs to external database identifiers.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# All xrefs for a gene
r = requests.get(
f"{BASE}/xrefs/id/ENSG00000141510",
headers=HEADERS
)
xrefs = r.json()
# Group by database
from collections import defaultdict
by_db = defaultdict(list)
for x in xrefs:
by_db[x["dbname"]].append(x["primary_id"])
for db in ["HGNC", "RefSeq_gene_name", "Uniprot_gn", "MIM_gene"]:
if db in by_db:
print(f"{db}: {by_db[db]}")
Query 5: Variant Consequence Annotation (VEP)
Predict functional consequences of variants via REST VEP endpoint.
import requests, json
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# Annotate a list of hgvs notations
variants = ["17:g.43094692C>T", "13:g.32929387C>T"]
r = requests.post(
f"{BASE}/vep/human/hgvs",
headers=HEADERS,
data=json.dumps({"hgvs_notations": variants})
)
for v in r.json():
print(f"\nVariant: {v.get('input')}")
for tc in v.get("transcript_consequences", [])[:2]:
print(f" Gene: {tc.get('gene_symbol')}, Impact: {tc.get('impact')}, Consequence: {tc.get('consequence_terms')}")
# Annotate by rsID
r = requests.get(
f"{BASE}/vep/human/id/rs699",
headers=HEADERS
)
v = r.json()[0]
print(f"rsID rs699 in gene: {v['transcript_consequences'][0]['gene_symbol']}")
print(f"Consequence: {v['transcript_consequences'][0]['consequence_terms']}")
Query 6: Regulatory Features
Query regulatory build features in a genomic region.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# Regulatory features in BRCA1 region
r = requests.get(
f"{BASE}/overlap/region/human/17:43044000-43126000",
headers=HEADERS,
params={"feature": "regulatory"}
)
features = r.json()
print(f"Found {len(features)} regulatory features")
for f in features[:5]:
print(f" {f.get('feature_type')}: {f.get('start')}-{f.get('end')} ({f.get('description', 'n/a')})")
Query 7: Comparative Genomics (Orthologs / Gene Trees)
Find orthologs and paralogs across species.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# Get mouse ortholog for human TP53
r = requests.get(
f"{BASE}/homology/symbol/human/TP53",
headers=HEADERS,
params={"target_species": "mus_musculus", "type": "orthologues"}
)
data = r.json()
for homo in data["data"][0]["homologies"][:3]:
tgt = homo["target"]
print(f"Mouse ortholog: {tgt['id']} ({tgt.get('perc_id', 'n/a')}% identity)")
Key Concepts
Stable IDs and Versioning
Ensembl uses stable IDs with optional version suffixes (e.g., ENSG00000141510.17). Genes (ENSG), transcripts (ENST), proteins (ENSP), and exons (ENSE) each have their own prefix. IDs are preserved across releases when possible; retired IDs can still be resolved via the archive API.
Assembly Versions
Human genome: GRCh38 (current) and GRCh37 (legacy, via grch37.rest.ensembl.org). Always specify which assembly your coordinates belong to when making region-based queries.
Common Workflows
Workflow 1: Gene-to-Protein Information Pipeline
Goal: Retrieve all key annotations for a gene list — coordinates, transcripts, xrefs, and canonical protein sequence.
import requests, json, time
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
def batch_lookup(symbols, species="homo_sapiens"):
r = requests.post(
f"{BASE}/lookup/symbol/{species}",
headers=HEADERS,
data=json.dumps({"symbols": symbols, "expand": 1})
)
return r.json()
def canonical_transcript(gene_data):
"""Return the ID of the canonical (longest CDS) transcript."""
transcripts = gene_data.get("Transcript", [])
coding = [t for t in transcripts if t.get("biotype") == "protein_coding"]
if not coding:
return None
return max(coding, key=lambda t: t.get("Translation", {}).get("length", 0))
genes = ["BRCA1", "BRCA2", "TP53"]
lookup = batch_lookup(genes)
for sym in genes:
g = lookup.get(sym)
if not g:
print(f"{sym}: not found")
continue
canon = canonical_transcript(g)
print(f"\n{sym} ({g['id']})")
print(f" Location: {g['seq_region_name']}:{g['start']}-{g['end']}")
if canon:
prot_len = canon.get("Translation", {}).get("length", "n/a")
print(f" Canonical transcript: {canon['id']} ({prot_len} aa)")
time.sleep(0.1) # be polite
Workflow 2: Variant Annotation Pipeline
Goal: Annotate a VCF-style variant list with gene, consequence, and impact.
import requests, json, pandas as pd
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
# Input: list of hgvs notations
hgvs_list = [
"17:g.43094692C>T",
"17:g.43063873A>G",
"13:g.32929387C>T",
]
# Annotate in batches of 200
def vep_batch(hgvs_batch):
r = requests.post(
f"{BASE}/vep/human/hgvs",
headers=HEADERS,
data=json.dumps({"hgvs_notations": hgvs_batch})
)
r.raise_for_status()
return r.json()
records = []
for ann in vep_batch(hgvs_list):
for tc in ann.get("transcript_consequences", []):
if tc.get("canonical") == 1:
records.append({
"variant": ann["input"],
"gene": tc.get("gene_symbol"),
"consequence": ",".join(tc.get("consequence_terms", [])),
"impact": tc.get("impact"),
"biotype": tc.get("biotype"),
})
df = pd.DataFrame(records)
print(df.to_string(index=False))
df.to_csv("vep_results.csv", index=False)
print(f"\nSaved {len(df)} variant annotations → vep_results.csv")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
expand |
Lookup | 0 |
0 or 1 |
Include nested transcripts/translations |
type |
Sequence | "genomic" |
"genomic", "cDNA", "CDS", "protein" |
Sequence type to return |
target_species |
Homology | None |
Species name or taxon ID | Filter homologs to target species |
feature |
Overlap | required | "gene", "transcript", "regulatory", "variation" |
Feature type to retrieve |
coord_system_version |
Region | "GRCh38" |
"GRCh38", "GRCh37" |
Genome assembly |
content_type |
All | via header | "application/json", "text/plain" |
Response format |
Best Practices
-
Use batch endpoints: POST
/lookup/symbol/{species}and POST/vep/human/hgvsaccept up to 1000 IDs; single-ID GET requests in a loop will hit rate limits quickly. -
Pin assembly version: For region-based queries always specify
coord_system_version=GRCh38(or usegrch37.rest.ensembl.orgfor legacy coordinates) to avoid silent mismatch errors. -
Cache responses: Gene metadata rarely changes between Ensembl releases; cache results to disk (
joblib.Memory) to avoid redundant API calls during development.from joblib import Memory mem = Memory("cache/", verbose=0) cached_lookup = mem.cache(batch_lookup) -
Use
expand=0for metadata: When you only need gene coordinates and biotype (not transcript details), keepexpand=0for smaller payloads and faster responses. -
Check canonical flag in VEP: VEP returns consequences for all overlapping transcripts; filter on
tc.get("canonical") == 1to get the biologically most relevant consequence per variant.
Common Recipes
Recipe: Symbol → Ensembl ID Mapping Table
When to use: Build a lookup table from gene symbols to Ensembl IDs for downstream analysis.
import requests, json, pandas as pd
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
symbols = ["EGFR", "KRAS", "BRAF", "PIK3CA", "PTEN", "AKT1", "MYC", "RB1"]
r = requests.post(
f"{BASE}/lookup/symbol/homo_sapiens",
headers=HEADERS,
data=json.dumps({"symbols": symbols})
)
data = r.json()
rows = [{"symbol": s, "ensembl_id": d["id"] if d else None,
"chrom": d["seq_region_name"] if d else None} for s, d in data.items()]
df = pd.DataFrame(rows)
df.to_csv("symbol_to_ensembl.csv", index=False)
print(df.to_string(index=False))
Recipe: Region Gene Overlap
When to use: Find all genes overlapping a genomic interval (e.g., a GWAS locus).
import requests, pandas as pd
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
chrom, start, end = "17", 43044295, 43125364
r = requests.get(
f"{BASE}/overlap/region/human/{chrom}:{start}-{end}",
headers=HEADERS,
params={"feature": "gene", "biotype": "protein_coding"}
)
genes = r.json()
df = pd.DataFrame([{
"id": g["id"], "name": g.get("external_name"),
"start": g["start"], "end": g["end"], "strand": g["strand"]
} for g in genes])
print(df.to_string(index=False))
print(f"\n{len(df)} protein-coding genes in region")
Recipe: Species List
When to use: Check which species are available in Ensembl before querying.
import requests
BASE = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json"}
r = requests.get(f"{BASE}/info/species", headers=HEADERS)
species_list = r.json()["species"]
print(f"Total species: {len(species_list)}")
vertebrates = [s for s in species_list if s.get("division") == "EnsemblVertebrates"]
print(f"Vertebrates: {len(vertebrates)}")
for s in vertebrates[:5]:
print(f" {s['common_name']} ({s['name']}): {s['assembly']}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 429 Too Many Requests |
Exceeding ~15 req/s rate limit | Add time.sleep(0.1) between requests; use batch POST endpoints |
HTTP 400 Bad Request on VEP |
Malformed HGVS notation | Verify format: chr:g.posREF>ALT (e.g., 17:g.43094692C>T) |
Gene not found |
Gene symbol not in Ensembl | Try alternative symbol; check species name (use homo_sapiens not human for symbols) |
| Region query returns wrong genes | Assembly mismatch | Set coord_system_version=GRCh38 or use grch37.rest.ensembl.org |
| Old ID not resolving | Retired Ensembl ID | Query GET /archive/id/{id} to get current mapping |
HTTP 503 Service Unavailable |
Server maintenance | Retry after a few minutes; check Ensembl status at status.ensembl.org |
Related Skills
gget-genomic-databases— CLI/Python wrapper covering Ensembl + 20 other databases; use for quick lookups without raw API codebiopython-molecular-biology— Biopython'sEntrezmodule for NCBI databases (alternative for RefSeq/GenBank queries)kegg-database— Pathway/metabolic annotations for the same gene setreactome-database— Pathway enrichment and hierarchy queries
References
- Ensembl REST API documentation — Interactive API explorer and endpoint reference
- Ensembl Help & Documentation — REST API overview
- Ensembl stable IDs guide — ID versioning policy
- VEP documentation — Variant Effect Predictor full reference
skills/genomics-bioinformatics/databases/gene-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gene-database -g -y
SKILL.md
Frontmatter
{
"name": "gene-database",
"license": "CC0-1.0",
"description": "NCBI Gene via E-utilities: curated records across 1M+ taxa. Official symbols, aliases, RefSeq IDs, summaries, coordinates, GO, interactions. Use for gene ID resolution and cross-species function queries. For sequences use Ensembl; for expression use geo-database."
}
NCBI Gene Database
Overview
NCBI Gene is the authoritative curated database for gene-centric information, covering 1M+ genes across hundreds of thousands of taxa. Each gene record includes the official symbol, aliases, full name, functional summary, genomic coordinates (GRCh38/GRCh37), RefSeq accessions, GO annotations, interaction partners, and links to related databases. Access is free via E-utilities REST API (no API key required, though recommended).
When to Use
- Resolving gene aliases and synonyms to the current official HGNC/NCBI symbol
- Fetching the NCBI Gene ID (integer) for a gene symbol for downstream API calls (e.g., dbSNP, ClinVar, GEO)
- Retrieving curated gene summaries and function descriptions programmatically
- Pulling RefSeq mRNA (NM_) and protein (NP_) accessions associated with a gene
- Querying GO functional annotations (Biological Process, Molecular Function, Cellular Component)
- Finding orthologs across species via the NCBI Datasets v2 orthologs endpoint (legacy E-utilities
gene_gene_homologretired with HomoloGene in 2019) - For expression profiles across conditions use
geo-database; for variant annotations useclinvar-databaseorensembl-database
Prerequisites
- Python packages:
requests,xml.etree.ElementTree(stdlib),pandas(optional) - Data requirements: gene symbols, NCBI Gene IDs, or tax IDs
- Environment: internet connection; NCBI email required (set
emailparameter) - Rate limits: 3 req/s unauthenticated; 10 req/s with free NCBI API key
pip install requests pandas
Quick Start
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def gene_search(query, retmax=5):
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "term": query,
"retmax": retmax, "retmode": "json", "email": EMAIL})
r.raise_for_status()
return r.json()["esearchresult"]["idlist"]
# Find human BRCA1 gene ID
ids = gene_search("BRCA1[sym] AND Homo sapiens[orgn]")
print(f"Gene IDs for BRCA1: {ids}") # → ['672']
Core API
Query 1: Search by Symbol, Name, or Function
Use ESearch with field tags for precise queries.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
# Exact symbol match for human gene
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"term": "TP53[sym] AND Homo sapiens[orgn] AND alive[prop]"})
ids = r.json()["esearchresult"]["idlist"]
print(f"TP53 Gene ID: {ids}") # → ['7157']
# Search by function keyword
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"term": "CRISPR[title] AND Homo sapiens[orgn]", "retmax": 5})
ids = r.json()["esearchresult"]["idlist"]
print(f"CRISPR-related gene IDs: {ids}")
Query 2: Fetch Gene Summary (JSON/ESummary)
Retrieve key metadata fields for a list of Gene IDs.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def esummary_gene(gene_ids):
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "gene", "id": ",".join(gene_ids),
"retmode": "json", "email": EMAIL})
r.raise_for_status()
return r.json()["result"]
result = esummary_gene(["672", "675", "7157"]) # BRCA1, BRCA2, TP53
for uid in result.get("uids", []):
g = result[uid]
print(f"\n{g.get('name')} (ID {uid})")
print(f" Official symbol : {g.get('nomenclaturesymbol', g.get('name'))}")
print(f" Chr location : {g.get('maplocation')}")
print(f" Summary (first 100): {g.get('summary', '')[:100]}...")
print(f" Aliases: {g.get('otheraliases', 'none')}")
Query 3: Fetch Full Gene Record (XML)
Retrieve the complete gene record in XML for RefSeq accessions, GO terms, and interaction data.
import requests
import xml.etree.ElementTree as ET
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def efetch_gene_xml(gene_id):
r = requests.get(f"{BASE}/efetch.fcgi",
params={"db": "gene", "id": gene_id,
"rettype": "gene_table", "retmode": "text", "email": EMAIL})
r.raise_for_status()
return r.text
# Get gene table (tab-delimited overview)
table = efetch_gene_xml("672")
print(table[:500])
# XML for RefSeq accession extraction
r = requests.get(f"{BASE}/efetch.fcgi",
params={"db": "gene", "id": "672",
"rettype": "xml", "retmode": "xml", "email": EMAIL})
root = ET.fromstring(r.text)
# Extract RefSeq mRNA accessions
for ref in root.iter("Gene-commentary"):
acc = ref.find("Gene-commentary_accession")
ver = ref.find("Gene-commentary_version")
typ = ref.find("Gene-commentary_type")
if acc is not None and acc.text and acc.text.startswith("NM_"):
print(f"RefSeq mRNA: {acc.text}.{ver.text if ver is not None else ''}")
Query 4: Batch Symbol-to-ID Mapping
Map a list of gene symbols to NCBI Gene IDs efficiently.
import requests, time
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def symbols_to_ids(symbols, organism="Homo sapiens"):
"""Map gene symbols to NCBI Gene IDs. Returns dict {symbol: gene_id}."""
mapping = {}
for sym in symbols:
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"term": f"{sym}[sym] AND {organism}[orgn] AND alive[prop]"})
ids = r.json()["esearchresult"]["idlist"]
mapping[sym] = ids[0] if ids else None
time.sleep(0.1)
return mapping
genes = ["EGFR", "KRAS", "BRAF", "PIK3CA", "PTEN"]
id_map = symbols_to_ids(genes)
for sym, gid in id_map.items():
print(f"{sym:10s} → Gene ID {gid}")
Query 5: GO Annotation Retrieval
Parse GO terms from the gene XML record.
import requests
import xml.etree.ElementTree as ET
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
r = requests.get(f"{BASE}/efetch.fcgi",
params={"db": "gene", "id": "7157",
"rettype": "xml", "retmode": "xml", "email": EMAIL})
root = ET.fromstring(r.text)
# Extract GO annotations
go_terms = []
for ref in root.iter("Gene-commentary"):
heading = ref.find("Gene-commentary_heading")
label = ref.find("Gene-commentary_label")
if heading is not None and "Gene Ontology" in heading.text:
if label is not None:
go_terms.append(label.text)
print(f"TP53 GO terms ({len(go_terms)} found):")
for term in go_terms[:10]:
print(f" {term}")
Query 6: Cross-Species Orthologs (NCBI Datasets v2)
Find orthologs across species. Note: the legacy E-utilities link gene_gene_homolog was retired with HomoloGene in 2019 — the modern path is the NCBI Datasets v2 REST API, which exposes a dedicated orthologs endpoint.
import requests, time
DATASETS_BASE = "https://api.ncbi.nlm.nih.gov/datasets/v2"
def get_orthologs(gene_id, taxon_filter=None):
"""Return ortholog Gene reports for a given NCBI Gene ID.
taxon_filter: optional tax_id (int) or list of tax_ids to narrow species."""
params = {}
if taxon_filter is not None:
# tax_ids: human=9606, mouse=10090, rat=10116, zebrafish=7955, fly=7227
ids = taxon_filter if isinstance(taxon_filter, (list, tuple)) else [taxon_filter]
params["taxon_filter"] = [str(t) for t in ids]
r = requests.get(f"{DATASETS_BASE}/gene/id/{gene_id}/orthologs",
params=params, timeout=30)
r.raise_for_status()
return r.json().get("reports", [])
# Mouse ortholog of human TP53 (Gene ID 7157)
reports = get_orthologs("7157", taxon_filter=10090)
for rep in reports[:5]:
g = rep.get("gene", {})
print(f" {g.get('symbol'):8s} (tax {g.get('tax_id')}, gene_id {g.get('gene_id')}): "
f"{g.get('description', '')[:60]}")
# Expect: Trp53 (tax 10090, gene_id 22059): transformation related protein 53
time.sleep(0.34)
# All orthologs (every species in the orthology group)
all_orthologs = get_orthologs("7157")
print(f"\nTotal TP53 orthologs across species: {len(all_orthologs)}")
Key Concepts
NCBI Gene ID vs. HGNC ID vs. Ensembl ID
NCBI Gene IDs are integers assigned per gene per organism (e.g., human TP53 = 7157). These are distinct from HGNC IDs (e.g., HGNC:11998) and Ensembl IDs (ENSG00000141510). Many downstream NCBI databases (ClinVar, dbSNP, GEO) use NCBI Gene IDs internally.
alive[prop] Filter
NCBI Gene records for discontinued genes have status=discontinued. Always add AND alive[prop] to symbol queries to exclude retired entries and avoid retrieving stale data.
Common Workflows
Workflow 1: Build a Gene Annotation Table
Goal: For a list of gene symbols, retrieve Gene ID, official name, chromosomal location, and description.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def search_gene(sym, organism="Homo sapiens"):
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"term": f"{sym}[sym] AND {organism}[orgn] AND alive[prop]"})
ids = r.json()["esearchresult"]["idlist"]
return ids[0] if ids else None
def batch_summary(gene_ids):
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "gene", "id": ",".join(gene_ids),
"retmode": "json", "email": EMAIL})
return r.json()["result"]
symbols = ["BRCA1", "BRCA2", "TP53", "EGFR", "MYC", "KRAS", "PTEN"]
# Step 1: Symbol → Gene ID
id_map = {}
for sym in symbols:
gid = search_gene(sym)
id_map[sym] = gid
time.sleep(0.12)
# Step 2: Batch summary
valid_ids = [v for v in id_map.values() if v]
result = batch_summary(valid_ids)
rows = []
sym_to_id = {v: k for k, v in id_map.items() if v}
for uid in result.get("uids", []):
g = result[uid]
rows.append({
"symbol": sym_to_id.get(uid, g.get("name")),
"gene_id": uid,
"full_name": g.get("description"),
"chr_location": g.get("maplocation"),
"summary": g.get("summary", "")[:200],
})
df = pd.DataFrame(rows)
df.to_csv("gene_annotations.csv", index=False)
print(df[["symbol", "gene_id", "full_name", "chr_location"]].to_string(index=False))
Workflow 2: Find All Genes in a Pathway Keyword
Goal: Retrieve all human genes associated with a biological keyword from the NCBI Gene summary field.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
keyword = "DNA mismatch repair"
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"retmax": 50,
"term": f"{keyword}[title/abstract] AND Homo sapiens[orgn] AND alive[prop]"})
ids = r.json()["esearchresult"]["idlist"]
print(f"Found {len(ids)} genes related to '{keyword}'")
# Fetch summaries
r2 = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "gene", "id": ",".join(ids), "retmode": "json", "email": EMAIL})
result = r2.json()["result"]
rows = []
for uid in result.get("uids", []):
g = result[uid]
rows.append({"gene_id": uid, "symbol": g.get("name"),
"description": g.get("description"),
"location": g.get("maplocation")})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
df.to_csv(f"{keyword.replace(' ', '_')}_genes.csv", index=False)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
retmax |
ESearch | 20 |
1–10000 |
Max records returned |
retmode |
ESearch/ESummary | "xml" |
"json", "xml" |
Response format |
rettype |
EFetch | depends | "xml", "gene_table", "text" |
Record format for full fetch |
[sym] field tag |
ESearch | — | gene symbol | Match exact official symbol only |
[orgn] field tag |
ESearch | — | organism name or tax ID | Filter by taxonomy |
alive[prop] |
ESearch | — | boolean flag | Exclude discontinued gene records |
Best Practices
-
Always add
alive[prop]: Discontinued gene records remain in the database. Without this filter, symbol searches may return outdated records. -
Use Gene IDs in pipelines: Downstream NCBI databases (ClinVar, dbSNP, GEO) accept Gene IDs; avoid re-searching by symbol in each call.
-
Use ESummary for metadata, EFetch for full records: ESummary returns JSON with all common fields; EFetch XML is needed only for RefSeq accessions, GO terms, or interaction links.
-
Register for a free API key: Triple your rate limit (3 → 10 req/s) at https://www.ncbi.nlm.nih.gov/account/. Pass as
api_keyparameter. -
Batch with ESummary: POST up to 200 Gene IDs per call to ESummary instead of querying one at a time.
Common Recipes
Recipe: Gene ID to RefSeq NM Accession
When to use: Get the canonical mRNA accession for a protein-coding gene.
import requests, re
EMAIL = "your@email.com"
GENE_ID = "672" # BRCA1
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi",
params={"db": "gene", "id": GENE_ID, "rettype": "gene_table",
"retmode": "text", "email": EMAIL}
)
nm_accessions = re.findall(r"NM_\d+\.\d+", r.text)
print(f"RefSeq mRNA accessions: {list(set(nm_accessions))}")
Recipe: Retrieve Gene Aliases
When to use: Resolve legacy/alias symbols to the current official NCBI symbol.
import requests
EMAIL = "your@email.com"
# P53 is an alias for TP53
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json",
"term": "p53[sym] AND Homo sapiens[orgn] AND alive[prop]"}
)
ids = r.json()["esearchresult"]["idlist"]
r2 = requests.post("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi",
data={"db": "gene", "id": ",".join(ids[:1]),
"retmode": "json", "email": EMAIL})
g = r2.json()["result"][ids[0]]
print(f"Official symbol : {g.get('nomenclaturesymbol', g.get('name'))}")
print(f"Other aliases : {g.get('otheraliases')}")
print(f"Designations : {g.get('otherdesignations', '')[:100]}")
Recipe: List All Genes on a Chromosome
When to use: Get all protein-coding genes on a specific human chromosome.
import requests
EMAIL = "your@email.com"
r = requests.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "gene", "email": EMAIL, "retmode": "json", "retmax": 5,
"term": "17[chr] AND Homo sapiens[orgn] AND protein coding[filter] AND alive[prop]"}
)
result = r.json()["esearchresult"]
print(f"Protein-coding genes on chr17: {result['count']} total")
print(f"Sample IDs: {result['idlist']}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Empty idlist for known symbol |
Symbol is an alias, not the official term | Use [gene name] or [title] field tag; check aliases via ESummary |
| Wrong species returned | Missing organism filter | Add AND Homo sapiens[orgn] or target tax ID (9606[taxid]) |
| Discontinued gene returned | Missing alive[prop] filter |
Append AND alive[prop] to all symbol queries |
HTTP 429 rate limit |
Too many requests | Add time.sleep(0.35) between calls; use NCBI API key |
ESummary missing uids key |
All IDs invalid/absent | Check id values are valid integers, not empty strings |
| XML parse error | Malformed XML for rare genes | Wrap ET.fromstring in try/except; retry with rettype=text |
| Empty ortholog list from ELink | Legacy linkname=gene_gene_homolog retired with HomoloGene in 2019 |
Use NCBI Datasets v2 /gene/id/{gene_id}/orthologs instead (Query 6) |
Related Skills
geo-database— Gene Expression Omnibus for retrieving expression data linked to genes found hereclinvar-database— Clinical variant data indexed by NCBI Gene IDsensembl-database— Complementary gene annotations with VEP and comparative genomicsbiopython-molecular-biology— Biopython Entrez module wraps E-utilities with typed return values
References
- NCBI Gene database — Official homepage and search interface
- E-utilities documentation — Complete API reference for ESearch, ESummary, EFetch
- NCBI Gene field tags — Field tag reference for constructing Entrez queries
- NCBI API Key registration — Free registration for 10 req/s rate limit
- NCBI Datasets v2 REST API — Modern endpoint for orthologs, gene metadata, and taxonomy (replaces retired HomoloGene)
skills/genomics-bioinformatics/databases/geo-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill geo-database -g -y
SKILL.md
Frontmatter
{
"name": "geo-database",
"license": "MIT",
"description": "NCBI GEO access via GEOparse and E-utilities. Search by keyword\/organism\/platform, download GSE series matrices, parse GPL annotations, extract GSM metadata, load expression matrices into pandas. For single-cell use cellxgene-census; for multi-DB access use gget-genomic-databases."
}
GEO Gene Expression Omnibus Database
Overview
GEO (Gene Expression Omnibus) is NCBI's public repository for high-throughput functional genomics data, containing 200,000+ datasets (series) from microarrays, RNA-seq, ChIP-seq, methylation, and proteomics experiments. GEOparse provides a Python interface for downloading and parsing GEO records (GSE series, GPL platforms, GSM samples) while NCBI E-utilities enables programmatic search across GEO's metadata.
When to Use
- Searching for publicly available gene expression datasets by organism, tissue, disease, or experimental condition
- Downloading and parsing a specific GEO series (GSE) with its expression matrix and sample metadata
- Extracting sample annotation tables (e.g., treatment groups, clinical covariates) for meta-analysis
- Loading microarray expression data (GPL platform-annotated probes) into a tidy DataFrame
- Retrieving all GEO experiments associated with a gene or pathway of interest
- Building automated pipelines that download and process GEO datasets for downstream analysis
- For single-cell RNA-seq data at scale, use
cellxgene-census; for aligned reads, download FASTQ from ENA/SRA instead
Prerequisites
- Python packages:
GEOparse,requests,pandas - Data requirements: GSE/GPL/GSM accession numbers, or search terms
- Environment: internet connection; write access to local directory for downloads
- Rate limits: E-utilities: 3 req/s unauthenticated, 10 req/s with API key; GEO FTP is unlimited
pip install GEOparse requests pandas
Quick Start
import GEOparse
# Download a GEO series (caches in current directory)
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/")
print(f"Title: {gse.metadata['title'][0]}")
print(f"Samples: {len(gse.gsms)}")
print(f"Platform: {list(gse.gpls.keys())}")
# Sample metadata
meta = gse.phenotype_data
print(meta.head())
Core API
Query 1: Search GEO Datasets via E-utilities
Find GEO series (GSE) by keyword, organism, or dataset type.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def geo_search(query, retmax=20):
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gds", "term": query,
"retmax": retmax, "retmode": "json", "email": EMAIL})
r.raise_for_status()
return r.json()["esearchresult"]
# Search for human breast cancer RNA-seq datasets
result = geo_search(
"breast cancer[title] AND Homo sapiens[organism] AND gse[entry type]",
retmax=10
)
print(f"Found {result['count']} matching GEO datasets")
print(f"First accessions (UIDs): {result['idlist']}")
# Search for specific platform (e.g., Illumina HumanHT-12)
result = geo_search(
"Illumina HumanHT-12[platform] AND Homo sapiens[organism] AND gse[entry type]",
retmax=5
)
print(f"Illumina HumanHT-12 human datasets: {result['count']}")
Query 2: Fetch Dataset Summary Metadata
Retrieve title, accession, and organism for search results.
import requests
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def geo_summary(uids):
r = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "gds", "id": ",".join(uids),
"retmode": "json", "email": EMAIL})
r.raise_for_status()
return r.json()["result"]
# Get metadata for search results
result = geo_search_func = lambda q: requests.get(
f"{BASE}/esearch.fcgi",
params={"db": "gds", "term": q, "retmax": 3, "retmode": "json", "email": EMAIL}
).json()["esearchresult"]["idlist"]
uids = requests.get(
f"{BASE}/esearch.fcgi",
params={"db": "gds", "term": "lung cancer[title] AND gse[entry type]",
"retmax": 3, "retmode": "json", "email": EMAIL}
).json()["esearchresult"]["idlist"]
summaries = geo_summary(uids)
for uid in summaries.get("uids", []):
s = summaries[uid]
print(f"\nAccession: {s.get('accession')} | {s.get('title')}")
print(f" Organism: {s.get('taxon')}")
print(f" Samples: {s.get('n_samples')}")
print(f" Type: {s.get('gdstype')}")
Query 3: Download and Parse a GEO Series
Use GEOparse to download a full GSE record with expression matrix and sample metadata.
import GEOparse
# Download GSE (auto-caches; skip download if already present)
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
# Series metadata
print(f"Title : {gse.metadata['title'][0]}")
print(f"Summary : {gse.metadata['summary'][0][:200]}...")
print(f"Samples : {len(gse.gsms)} GSMs")
print(f"Platforms: {list(gse.gpls.keys())}")
# Sample metadata table (phenotype data)
meta = gse.phenotype_data
print(f"Metadata columns: {list(meta.columns)}")
print(meta.head())
Query 4: Extract Expression Matrix
Parse probe-level expression data and optionally merge with platform gene annotations.
import GEOparse, pandas as pd
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
# Pivot to gene expression matrix (probes × samples)
gpl_id = list(gse.gpls.keys())[0]
pivot = gse.pivot_samples("VALUE", gpl_id)
print(f"Expression matrix shape: {pivot.shape}") # (probes, samples)
print(pivot.iloc[:5, :3])
# Annotate probes with gene symbols from the GPL platform
gpl = gse.gpls[gpl_id]
annot = gpl.table[["ID", "Gene Symbol", "Gene Title"]].copy()
annot.columns = ["ID", "gene_symbol", "gene_title"]
annot = annot.dropna(subset=["gene_symbol"])
annot = annot[annot["gene_symbol"] != ""]
expr_annotated = pivot.join(annot.set_index("ID"), how="inner")
print(f"Annotated expression matrix: {expr_annotated.shape}")
print(expr_annotated[["gene_symbol", "gene_title"]].head())
Query 5: Download Individual Sample (GSM)
Retrieve expression values and metadata for a single sample.
import GEOparse
gsm = GEOparse.get_GEO("GSM45553", destdir="./geo_data/", silent=True)
print(f"Title : {gsm.metadata['title'][0]}")
print(f"Source : {gsm.metadata.get('source_name_ch1', ['n/a'])[0]}")
print(f"Organism: {gsm.metadata.get('organism_ch1', ['n/a'])[0]}")
print(f"Data rows: {len(gsm.table)}")
print(gsm.table.head())
Query 6: Direct FTP Download for Large Series
For large datasets, download the series matrix file directly from GEO FTP.
import urllib.request, gzip, io, pandas as pd
# GEO series matrix file URL pattern
accession = "GSE2553"
series_num = accession[3:] # strip "GSE"
folder = f"GSE{series_num[:-3]}nnn" if len(series_num) > 3 else f"GSE{series_num[:-2]}nn"
url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{folder}/{accession}/matrix/{accession}_series_matrix.txt.gz"
with urllib.request.urlopen(url) as resp:
with gzip.open(resp, "rt", encoding="utf-8") as f:
lines = f.readlines()
# Find metadata lines (start with !) and data table
meta_lines = [l for l in lines if l.startswith("!")]
data_start = next(i for i, l in enumerate(lines) if l.startswith('"ID_REF"'))
df = pd.read_csv(
io.StringIO("".join(lines[data_start:])),
sep="\t", index_col=0
)
print(f"Matrix shape: {df.shape}")
print(df.iloc[:3, :3])
Key Concepts
GEO Record Types
- GSE (Series): A complete experiment with all samples and metadata
- GPL (Platform): The microarray or sequencing platform definition (probe/gene mapping)
- GSM (Sample): A single hybridization or sequencing run
- GDS (Dataset): Curated, normalized subset of a series (fewer than GSE records)
SuperSeries and SubSeries
Multi-assay or multi-batch submissions (e.g., RNA-seq + ATAC-seq) are organized as a SuperSeries GSE that references one or more SubSeries GSEs. Each SubSeries holds its own samples, platform, and matrix; the SuperSeries itself has no samples of its own. Both are tagged in gse.metadata:
- SuperSeries:
gse.metadata["relation"]contains entries like"SuperSeries of: GSExxxx" - SubSeries:
gse.metadata["relation"]contains"SubSeries of: GSEyyyy"
Always resolve SubSeries before pulling an expression matrix — downloading the SuperSeries alone yields metadata but no data.
import GEOparse
gse = GEOparse.get_GEO("GSE47966", destdir="./geo_data/", silent=True) # a SuperSeries
relations = gse.metadata.get("relation", [])
subseries = [r.split(": ")[1] for r in relations if r.startswith("SuperSeries of")]
print(f"SubSeries to download: {subseries}")
for acc in subseries:
sub = GEOparse.get_GEO(acc, destdir="./geo_data/", silent=True)
print(f" {acc}: {len(sub.gsms)} samples, platforms={list(sub.gpls.keys())}")
Soft vs. MiniML Format
GEOparse downloads SOFT-format files (plain text). For XML-based access, use MiniML format via E-utilities. Series Matrix files (tab-delimited) are the most compact format for expression data.
Common Workflows
Workflow 1: Download and Prepare Expression Data for DE Analysis
Goal: Download a GEO dataset, extract the expression matrix and group labels, and save for downstream differential expression analysis.
import GEOparse, pandas as pd
# Download series
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
# 1. Extract expression matrix
gpl_id = list(gse.gpls.keys())[0]
expr = gse.pivot_samples("VALUE", gpl_id)
# 2. Extract sample groups from characteristics
meta = gse.phenotype_data
print("Available metadata columns:", list(meta.columns))
# 3. Annotate probes with gene symbols
gpl = gse.gpls[gpl_id]
gene_col = "Gene Symbol" if "Gene Symbol" in gpl.table.columns else gpl.table.columns[1]
annot = gpl.table[["ID", gene_col]].dropna()
annot.columns = ["probe_id", "gene_symbol"]
annot = annot[annot["gene_symbol"].str.strip() != ""]
expr_genes = expr.join(annot.set_index("probe_id")[["gene_symbol"]], how="inner")
expr_genes = expr_genes.groupby("gene_symbol").mean() # average duplicate probes
print(f"Genes × Samples: {expr_genes.shape}")
expr_genes.to_csv("expression_matrix.csv")
meta.to_csv("sample_metadata.csv")
print("Saved: expression_matrix.csv, sample_metadata.csv")
Workflow 2: Search and Build a Dataset Inventory
Goal: Search GEO for studies matching a topic and build a curated inventory CSV.
import requests, time, pandas as pd
EMAIL = "your@email.com"
BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
topic = "Alzheimer disease"
r = requests.get(f"{BASE}/esearch.fcgi",
params={"db": "gds", "email": EMAIL, "retmode": "json", "retmax": 50,
"term": f"{topic}[title] AND Homo sapiens[organism] AND gse[entry type]"})
uids = r.json()["esearchresult"]["idlist"]
print(f"Found {len(uids)} GSE datasets for '{topic}'")
rows = []
for i in range(0, len(uids), 20):
batch = uids[i:i+20]
r2 = requests.post(f"{BASE}/esummary.fcgi",
data={"db": "gds", "id": ",".join(batch),
"retmode": "json", "email": EMAIL})
result = r2.json()["result"]
for uid in result.get("uids", []):
s = result[uid]
rows.append({
"accession": s.get("accession"),
"title": s.get("title"),
"n_samples": s.get("n_samples"),
"organism": s.get("taxon"),
"gds_type": s.get("gdstype"),
"pub_date": s.get("pdat"),
})
time.sleep(0.4)
df = pd.DataFrame(rows).sort_values("n_samples", ascending=False)
df.to_csv(f"{topic.replace(' ', '_')}_geo_datasets.csv", index=False)
print(df[["accession", "title", "n_samples"]].head(10).to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
destdir |
GEOparse.get_GEO | "./" |
any directory path | Where to save downloaded files |
silent |
GEOparse.get_GEO | False |
True/False |
Suppress download progress output |
retmax |
ESearch | 20 |
1–10000 |
Max dataset records returned |
entry type query |
ESearch | — | "gse", "gds", "gpl", "gsm" |
Filter by GEO record type |
VALUE column |
pivot_samples | — | column name in GSM table | Expression value column to pivot |
email |
E-utilities | required | valid email | NCBI rate-limit attribution |
Best Practices
-
Use
silent=Truein GEOparse: Suppresses verbose download progress; add your own print statement to confirm download. -
Cache downloads: GEOparse skips re-downloading if the
.soft.gzfile already exists indestdir. Set a shareddestdiracross sessions to avoid redundant downloads. -
Prefer Series Matrix for large datasets: For series with 100+ samples, download the
_series_matrix.txt.gzdirectly from FTP rather than parsing individual GSM soft files—it's orders of magnitude faster. -
Handle probe-to-gene mapping carefully: Many probes map to multiple genes or no gene. Decide how to handle multi-gene probes (drop, split, or keep) before analysis. Use
gene_symbol.str.split(" /// ")for Affymetrix arrays. -
Check platform column names: GPL annotation table column names vary by platform (e.g.,
"Gene Symbol"vs"GENE_SYMBOL"vs"gene_id"). Always inspectgpl.table.columnsbefore assuming field names. -
Always resolve SubSeries before analysis: After loading any GSE, inspect
gse.metadata.get("relation", [])for"SuperSeries of: ..."entries. If present, iterate every referenced SubSeries accession and download each one — the SuperSeries record itself carries no samples or expression matrices. Skipping this step silently drops the actual data.
Common Recipes
Recipe: Quick GSE Metadata Peek
When to use: Get series title, sample count, and platform for any GSE accession.
import GEOparse
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
print(f"Title : {gse.metadata['title'][0]}")
print(f"Samples: {len(gse.gsms)}")
print(f"Platform: {list(gse.gpls.keys())}")
print(f"Summary: {gse.metadata['summary'][0][:300]}")
Recipe: Extract Sample Characteristics
When to use: Parse GEO sample characteristics into a tidy DataFrame for grouping.
import GEOparse, pandas as pd, re
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
meta = gse.phenotype_data
# Parse "characteristics_ch1" columns
ch_cols = [c for c in meta.columns if "characteristics" in c.lower()]
print(f"Characteristic columns: {ch_cols}")
print(meta[ch_cols].head())
Recipe: List All GSMs in a Series
When to use: Enumerate sample accessions for download or metadata collection.
import GEOparse
gse = GEOparse.get_GEO("GSE2553", destdir="./geo_data/", silent=True)
gsm_ids = list(gse.gsms.keys())
print(f"Total samples: {len(gsm_ids)}")
print("First 5:", gsm_ids[:5])
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FileNotFoundError during download |
Incorrect destdir |
Create directory first: os.makedirs("geo_data/", exist_ok=True) |
pivot_samples returns empty DataFrame |
GPL annotation table missing ID |
Check gpl.table.columns; use correct probe ID column name |
KeyError for "Gene Symbol" |
Platform uses different column name | Inspect gpl.table.columns and use the correct annotation column |
| Download hangs for large series | Large SOFT file (GB range) | Use FTP Series Matrix download instead of GEOparse for large series |
| ESearch returns 0 results | Wrong entry type or field tag |
Switch gse[entry type] to gds[entry type]; verify query syntax |
Numeric sample columns contain null |
Missing/absent expression values | Fill with df.fillna(0) or drop columns with high missingness |
GSE has no samples / empty gse.gsms |
Accession is a SuperSeries | Parse gse.metadata["relation"] for SuperSeries of: entries and download each SubSeries |
Related Skills
cellxgene-census— Single-cell RNA-seq data at scale (61M+ cells) as an alternative to GEO for scRNA-seqgene-database— NCBI Gene records with curated annotations for genes found in GEO studiespubmed-database— Retrieve publications linked to GEO datasets via NCBI ELinkpydeseq2-differential-expression— Downstream differential expression analysis after loading GEO count data
References
- GEO database home — Browse and search GEO datasets
- GEOparse GitHub — Python library documentation and examples
- GEO FTP server — Direct file access for series, samples, and platforms
- NCBI E-utilities for GEO — ESearch/ESummary API reference for the
gdsdatabase
skills/genomics-bioinformatics/databases/gget-genomic-databases/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gget-genomic-databases -g -y
SKILL.md
Frontmatter
{
"name": "gget-genomic-databases",
"license": "BSD-2-Clause",
"description": "Unified CLI\/Python interface to 20+ genomic databases. Gene lookups (Ensembl search\/info\/seq), BLAST\/BLAT, AlphaFold, Enrichr enrichment, OpenTargets disease\/drug, CELLxGENE single-cell, cBioPortal\/COSMIC cancer, ARCHS4 expression. Spans genomics, proteomics, disease. For batch\/advanced BLAST use biopython; for multi-DB Python SDK use bioservices."
}
gget — Unified Genomic Database Access
Overview
gget is a command-line and Python package providing unified access to 20+ genomic databases and analysis methods. Query gene information, sequences, protein structures, expression data, and disease associations through a consistent interface. All modules work as both CLI tools and Python functions, returning DataFrames (Python) or JSON/CSV (CLI).
When to Use
- Looking up gene information (names, IDs, descriptions) across species from Ensembl
- Retrieving nucleotide or protein sequences for Ensembl gene/transcript IDs
- Running BLAST or BLAT searches against standard reference databases
- Predicting protein 3D structures with AlphaFold2 from amino acid sequences
- Performing gene set enrichment analysis (GO, KEGG, disease terms) via Enrichr
- Querying single-cell RNA-seq datasets from CELLxGENE Census
- Finding disease and drug associations for a gene target via OpenTargets
- Downloading Ensembl reference genomes and annotations for a species
- Finding cancer mutations and genomic alterations via cBioPortal or COSMIC
- Getting tissue expression and correlated genes from ARCHS4
- For batch processing or advanced BLAST parameters, use
biopythoninstead - For programmatic multi-database workflows with rate limiting, use
bioservicesinstead
Prerequisites
- Python packages:
gget - Optional setup: Some modules require
gget setup <module>before first use (alphafold, cellxgene, elm, gpt) - Environment: Clean virtual environment recommended to avoid dependency conflicts
- API notes: gget queries remote databases — rate-limit large batch queries with
time.sleep(). Databases update biweekly; keep gget updated. Max ~1000 Ensembl IDs pergget.info()call
pip install gget
# Optional: setup modules that need additional dependencies
gget setup alphafold # ~4GB model parameters, requires OpenMM
gget setup cellxgene # cellxgene-census package
gget setup elm # local ELM database
Quick Start
import gget
# Search for genes by keyword
results = gget.search(["BRCA1", "tumor suppressor"], species="homo_sapiens")
print(f"Found {len(results)} genes")
# Get detailed gene information (Ensembl + UniProt + NCBI)
info = gget.info(["ENSG00000012048"])
print(f"Gene: {info.iloc[0]['primary_gene_name']}")
# Enrichment analysis on a gene list
enrichment = gget.enrichr(["ACE2", "AGT", "AGTR1"], database="ontology")
print(f"Enriched terms: {len(enrichment)}")
Core API
Module 1: Reference & Gene Search (ref, search, info, seq)
Query Ensembl for gene references, search by keywords, retrieve gene metadata, and fetch sequences.
import gget
# Search for genes by keyword
results = gget.search(["BRCA1", "tumor suppressor"], species="homo_sapiens")
print(f"Found {len(results)} genes")
print(results[["ensembl_id", "gene_name", "biotype"]].head())
# Get detailed gene information (Ensembl + UniProt + NCBI)
info = gget.info(["ENSG00000012048", "ENSG00000139618"])
print(f"Gene info columns: {list(info.columns)}")
import gget
# Retrieve sequences
nucleotide_seqs = gget.seq(["ENSG00000012048"])
protein_seqs = gget.seq(["ENSG00000012048"], translate=True, isoforms=True)
print(f"Retrieved {len(protein_seqs)} isoform sequences")
# Download reference genome files (specify release for reproducibility)
ref_links = gget.ref("homo_sapiens", which="gtf", release=112)
print(f"GTF download link: {ref_links}")
Module 2: Sequence Alignment (blast, blat, muscle, diamond)
BLAST/BLAT remote searches, multiple sequence alignment, and fast local alignment.
import gget
import time
# BLAST against SwissProt (remote API — add delay for batch queries)
blast_results = gget.blast(
"MKWMFKEDHSLEHRCVESAKIRAKYPDRVPVIVEKVSGSQIVDIDKRKYLVPSDITVAQFMWIIRKRIQLPSEKAIFLFVDKTVPQSR",
database="swissprot", limit=10
)
print(f"Top hit: {blast_results.iloc[0]['Description']}, E-value: {blast_results.iloc[0]['e-value']}")
time.sleep(2) # Rate-limit between BLAST queries
# BLAT — find genomic position (UCSC)
blat_results = gget.blat("ATCGATCGATCGATCGATCG", assembly="human")
print(f"Genomic location: chr{blat_results.iloc[0]['chromosome']}:{blat_results.iloc[0]['start']}")
import gget
# Multiple sequence alignment with Muscle5
aligned = gget.muscle("sequences.fasta", save=True)
# Fast local alignment with DIAMOND (local, no rate limit needed)
diamond_results = gget.diamond(
"GGETISAWESQME",
reference="reference.fasta",
sensitivity="very-sensitive",
threads=4
)
print(f"Alignments found: {len(diamond_results)}")
Module 3: Protein Structure (pdb, alphafold, elm)
Download PDB structures, predict structures with AlphaFold2, find linear motifs.
import gget
# Download PDB structure
pdb_data = gget.pdb("7S7U", save=True)
# Predict structure with AlphaFold2 (requires gget setup alphafold)
structure = gget.alphafold(
"MKWMFKEDHSLEHRCVESAKIRAKYPDRVPVIVEKVSGSQIVDIDKRKYLVPSDITVAQFMWIIRKRIQLPSEKAIFLFVDKTVPQSR",
plot=True, show_sidechains=True
)
print("Structure prediction complete, PDB file saved")
import gget
# Find Eukaryotic Linear Motifs (requires gget setup elm)
ortholog_df, regex_df = gget.elm("LIAQSIGQASFV")
print(f"Ortholog motifs: {len(ortholog_df)}, Regex motifs: {len(regex_df)}")
Module 4: Expression & Correlation (archs4, cellxgene, bgee)
Gene expression, tissue expression, correlated genes, single-cell data.
import gget
# Tissue expression from ARCHS4
tissue_expr = gget.archs4("ACE2", which="tissue")
print(f"Expression across {len(tissue_expr)} tissues")
# Correlated genes from ARCHS4
correlated = gget.archs4("ACE2", which="correlation")
print(f"Top correlated gene: {correlated.iloc[0]['gene_symbol']}")
import gget
# Single-cell data from CELLxGENE (requires gget setup cellxgene)
adata = gget.cellxgene(
gene=["ACE2", "TMPRSS2"],
tissue="lung",
cell_type="epithelial cell",
census_version="2023-07-25" # pin version for reproducibility
)
print(f"Cells: {adata.n_obs}, Genes: {adata.n_vars}")
# Orthologs and expression from Bgee
orthologs = gget.bgee("ENSG00000169194", type="orthologs")
print(f"Orthologs in {len(orthologs)} species")
Module 5: Disease & Drug Associations (opentargets, enrichr)
Disease associations, drug targets, enrichment analysis.
import gget
# Disease associations from OpenTargets
diseases = gget.opentargets("ENSG00000169194", resource="diseases", limit=10)
print(f"Associated diseases: {len(diseases)}")
# Drug associations
drugs = gget.opentargets("ENSG00000169194", resource="drugs", limit=10)
print(f"Associated drugs: {len(drugs)}")
# OpenTargets resources: diseases, drugs, tractability, pharmacogenetics,
# expression, depmap, interactions
import gget
# Enrichment analysis via Enrichr
# Database shortcuts: 'pathway' (KEGG), 'transcription' (ChEA),
# 'ontology' (GO_BP), 'diseases_drugs' (GWAS), 'celltypes' (PanglaoDB)
enrichment = gget.enrichr(
["ACE2", "AGT", "AGTR1", "TMPRSS2", "DPP4"],
database="ontology"
)
print(f"Enriched terms: {len(enrichment)}")
print(enrichment[["Term", "Adjusted P-value"]].head())
Module 6: Cancer Genomics (cbio, cosmic)
Cancer mutations, copy number alterations, and somatic mutation databases.
import gget
# Search cBioPortal studies
studies = gget.cbio_search(["breast", "lung"])
print(f"Studies found: {len(studies)}")
# Plot cancer genomics heatmap
gget.cbio_plot(
["msk_impact_2017"],
["AKT1", "ALK", "BRAF"],
stratification="tissue",
variation_type="mutation_occurrences"
)
import gget
# COSMIC: requires account + local database download
# First-time: gget.cosmic(searchterm="", download_cosmic=True,
# email="user@example.com", password="xxx", cosmic_project="cancer")
cosmic_results = gget.cosmic("EGFR", cosmic_tsv_path="cosmic_data.tsv", limit=10)
print(f"COSMIC mutations: {len(cosmic_results)}")
Module 7: Mutation Generation & Utilities (mutate, setup)
Generate mutated sequences and manage module dependencies.
import gget
import pandas as pd
# Generate mutated sequences from mutation annotations
mutations_df = pd.DataFrame({
"seq_ID": ["seq1", "seq1"],
"mutation": ["c.4G>T", "c.10del"]
})
mutated = gget.mutate(["ATCGCTAAGCTGATCG"], mutations=mutations_df)
print(f"Generated {len(mutated)} mutated sequences")
Key Concepts
Module Overview
gget organizes 20+ modules by domain. Python interface uses gget.<module>():
| Domain | Modules | Primary Database |
|---|---|---|
| Gene reference | ref, search, info, seq |
Ensembl, UniProt, NCBI |
| Sequence alignment | blast, blat, muscle, diamond |
NCBI BLAST, UCSC, local |
| Protein structure | pdb, alphafold, elm |
RCSB PDB, AlphaFold2, ELM |
| Expression | archs4, cellxgene, bgee |
ARCHS4, CZ CELLxGENE, Bgee |
| Disease/drugs | opentargets, enrichr |
OpenTargets, Enrichr |
| Cancer | cbio, cosmic |
cBioPortal, COSMIC |
| Utilities | mutate, setup, gpt |
local / OpenAI |
Output Formats
| Context | Default Format | Alternatives |
|---|---|---|
| Python | DataFrame or dict | json=True for JSON; save=True to file |
| CLI | JSON | -csv for CSV; -o file to save |
| Sequences | FASTA (seq, mutate) | -- |
| Structures | PDB file (pdb, alphafold) | JSON alignment error data |
| Single-cell | AnnData object (cellxgene) | meta_only=True for metadata only |
| Visualization | PNG (cbio plot) | show=True for interactive display |
Enrichr Database Shortcuts
| Shortcut | Full Database Name |
|---|---|
'pathway' |
KEGG_2021_Human |
'transcription' |
ChEA_2016 |
'ontology' |
GO_Biological_Process_2021 |
'diseases_drugs' |
GWAS_Catalog_2019 |
'celltypes' |
PanglaoDB_Augmented_2021 |
Custom libraries: pass any Enrichr library name directly (e.g., "Jensen_TISSUES").
OpenTargets Resources
| Resource | Description |
|---|---|
diseases |
Disease associations with evidence scores |
drugs |
Drug associations and clinical trial data |
tractability |
Target tractability assessment |
pharmacogenetics |
Pharmacogenetic variants |
expression |
Baseline tissue expression |
depmap |
DepMap gene-disease effects |
interactions |
Protein-protein interactions |
Reproducibility
Pin database versions for consistent results across analyses:
import gget
# Pin Ensembl release
ref = gget.ref("homo_sapiens", release=112)
# Pin CELLxGENE Census version
adata = gget.cellxgene(gene=["ACE2"], census_version="2023-07-25")
# Always record gget version
print(f"gget version: {gget.__version__}")
Common Workflows
Workflow 1: Gene Discovery to Functional Analysis
Goal: Find genes of interest, get their sequences, and perform enrichment analysis.
import gget
# 1. Search for genes
results = gget.search(["GABA", "receptor"], species="homo_sapiens")
gene_ids = results["ensembl_id"].tolist()[:10]
# 2. Get detailed information
info = gget.info(gene_ids)
print(f"Retrieved info for {len(info)} genes")
# 3. Get protein sequences
sequences = gget.seq(gene_ids, translate=True)
# 4. Find correlated genes
correlated = gget.archs4(info.index[0], which="correlation")
# 5. Enrichment analysis on correlated genes
gene_list = correlated["gene_symbol"].tolist()[:50]
enrichment = gget.enrichr(gene_list, database="ontology")
print(f"Top enriched term: {enrichment.iloc[0]['Term']}")
Workflow 2: Target Validation for Drug Discovery
Goal: Investigate a gene's disease associations, druggability, and cancer mutations.
import gget
gene_id = "ENSG00000169194" # ZBTB16
# 1. Disease associations
diseases = gget.opentargets(gene_id, resource="diseases", limit=20)
# 2. Drug associations
drugs = gget.opentargets(gene_id, resource="drugs")
# 3. Tractability assessment
tractability = gget.opentargets(gene_id, resource="tractability")
# 4. Protein interactions
interactions = gget.opentargets(gene_id, resource="interactions")
print(f"Diseases: {len(diseases)}, Drugs: {len(drugs)}, Interactions: {len(interactions)}")
# 5. Cancer genomics
gget.cbio_plot(["msk_impact_2017"], ["ZBTB16"], stratification="cancer_type")
Workflow 3: Comparative Genomics
Goal: Compare a gene across species using orthologs and sequence alignment.
import gget
# 1. Find orthologs
orthologs = gget.bgee("ENSG00000169194", type="orthologs")
# 2. Get sequences for human and mouse
human_seq = gget.seq("ENSG00000169194", translate=True)
mouse_seq = gget.seq("ENSMUSG00000026091", translate=True)
# 3. Align sequences
alignment = gget.muscle([human_seq, mouse_seq])
# 4. Get human protein structure from PDB
pdb_structure = gget.pdb("7S7U")
print("Comparative analysis complete")
Key Parameters
| Parameter | Module(s) | Default | Range / Options | Effect |
|---|---|---|---|---|
species |
search, archs4, cellxgene, enrichr | "homo_sapiens" |
Any Ensembl species; shortcuts: 'human', 'mouse' | Target organism |
limit |
blast, opentargets, cosmic | 50 / 100 |
1-1000 |
Maximum results returned |
database |
blast, enrichr | varies | blast: nt/nr/swissprot/pdbaa; enrichr: shortcuts or library names | Target database for query |
which |
ref, archs4 | varies | ref: gtf,cdna,dna,cds,pep; archs4: correlation,tissue |
Data type to retrieve |
translate |
seq | False |
True/False |
Return amino acid instead of nucleotide sequences |
resource |
opentargets | "diseases" |
diseases, drugs, tractability, pharmacogenetics, expression, depmap, interactions | OpenTargets data type |
release |
ref, search | latest | Integer Ensembl release number | Pin database version for reproducibility |
census_version |
cellxgene | "stable" |
"stable", "latest", date string |
Pin CELLxGENE Census version |
sensitivity |
diamond, elm | "very-sensitive" |
fast to ultra-sensitive |
Alignment sensitivity vs speed |
threads |
diamond, elm | 1 |
1-N |
CPU threads for alignment |
multimer_recycles |
alphafold | 3 |
3-20 |
Higher = more accurate multimer prediction |
Best Practices
-
Pin database versions for reproducibility: Use
release=112for Ensembl andcensus_version="2023-07-25"for CELLxGENE to ensure consistent results across analyses. -
Rate-limit batch queries: gget queries remote APIs. Add
time.sleep(2)between BLAST/BLAT queries in loops. Forgget.info(), limit to ~1000 IDs per call. -
Keep gget updated: Databases change their structure biweekly. Run
pip install --upgrade ggetregularly to avoid breakage from schema changes. -
Use Python interface for pipelines, CLI for exploration: Python functions return DataFrames suitable for chaining. CLI with
-csvis better for quick one-off lookups. -
Check PDB before running AlphaFold:
gget.pdb()is instant; AlphaFold prediction takes minutes to hours. Always check if the structure already exists in PDB. -
Use database shortcuts in enrichr: The shortcuts (
'pathway','ontology', etc.) map to curated Enrichr libraries. For custom analyses, pass any Enrichr library name directly. -
Cache cBioPortal data for repeated analyses: Use
data_dir="./cache"parameter to avoid re-downloading large cancer genomics datasets.
Common Recipes
Recipe: Batch Gene Information Retrieval
When to use: Need information for many genes at once (up to ~1000 IDs per call).
import gget
import time
gene_ids = ["ENSG00000012048", "ENSG00000139618", "ENSG00000141510"]
info = gget.info(gene_ids)
info.to_csv("gene_info_batch.csv")
print(f"Saved info for {len(info)} genes")
# For >1000 genes, batch with rate limiting
all_ids = [f"ENSG{i:011d}" for i in range(2000)]
results = []
for i in range(0, len(all_ids), 500):
batch = all_ids[i:i+500]
results.append(gget.info(batch))
time.sleep(1)
Recipe: Custom Enrichment with Background
When to use: Running enrichment against a custom background gene set.
import gget
# Use specific Enrichr library with background genes
enrichment = gget.enrichr(
["ACE2", "AGT", "AGTR1"],
database="Jensen_TISSUES",
background_list=["ACE2", "AGT", "AGTR1", "TP53", "BRCA1", "MYC"]
)
print(enrichment[["Term", "Adjusted P-value"]].head())
Recipe: AlphaFold Structure Prediction with Visualization
When to use: Predicting and visualizing protein structures with confidence coloring.
import gget
# Predict with visualization (PAE + 3D structure)
result = gget.alphafold(
"MKWMFKEDHSLEHRCVESAKIRAKYPDRVPVIVEKVSGSQIVDIDKRKYLVPSDITVAQFMWIIRKRIQLPSEKAIFLFVDKTVPQSR",
plot=True,
show_sidechains=True,
relax=True # AMBER relaxation for final structure
)
# Output: PDB file + predicted aligned error (PAE) JSON
# PAE heatmap auto-generated with plot=True
Recipe: Download Reference Genome for RNA-seq Pipeline
When to use: Setting up reference files for RNA-seq alignment pipelines.
# Download GTF and cDNA for human (specific release)
gget ref -w gtf -w cdna -d -r 112 homo_sapiens
# Download genome DNA
gget ref -w dna -d homo_sapiens
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ModuleNotFoundError: gget |
Package not installed | pip install gget in clean virtual environment |
gget setup alphafold fails |
Python version incompatibility | Use Python 3.8-3.10; check gget --version |
| Empty BLAST results | Sequence too short or no matches | Try longer sequence, different database, or megablast_off=True |
cellxgene gene not found |
Case-sensitive gene symbols | Use 'ACE2' for human, 'Ace2' for mouse (exact capitalization required) |
gget info timeout |
Too many IDs at once | Limit to ~1000 Ensembl IDs per call; batch with time.sleep() |
| Database structure changed | gget databases update biweekly | pip install --upgrade gget |
| COSMIC authentication error | Missing or expired credentials | Re-enter email/password; check COSMIC account status |
| AlphaFold out of memory | Protein too long for GPU memory | Use shorter sequences or split into domains |
| Different results on re-run | Database updated between runs | Pin versions: release=112 for Ensembl, census_version for CELLxGENE |
Bundled Resources
2 reference files provide extended coverage of capabilities from the original 3 reference files and 3 script files:
-
references/module_parameters.md— Consolidates module_reference.md (468 lines). Covers: detailed parameter tables for all 15+ modules with types, defaults, and return value descriptions; CLI vs Python interface differences; setup requirements per module. Relocated inline: most-used module parameters (Core API code blocks), output format summary (Key Concepts table). Omitted: gget gpt module details — trivial OpenAI wrapper, not genomics-specific. -
references/databases_workflows.md— Consolidates database_info.md (301 lines) and workflows.md (815 lines). Covers: complete database directory with update frequencies and citation info, extended workflow examples (building reference indices, disease-drug pipeline, multi-species comparative analysis), data consistency and reproducibility guidance. Relocated inline: core database overview (Key Concepts table), top 3 workflows (Common Workflows), reproducibility patterns (Key Concepts). Omitted: scripts/ content (3 files, 590 lines total) — thin wrappers around gget API calls for CLI automation; core patterns absorbed into Core API and Common Workflows.
Related Skills
- biopython — advanced BLAST parameters, batch sequence processing, GenBank record parsing
- bioservices — programmatic multi-database queries with built-in rate limiting (UniProt, KEGG, ChEMBL)
- anndata-data-structure — working with AnnData objects returned by
gget.cellxgene() - enrichr — deeper enrichment analysis with custom gene set libraries
References
- gget documentation — official docs and tutorials
- gget GitHub — source code, issues
- Luebbert, L. & Pachter, L. (2023). Efficient querying of genomic reference databases with gget. Bioinformatics. https://doi.org/10.1093/bioinformatics/btac836
skills/genomics-bioinformatics/databases/gnomad-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gnomad-database -g -y
SKILL.md
Frontmatter
{
"name": "gnomad-database",
"license": "ODbL-1.0",
"description": "gnomAD v4 population variant frequencies via GraphQL API. Allele counts and frequencies stratified by ancestry (AFR, AMR, EAS, NFE, SAS, FIN, ASJ, MID), gene-level constraint (pLI, LOEUF, missense z), and coverage. Identify rare or constrained variants. For clinical pathogenicity use clinvar-database; for GWAS use gwas-database."
}
gnomAD Database
Overview
The Genome Aggregation Database (gnomAD) is a resource of aggregated exome and genome sequencing data from 730,000+ individuals. It provides population variant frequencies stratified by 9 ancestry groups, gene-level constraint scores (pLI, LOEUF), and read coverage information. Access is free via a GraphQL API at https://gnomad.broadinstitute.org/api — no authentication required, no official SDK.
When to Use
- Checking whether a candidate variant is rare enough to be clinically relevant (AF < 0.1% in all populations)
- Retrieving allele frequencies stratified by ancestry group (AFR, AMR, EAS, NFE, SAS, FIN, ASJ, MID) for a variant
- Identifying all rare loss-of-function variants in a gene for burden testing or candidate prioritization
- Getting gene constraint metrics (pLI, LOEUF) to assess tolerance to loss-of-function variants
- Checking read depth coverage for a region to evaluate if low variant frequency reflects low sequencing coverage
- Filtering a VCF by population frequency — query gnomAD AF to discard common variants before clinical interpretation
- For clinical pathogenicity classifications use
clinvar-database; gnomAD provides frequency evidence but does not classify pathogenicity - For GWAS associations at the study level use
gwas-database; gnomAD is for population frequency lookups
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: gene symbols (e.g.,
BRCA1), variant IDs (1-69511-A-Gformat, or rsIDs) - Environment: internet connection; no API key required
- Rate limits: no official published limits; use
time.sleep(0.5)between requests for polite access; avoid bursts over 10 requests/second
pip install requests pandas matplotlib
Quick Start
import requests
import time
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def gnomad_query(query: str, variables: dict = None) -> dict:
"""Execute a gnomAD GraphQL query and return the data payload."""
payload = {"query": query, "variables": variables or {}}
r = requests.post(GNOMAD_API, json=payload, timeout=30)
r.raise_for_status()
result = r.json()
if "errors" in result:
raise ValueError(f"GraphQL errors: {result['errors']}")
return result["data"]
# Quick check: get pLI / LOEUF for BRCA1
# GnomadConstraint fields are FLAT (no nested `lof { oe_ci { upper } }` type).
# `pli` is the current field; `pLI` is preserved as a deprecated alias.
query = """
query GeneConstraint($gene_symbol: String!, $reference_genome: ReferenceGenomeId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gnomad_constraint { pli oe_lof_upper }
}
}
"""
data = gnomad_query(query, {"gene_symbol": "BRCA1", "reference_genome": "GRCh38"})
constraint = data["gene"]["gnomad_constraint"]
print(f"BRCA1 pLI: {constraint['pli']:.3e}") # ~5.5e-38 (very high LoF-intolerant)
print(f"BRCA1 LOEUF: {constraint['oe_lof_upper']:.3f}") # 0.928
Core API
Query 1: Gene Variant Query
Fetch all variants in a gene with population allele frequencies. Returns a list of variants with their genome-level frequencies.
import requests, time
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def gnomad_query(query, variables=None):
r = requests.post(GNOMAD_API, json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
result = r.json()
if "errors" in result:
raise ValueError(f"GraphQL errors: {result['errors']}")
return result["data"]
GENE_VARIANTS_QUERY = """
query GeneVariants($gene_symbol: String!, $reference_genome: ReferenceGenomeId!, $dataset: DatasetId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gene_id
symbol
variants(dataset: $dataset) {
variant_id
rsids
chrom
pos
ref
alt
consequence
lof
genome {
an
ac
af
faf95 { popmax popmax_population }
}
}
}
}
"""
data = gnomad_query(GENE_VARIANTS_QUERY, {
"gene_symbol": "PCSK9",
"reference_genome": "GRCh38",
"dataset": "gnomad_r4"
})
variants = data["gene"]["variants"]
print(f"Gene: {data['gene']['symbol']} ({data['gene']['gene_id']})")
print(f"Total variants: {len(variants)}")
# Filter to rare variants (AF < 0.001)
rare = [v for v in variants if v["genome"] and v["genome"]["af"] is not None and v["genome"]["af"] < 0.001]
print(f"Rare variants (AF < 0.1%): {len(rare)}")
for v in rare[:3]:
print(f" {v['variant_id']} | {v['consequence']} | AF={v['genome']['af']:.2e}")
Query 2: Variant Lookup
Fetch detailed information for a single variant by its gnomAD variant ID (CHROM-POS-REF-ALT format) or search by rsID.
VARIANT_QUERY = """
query VariantDetails($variantId: String!, $dataset: DatasetId!) {
variant(variantId: $variantId, dataset: $dataset) {
variant_id
rsids
chrom
pos
ref
alt
transcript_consequences {
gene_symbol
transcript_id
is_canonical
major_consequence
lof
lof_filter
lof_flags
}
genome {
an
ac
af
faf95 { popmax popmax_population }
populations { id ac an homozygote_count }
}
}
}
"""
# Query.variant() arg is `variantId` (camelCase). The top-level deprecated
# `consequence`/`lof`/`lof_filter`/`lof_flags` fields on VariantDetails were
# removed — read them from `transcript_consequences` (plural list; pick the
# canonical transcript with is_canonical=True).
data = gnomad_query(VARIANT_QUERY, {
"variantId": "1-55039974-G-T", # PCSK9 p.Tyr142Ter (LoF)
"dataset": "gnomad_r4"
})
v = data["variant"]
canon = next((t for t in (v.get("transcript_consequences") or []) if t.get("is_canonical")),
(v.get("transcript_consequences") or [{}])[0])
print(f"Variant : {v['variant_id']}")
print(f"rsIDs : {v['rsids']}")
print(f"Gene : {canon.get('gene_symbol')}")
print(f"Consequence : {canon.get('major_consequence')} | LoF: {canon.get('lof')}")
g = v["genome"]
print(f"Genome AF : {g['af']:.2e} (AC={g['ac']}, AN={g['an']})")
print(f"FAF95 popmax: {g['faf95']['popmax']:.2e} in {g['faf95']['popmax_population']}")
Query 3: Population Frequencies
Retrieve allele frequency broken down by ancestry group for a specific variant.
import pandas as pd
POPULATION_FREQ_QUERY = """
query PopFreqs($variantId: String!, $dataset: DatasetId!) {
variant(variantId: $variantId, dataset: $dataset) {
variant_id
genome {
populations {
id
ac
an
homozygote_count
}
}
}
}
"""
ANCESTRY_LABELS = {
"afr": "African/African American",
"amr": "Admixed American",
"eas": "East Asian",
"fin": "Finnish",
"nfe": "Non-Finnish European",
"sas": "South Asian",
"asj": "Ashkenazi Jewish",
"mid": "Middle Eastern",
"oth": "Other",
}
data = gnomad_query(POPULATION_FREQ_QUERY, {
"variantId": "1-55039974-G-T",
"dataset": "gnomad_r4"
})
pops = data["variant"]["genome"]["populations"]
# VariantPopulation no longer exposes `af` directly — compute from ac/an.
main_pops = [p for p in pops if p["id"] in ANCESTRY_LABELS and p["an"] > 0]
df = pd.DataFrame(main_pops)
df["af"] = df["ac"] / df["an"]
df["label"] = df["id"].map(ANCESTRY_LABELS)
df = df.sort_values("af", ascending=False)
print(df[["label", "ac", "an", "af", "homozygote_count"]].to_string(index=False))
Query 4: Coverage Query
Retrieve per-base read depth coverage for a gene region to assess data completeness.
COVERAGE_QUERY = """
query Coverage($chrom: String!, $start: Int!, $stop: Int!,
$reference_genome: ReferenceGenomeId!, $dataset: DatasetId!) {
region(chrom: $chrom, start: $start, stop: $stop, reference_genome: $reference_genome) {
coverage(dataset: $dataset) {
exome { mean median over_1 over_10 over_20 over_30 over_100 }
genome { mean median over_1 over_10 over_20 over_30 over_100 }
}
}
}
"""
# Coverage is no longer a top-level Query field — it lives under Region, and the
# `RegionCoverage` shape returns parallel `exome` / `genome` arrays (one entry per
# position; there is no per-row `pos` — the array index is implicit position).
data = gnomad_query(COVERAGE_QUERY, {
"chrom": "1",
"start": 55039700,
"stop": 55040200,
"reference_genome": "GRCh38",
"dataset": "gnomad_r4",
})
cov = data["region"]["coverage"]
exome = cov.get("exome") or []
genome = cov.get("genome") or []
print(f"Coverage positions: exome={len(exome)}, genome={len(genome)}")
if exome:
avg_mean = sum(c["mean"] for c in exome) / len(exome)
pct_20x = sum(1 for c in exome if (c.get("over_20") or 0) > 0.9) / len(exome) * 100
print(f"Exome — average mean depth: {avg_mean:.1f}x; {pct_20x:.1f}% positions >=90% at >=20x")
c = exome[0]
print(f" Sample position: mean={c['mean']:.1f}x, median={c['median']}x, "
f">=10x:{c['over_10']:.3f} >=20x:{c['over_20']:.3f} >=30x:{c['over_30']:.3f}")
Query 5: Gene Constraint
Retrieve gene-level constraint scores: pLI (probability of loss-of-function intolerance), LOEUF (LoF observed/expected upper bound fraction), and missense z-score.
CONSTRAINT_QUERY = """
query GeneConstraint($gene_symbol: String!, $reference_genome: ReferenceGenomeId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gene_id
symbol
name
gnomad_constraint {
pli
mis_z
lof_z
obs_lof
exp_lof
oe_lof
oe_lof_lower
oe_lof_upper
}
}
}
"""
# GnomadConstraint fields are flat (no nested `lof { obs exp oe oe_ci }`).
# `gene_name` was removed from Gene — use `name` or `symbol`. `pNull`/`pRec` are gone.
genes = ["PCSK9", "BRCA1", "TP53", "TTN"]
print(f"{'Gene':<10} {'pLI':>10} {'LOEUF':>7} {'mis_z':>7}")
print("-" * 38)
for gene in genes:
data = gnomad_query(CONSTRAINT_QUERY, {"gene_symbol": gene, "reference_genome": "GRCh38"})
c = data["gene"]["gnomad_constraint"]
print(f"{gene:<10} {c['pli']:>10.3e} {c['oe_lof_upper']:>7.3f} {c['mis_z']:>7.2f}")
time.sleep(0.5)
# Gene pLI LOEUF mis_z
# PCSK9 4.27e-04 0.456 1.41
# BRCA1 5.52e-38 0.928 1.73
# TP53 8.86e-22 1.020 1.93
# TTN 1.00e+00 0.871 1.30
Query 6: Variant Search by Region
Fetch all variants in a chromosomal region, useful for targeted panels and regional analyses.
REGION_VARIANTS_QUERY = """
query RegionVariants($chrom: String!, $start: Int!, $stop: Int!,
$dataset: DatasetId!, $reference_genome: ReferenceGenomeId!) {
region(chrom: $chrom, start: $start, stop: $stop,
reference_genome: $reference_genome) {
variants(dataset: $dataset) {
variant_id
rsids
pos
consequence
lof
genome {
af
ac
an
faf95 { popmax }
}
}
}
}
"""
data = gnomad_query(REGION_VARIANTS_QUERY, {
"chrom": "1",
"start": 55039974,
"stop": 55064852, # PCSK9 coding region
"dataset": "gnomad_r4",
"reference_genome": "GRCh38"
})
variants = data["region"]["variants"]
print(f"Variants in region: {len(variants)}")
# Summarize by consequence
from collections import Counter
conseq_counts = Counter(v["consequence"] for v in variants if v["consequence"])
for c, n in conseq_counts.most_common(5):
print(f" {c}: {n}")
# Loss-of-function variants
lof_vars = [v for v in variants if v["lof"] == "HC"]
print(f"\nHigh-confidence LoF variants: {len(lof_vars)}")
for v in lof_vars[:3]:
af = v["genome"]["af"] if v["genome"] else None
print(f" {v['variant_id']} | AF={af:.2e}" if af else f" {v['variant_id']} | AF=NA")
Key Concepts
gnomAD Data Model
gnomAD v4 has two datasets: gnomad_r4 (exomes + genomes, GRCh38, 730K+ individuals) and gnomad_r2_1 (GRCh37, 141K individuals). The API uses a GraphQL schema where variants are accessed either through gene(), region(), or direct variant() lookups. Each variant has separate exome and genome frequency objects; the genome object is preferred for population frequency comparisons.
Ancestry Groups
gnomAD v4 reports frequencies for 9 top-level ancestry groups identified by genetic ancestry (not self-reported):
| Code | Population | Dataset size (approx) |
|---|---|---|
afr |
African/African American | 76,000+ |
amr |
Admixed American | 45,000+ |
eas |
East Asian | 50,000+ |
fin |
Finnish | 24,000+ |
nfe |
Non-Finnish European | 400,000+ |
sas |
South Asian | 80,000+ |
asj |
Ashkenazi Jewish | 10,000+ |
mid |
Middle Eastern | 5,000+ |
oth |
Other/Unknown | varies |
Filtering Allele Frequency (FAF95)
The faf95 field provides a one-sided 95% confidence interval lower bound on the allele frequency in the population where the variant is most common. Use this for conservative variant filtering in clinical pipelines — a variant with faf95.popmax < 0.001 is likely rare enough to warrant clinical investigation.
Constraint Scores
| Score | Interpretation |
|---|---|
pLI > 0.9 |
Gene is intolerant to LoF — likely essential |
LOEUF < 0.35 |
Strong LoF constraint (upper CI of oe ratio) |
mis_z > 3.09 |
Gene shows significant missense constraint |
pLI < 0.1 |
Gene tolerates LoF — homozygous LoF variants exist |
Common Workflows
Workflow 1: Rare Variant Frequency Report for a Gene
Goal: Retrieve all rare (AF < 1%) variants in a gene, stratified by consequence, exported to CSV.
import requests, time, pandas as pd
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def gnomad_query(query, variables=None):
r = requests.post(GNOMAD_API, json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
result = r.json()
if "errors" in result:
raise ValueError(result["errors"])
return result["data"]
GENE_VARIANTS_QUERY = """
query GeneVariants($gene_symbol: String!, $reference_genome: ReferenceGenomeId!, $dataset: DatasetId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gene_id symbol name
variants(dataset: $dataset) {
variant_id rsids chrom pos ref alt consequence lof lof_filter
genome {
an ac af
faf95 { popmax popmax_population }
populations { id ac an homozygote_count }
}
}
}
}
"""
gene = "LDLR"
data = gnomad_query(GENE_VARIANTS_QUERY, {
"gene_symbol": gene,
"reference_genome": "GRCh38",
"dataset": "gnomad_r4"
})
variants = data["gene"]["variants"]
rows = []
for v in variants:
g = v.get("genome") or {}
af = g.get("af")
if af is None or af >= 0.01: # keep only rare variants
continue
rows.append({
"variant_id": v["variant_id"],
"rsids": ";".join(v.get("rsids") or []),
"consequence": v.get("consequence"),
"lof": v.get("lof"),
"af_genome": af,
"ac": g.get("ac"),
"an": g.get("an"),
"faf95_popmax": g.get("faf95", {}).get("popmax"),
"faf95_pop": g.get("faf95", {}).get("popmax_population"),
})
df = pd.DataFrame(rows)
df = df.sort_values("af_genome")
df.to_csv(f"{gene}_rare_variants.csv", index=False)
print(f"{gene}: {len(variants)} total variants, {len(df)} rare (AF<1%)")
print(df.groupby("consequence")["variant_id"].count().sort_values(ascending=False).head(6))
# LDLR: 2847 total variants, 2631 rare (AF<1%)
# consequence
# missense_variant 1423
# synonymous_variant 512
# splice_region_variant 231
# stop_gained 198
Workflow 2: Ancestry-Stratified Frequency Visualization
Goal: Query a list of variants and produce a barplot of allele frequencies by ancestry group.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def gnomad_query(query, variables=None):
r = requests.post(GNOMAD_API, json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
result = r.json()
if "errors" in result:
raise ValueError(result["errors"])
return result["data"]
POPULATION_FREQ_QUERY = """
query PopFreqs($variantId: String!, $dataset: DatasetId!) {
variant(variantId: $variantId, dataset: $dataset) {
variant_id
genome {
populations { id ac an homozygote_count }
}
}
}
"""
ANCESTRY_LABELS = {
"afr": "AFR", "amr": "AMR", "eas": "EAS", "fin": "FIN",
"nfe": "NFE", "sas": "SAS", "asj": "ASJ", "mid": "MID",
}
variant_id = "1-55039974-G-T" # PCSK9 p.Tyr142Ter
data = gnomad_query(POPULATION_FREQ_QUERY, {
"variantId": variant_id,
"dataset": "gnomad_r4"
})
pops = data["variant"]["genome"]["populations"]
rows = [{"code": p["id"], "af": p["ac"] / p["an"], "ac": p["ac"], "an": p["an"]}
for p in pops if p["id"] in ANCESTRY_LABELS and p["an"] > 0]
df = pd.DataFrame(rows)
df["label"] = df["code"].map(ANCESTRY_LABELS)
df = df.sort_values("af", ascending=False)
fig, ax = plt.subplots(figsize=(9, 4))
bars = ax.bar(df["label"], df["af"] * 100, color="#4472C4", edgecolor="white")
ax.bar_label(bars, fmt="%.3f%%", fontsize=8, padding=2)
ax.set_xlabel("Ancestry Group")
ax.set_ylabel("Allele Frequency (%)")
ax.set_title(f"gnomAD v4 Population Frequencies\n{variant_id}")
ax.set_ylim(0, df["af"].max() * 150)
plt.tight_layout()
plt.savefig("gnomad_pop_frequencies.png", dpi=150, bbox_inches="tight")
print(f"Saved gnomad_pop_frequencies.png (n={len(df)} ancestry groups)")
print(df[["label", "af", "ac", "an"]].to_string(index=False))
Workflow 3: Constraint-Guided Gene Prioritization
Goal: Score a gene list by constraint metrics and flag LoF-intolerant genes.
import requests, time, pandas as pd
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def gnomad_query(query, variables=None):
r = requests.post(GNOMAD_API, json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
result = r.json()
if "errors" in result:
raise ValueError(result["errors"])
return result["data"]
CONSTRAINT_QUERY = """
query GeneConstraint($gene_symbol: String!, $reference_genome: ReferenceGenomeId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gene_id symbol
gnomad_constraint {
pli mis_z lof_z
obs_lof exp_lof oe_lof oe_lof_lower oe_lof_upper
}
}
}
"""
# GnomadConstraint is flat (no nested `lof` type); `pNull` / `pRec` are gone;
# `gene_name` is gone — use `symbol`. Filter LoF-intolerant by LOEUF < 0.35
# (the gnomAD recommendation), which is more robust than pLI > 0.9.
gene_list = ["BRCA1", "BRCA2", "PCSK9", "LDLR", "TTN", "CFTR", "HTT"]
records = []
for gene in gene_list:
try:
data = gnomad_query(CONSTRAINT_QUERY, {"gene_symbol": gene, "reference_genome": "GRCh38"})
c = data["gene"]["gnomad_constraint"]
records.append({
"gene": gene,
"pli": c["pli"],
"LOEUF": c["oe_lof_upper"],
"mis_z": c["mis_z"],
"lof_obs": c["obs_lof"],
"lof_exp": c["exp_lof"],
"lof_oe": c["oe_lof"],
})
except Exception as e:
print(f"Warning: {gene} failed — {e}")
time.sleep(0.5)
df = pd.DataFrame(records).sort_values("LOEUF")
df["lof_intolerant"] = df["LOEUF"] < 0.35
print(df[["gene", "pli", "LOEUF", "mis_z", "lof_intolerant"]].to_string(index=False))
df.to_csv("constraint_scores.csv", index=False)
print(f"\nLoF-intolerant genes (LOEUF < 0.35): {df['lof_intolerant'].sum()}/{len(df)}")
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
dataset |
All variant queries | — | gnomad_r4, gnomad_r2_1, gnomad_r3 |
Dataset version (GRCh38 for r4/r3, GRCh37 for r2_1) |
reference_genome |
gene(), region() | — | GRCh38, GRCh37 |
Coordinate system; must match dataset |
variant_id |
variant() | — | CHROM-POS-REF-ALT string |
Identifies the specific variant to query |
gene_symbol |
gene() | — | HGNC symbol string | Gene to retrieve; case-insensitive |
chrom, start, stop |
region() | — | valid genomic coordinates | Region boundaries for region queries |
faf95.popmax |
variant() genome | — | float 0–1 | Filtering allele frequency (95% CI upper bound); use < 0.001 for rare |
lof filter field |
gene() variants | — | "HC" (high-confidence), "LC" |
LoF confidence level |
populations.id |
genome.populations | — | afr, amr, eas, fin, nfe, sas, asj, mid, oth |
Per-ancestry frequency |
Best Practices
-
Use
gnomad_r4for GRCh38 analyses: gnomAD v4 is the most current dataset with 730K+ individuals. Usegnomad_r2_1only when comparing to GRCh37-based variant calls. -
Use
faf95.popmaxfor clinical filtering, not overall AF: The filtering allele frequency accounts for maximum population stratification and provides a more conservative rarity estimate than the global AF. -
Add
time.sleep(0.5)in batch loops: gnomAD has no published rate limits but the API is shared infrastructure. Polite delays prevent server-side throttling. -
Filter
lof == "HC"for LoF burden analyses: Low-confidence LoF ("LC") annotations are often in repetitive regions or may be sequencing artifacts. High-confidence ("HC") calls are filtered by LOFTEE. -
Check AN before interpreting AF: Low allele number (AN) means poor coverage in that population. A zero or near-zero AF may reflect absent data, not true rarity. Cross-reference with the coverage query when AN is unexpectedly low.
Common Recipes
Recipe: Check if a Variant Is Common in Any Population
When to use: Quick check before clinical interpretation — confirm no ancestry group has AF > 1%.
import requests
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def is_common_in_any_population(variant_id, threshold=0.01, dataset="gnomad_r4"):
query = """
query($variantId: String!, $dataset: DatasetId!) {
variant(variantId: $variantId, dataset: $dataset) {
genome { faf95 { popmax popmax_population } af }
}
}
"""
r = requests.post(GNOMAD_API, json={"query": query,
"variables": {"variantId": variant_id, "dataset": dataset}},
timeout=15)
data = r.json()["data"]["variant"]
if not data or not data["genome"]:
return None, "Variant not found in gnomAD"
af = data["genome"]["af"]
popmax = data["genome"]["faf95"]["popmax"]
pop = data["genome"]["faf95"]["popmax_population"]
is_common = (popmax or 0) >= threshold
return is_common, f"overall AF={af:.2e}, FAF95 popmax={popmax:.2e} in {pop}"
common, info = is_common_in_any_population("1-55039974-G-T")
print(f"Common: {common} | {info}")
# Common: False | overall AF=3.2e-05, FAF95 popmax=6.4e-05 in nfe
Recipe: Batch Constraint Lookup
When to use: Score multiple genes from a differential expression or GWAS gene list.
import requests, time, pandas as pd
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def get_constraint(gene_symbol, reference_genome="GRCh38"):
query = """
query($gene_symbol: String!, $reference_genome: ReferenceGenomeId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
gnomad_constraint { pli mis_z oe_lof_upper }
}
}
"""
r = requests.post(GNOMAD_API, json={"query": query,
"variables": {"gene_symbol": gene_symbol, "reference_genome": reference_genome}},
timeout=15)
data = r.json().get("data", {}).get("gene", {})
if not data or not data.get("gnomad_constraint"):
return None
c = data["gnomad_constraint"]
return {"gene": gene_symbol, "pli": c["pli"], "LOEUF": c["oe_lof_upper"], "mis_z": c["mis_z"]}
genes = ["BRCA1", "BRCA2", "ATM", "CHEK2", "PALB2"]
rows = [r for g in genes for r in [get_constraint(g)] if r]
time.sleep(0.5) # polite delay per gene in real loop
df = pd.DataFrame(rows)
print(df.to_string(index=False))
# gene pli LOEUF mis_z
# BRCA1 5.52e-38 0.928 1.73
# BRCA2 6.34e-09 0.521 0.97
Recipe: Export LoF Variants for CADD/ClinVar Cross-Reference
When to use: Get high-confidence LoF variants from gnomAD for downstream annotation.
import requests, pandas as pd
GNOMAD_API = "https://gnomad.broadinstitute.org/api"
def get_lof_variants(gene_symbol, dataset="gnomad_r4", max_af=0.001):
query = """
query($gene_symbol: String!, $reference_genome: ReferenceGenomeId!, $dataset: DatasetId!) {
gene(gene_symbol: $gene_symbol, reference_genome: $reference_genome) {
variants(dataset: $dataset) {
variant_id rsids chrom pos ref alt consequence lof
genome { af ac an }
}
}
}
"""
r = requests.post(GNOMAD_API, json={"query": query,
"variables": {"gene_symbol": gene_symbol, "reference_genome": "GRCh38", "dataset": dataset}},
timeout=60)
variants = r.json()["data"]["gene"]["variants"]
lof = [v for v in variants
if v.get("lof") == "HC"
and v.get("genome") and v["genome"].get("af") is not None
and v["genome"]["af"] < max_af]
return pd.DataFrame([{
"variant_id": v["variant_id"],
"rsids": ";".join(v.get("rsids") or []),
"consequence": v["consequence"],
"af": v["genome"]["af"],
"ac": v["genome"]["ac"],
} for v in lof])
df = get_lof_variants("CFTR", max_af=0.001)
print(f"High-confidence LoF variants in CFTR (AF<0.1%): {len(df)}")
print(df.head(5).to_string(index=False))
df.to_csv("CFTR_HC_lof_variants.csv", index=False)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
{"errors": [...]} from GraphQL |
Invalid field name, wrong dataset ID, or null gene | Check field names match gnomAD v4 schema; use gnomad_r4 not gnomad_v4 |
Variant returns None genome object |
Variant only in exome data, not genome | Try accessing exome field instead of genome; genome is absent for exome-only variants |
| Gene query returns empty variants list | Gene symbol not found or mismatch | Verify HGNC symbol (case-sensitive); use gene_id (ENSG ID) as fallback |
faf95 returns null |
Variant is absent or monomorphic in all populations | Check ac and an — variant may have AC=0 or be filtered |
requests.exceptions.Timeout |
Large gene (e.g., TTN) takes >30s | Increase timeout=120; for very large genes use region queries instead |
Population AF is None for some groups |
Variant not observed in that ancestry | Treat None AF as 0 for filtering; check an to confirm the group was sequenced |
reference_genome mismatch error |
Using GRCh37 coords with gnomad_r4 |
Use GRCh38 for gnomad_r4/gnomad_r3; use GRCh37 only for gnomad_r2_1 |
Related Skills
clinvar-database— ClinVar pathogenicity classifications (complement to gnomAD population frequency data)gwas-database— GWAS Catalog for SNP-trait associations from published GWAS studiesensembl-database— Ensembl VEP for variant consequence prediction and gene annotationdbsnp-database— dbSNP for rsID lookup, variant classes, and cross-database ID mapping
References
- gnomAD GraphQL API — Interactive GraphQL explorer and endpoint documentation
- Karczewski et al., Nature 2020 — gnomAD v2.1 flagship paper (constraint metrics, LoF analysis)
- gnomAD Help & FAQ — Data model, ancestry definitions, FAF95 explanation
- gnomAD v4 blog post — gnomAD v4 release notes and dataset composition
skills/genomics-bioinformatics/databases/gwas-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gwas-database -g -y
SKILL.md
Frontmatter
{
"name": "gwas-database",
"license": "Apache-2.0",
"description": "NHGRI-EBI GWAS Catalog REST API for SNP-trait associations from published GWAS. Query studies, associations, variants, traits, genes, summary stats. Build PRS candidates, analyze pleiotropy, fetch stats for Manhattan plots. No auth."
}
GWAS Catalog Database — SNP-Trait Association Queries
Overview
The NHGRI-EBI GWAS Catalog is a curated collection of published genome-wide association studies, mapping SNP-trait associations with genomic context. The REST API provides programmatic access to studies, associations, variants, traits, genes, and summary statistics. All responses are HAL+JSON with embedded _links for pagination.
When to Use
- Finding genetic variants associated with a disease or trait (e.g., "which SNPs are linked to type 2 diabetes?")
- Retrieving genome-wide significant associations for a specific variant (rs ID)
- Exploring the genetic architecture of complex traits (number of loci, effect sizes)
- Checking variant pleiotropy (how many traits a single SNP affects)
- Downloading summary statistics for meta-analysis or polygenic risk score construction
- Identifying published GWAS studies by disease, gene, or PubMed ID
- Cross-referencing EFO trait ontology terms with GWAS evidence
- Building candidate gene lists from GWAS association regions
- For drug target validation from GWAS hits, use
opentargets-databaseinstead - For variant functional annotation (consequence prediction, regulatory impact), use Ensembl VEP via
gget
Prerequisites
pip install requests matplotlib numpy
API access:
- No authentication required -- fully open access
- Rate limits: no official limit, but add
time.sleep(0.2)between requests to be courteous - Base URL:
https://www.ebi.ac.uk/gwas/rest/api - Response format: HAL+JSON with
_embeddeddata and_linksfor pagination - Pagination: default 20 results per page; max 500 via
sizeparameter
Quick Start
import requests
import time
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
def gwas_get(endpoint, params=None):
"""GWAS Catalog REST API helper with rate limiting and pagination support."""
url = f"{BASE}/{endpoint}"
resp = requests.get(url, params=params or {})
resp.raise_for_status()
time.sleep(0.2)
return resp.json()
# Find studies for a trait keyword. Study records have no top-level `title`
# — the publication title lives at `publicationInfo.title`; the trait label
# lives at `diseaseTrait.trait`.
data = gwas_get("studies/search/findByDiseaseTrait", {"diseaseTrait": "diabetes"})
studies = data["_embedded"]["studies"]
print(f"Found {len(studies)} studies for 'diabetes'")
for s in studies[:3]:
title = (s.get("publicationInfo") or {}).get("title", "N/A")
trait = (s.get("diseaseTrait") or {}).get("trait", "N/A")
print(f" {s['accessionId']} | {trait[:40]:<40} | {title[:60]}")
Core API
Module 1: Study Search
Search GWAS studies by disease trait keyword or PubMed ID.
# Search studies by disease trait
data = gwas_get("studies/search/findByDiseaseTrait", {"diseaseTrait": "breast cancer"})
studies = data["_embedded"]["studies"]
for s in studies[:5]:
pi = s.get("publicationInfo") or {}
print(f" {s['accessionId']} | PMID:{pi.get('pubmedId','N/A')} | {pi.get('title','')[:60]}")
time.sleep(0.2)
# Search by PubMed ID. NOTE: the older `findByPubmedId` 404s on /studies/;
# the working endpoint is `findByPublicationIdPubmedId`.
data = gwas_get("studies/search/findByPublicationIdPubmedId", {"pubmedId": "25673413"})
studies = data["_embedded"]["studies"]
print(f"Studies from PMID 25673413: {len(studies)}")
for s in studies:
trait = (s.get("diseaseTrait") or {}).get("trait", "N/A")
print(f" {s['accessionId']}: {trait}")
Module 2: Association Queries
Retrieve SNP-trait associations filtered by trait (EFO term), variant, or p-value.
# Associations by EFO trait. The old path `efoTraits/{shortForm}/associations`
# also works *if* you have the current shortForm — but trait shortForms have
# been re-mapped to MONDO (e.g. EFO_0000249 → MONDO_0004975). The most reliable
# path is `associations/search/findByEfoTrait?efoTrait=<canonical trait name>`.
data = gwas_get("associations/search/findByEfoTrait",
{"efoTrait": "type 2 diabetes mellitus", "size": 50})
assocs = data["_embedded"]["associations"]
print(f"Associations for 'type 2 diabetes mellitus': {len(assocs)}")
for a in assocs[:5]:
pval = a.get("pvalue", None)
genes = []
for locus in a.get("loci", []) or []:
for gene in locus.get("authorReportedGenes", []) or []:
genes.append(gene.get("geneName", ""))
loci = a.get("loci") or [{}]
snps = [r.get("snps", [{}])[0].get("rsId", "N/A")
for r in (loci[0].get("strongestRiskAlleles") or [])]
print(f" rs={snps} | p={pval} | genes={genes}")
# Associations for a specific variant. NOTE: association records do not embed
# `efoTraits` inline — they expose them via the `_links.efoTraits.href`
# HAL link. Follow the link (cached if needed) to resolve trait names.
data = gwas_get("singleNucleotidePolymorphisms/rs7903146/associations", {"size": 5})
assocs = data["_embedded"]["associations"]
print(f"Associations for rs7903146 (first page): {len(assocs)}")
def association_traits(assoc):
"""Resolve efoTraits via the HAL link on an association record."""
href = (assoc.get("_links") or {}).get("efoTraits", {}).get("href")
if not href:
return []
r = requests.get(href, timeout=15)
if not r.ok:
return []
return [t.get("trait") for t in r.json().get("_embedded", {}).get("efoTraits", [])]
for a in assocs[:5]:
traits = association_traits(a)
print(f" p={a.get('pvalue')} | OR={a.get('orPerCopyNum', 'N/A')} | traits={traits}")
time.sleep(0.1)
Module 3: Variant Lookup
Query variant details by rsID, chromosomal region, or cytogenetic band.
# Lookup single variant
data = gwas_get("singleNucleotidePolymorphisms/rs7903146")
loc = data.get("locations", [{}])[0]
print(f"rs7903146: chr{loc.get('chromosomeName', '?')}:{loc.get('chromosomePosition', '?')}")
print(f" Functional class: {data.get('functionalClass', 'N/A')}")
print(f" Merged into: {data.get('merged', 'N/A')}")
time.sleep(0.2)
# Search variants by chromosomal region
data = gwas_get("singleNucleotidePolymorphisms/search/findByChromBpLocationRange",
{"chrom": "10", "bpStart": "114750000", "bpEnd": "114800000", "size": 50})
snps = data["_embedded"]["singleNucleotidePolymorphisms"]
print(f"Variants in chr10:114750000-114800000: {len(snps)}")
for v in snps[:5]:
print(f" {v['rsId']}: {v.get('functionalClass', 'N/A')}")
# Search variants by gene name (the cytogenetic-band endpoint
# `findByCytogeneticBand` was removed — use gene or chromosome-range instead).
data = gwas_get("singleNucleotidePolymorphisms/search/findByGene",
{"geneName": "TCF7L2", "size": 5})
snps = data["_embedded"]["singleNucleotidePolymorphisms"]
print(f"Variants in TCF7L2: {len(snps)}")
Module 4: Trait Search
Browse and search EFO-mapped traits in the GWAS Catalog.
# Search traits by exact name (the older `findByDescription` endpoint was
# removed — search/efoTrait now expects the canonical trait label).
data = gwas_get("efoTraits/search/findByEfoTrait", {"trait": "Alzheimer disease"})
traits = data["_embedded"]["efoTraits"]
print(f"Traits matching 'Alzheimer disease': {len(traits)}")
for t in traits[:5]:
print(f" {t['shortForm']}: {t['trait']} (uri={t['uri']})")
time.sleep(0.2)
# Get specific trait by shortForm. NOTE: many legacy EFO IDs have been
# re-mapped to MONDO (e.g. old `EFO_0000249` for Alzheimer is now
# `MONDO_0004975` — `efoTraits/EFO_0000249` returns 404). Resolve via search
# above first, then use the current shortForm:
short_form = traits[0]["shortForm"] # e.g. 'MONDO_0004975'
data = gwas_get(f"efoTraits/{short_form}")
print(f"Trait: {data['trait']}")
print(f" URI : {data['uri']}")
print(f" shortForm : {data['shortForm']}")
Module 5: Summary Statistics
Access study-level summary statistics for downstream analysis (meta-analysis, PRS).
# List studies with available summary statistics
data = gwas_get("studies/search/findByFullPvalueSet", {"fullPvalueSet": True, "size": 10})
studies = data["_embedded"]["studies"]
print(f"Studies with summary stats (first page): {len(studies)}")
for s in studies[:5]:
trait = (s.get("diseaseTrait") or {}).get("trait", "N/A")
print(f" {s['accessionId']}: {trait[:50]}")
time.sleep(0.2)
# Summary statistics metadata is NOT exposed via the REST API
# (`studies/{acc}/summaryStatistics` returns 404). Use the GWAS Catalog FTP
# directly — paths are predictable by study accession:
ftp_base = "http://ftp.ebi.ac.uk/pub/databases/gwas/summary_statistics"
acc = studies[0]["accessionId"]
ftp_url = f"{ftp_base}/{acc[:-3]}001-{acc[:-3]}999/{acc}/"
print(f"Summary stats FTP directory: {ftp_url}")
# Download summary statistics file (FTP)
# Summary statistics are hosted on the GWAS Catalog FTP, not the REST API
import urllib.request
study_id = "GCST006867" # Example study
ftp_base = "http://ftp.ebi.ac.uk/pub/databases/gwas/summary_statistics"
# Actual paths vary by study; check the study page for the download link
# Example: ftp_base/{study_id}/{study_id}.tsv.gz
url = f"{ftp_base}/{study_id}"
print(f"Summary stats FTP directory: {url}")
# Use requests.get() or urllib to download the .tsv.gz file
Module 6: Gene and Publication Search
Find GWAS associations by gene name or retrieve publication metadata.
# Search associations by gene
data = gwas_get("singleNucleotidePolymorphisms/search/findByGene",
{"geneName": "BRCA1", "size": 50})
snps = data["_embedded"]["singleNucleotidePolymorphisms"]
print(f"Variants near BRCA1: {len(snps)}")
for v in snps[:5]:
locs = v.get("locations", [{}])
pos = locs[0].get("chromosomePosition", "?") if locs else "?"
print(f" {v['rsId']}: chr{locs[0].get('chromosomeName', '?')}:{pos}")
time.sleep(0.2)
# Get study publication details
data = gwas_get("studies/GCST000392")
pub = data.get("publicationInfo", {})
print(f"Study: {data['accessionId']}")
print(f" Author: {pub.get('author', {}).get('fullname', 'N/A')}")
print(f" Journal: {pub.get('publication', 'N/A')}")
print(f" PMID: {pub.get('pubmedId', 'N/A')}")
print(f" Date: {pub.get('publicationDate', 'N/A')}")
Key Concepts
Data Entities and Relationships
The GWAS Catalog organizes data as interconnected entities:
| Entity | Description | Key Identifier | Example |
|---|---|---|---|
| Study | A published GWAS experiment | GCST accession (e.g., GCST000392) |
Wellcome Trust Case Control Consortium study |
| Association | A SNP-trait association with p-value and effect size | Internal ID | rs7903146 associated with T2D at p=1e-40 |
| Variant (SNP) | A single nucleotide polymorphism | rs number (e.g., rs7903146) |
TCF7L2 variant |
| Trait | A disease/phenotype mapped to EFO ontology | EFO ID (e.g., EFO_0001360) |
Type 2 diabetes mellitus |
| Gene | A gene near or harboring GWAS variants | Gene symbol (e.g., TCF7L2) |
Transcription factor 7-like 2 |
Relationships: Study --(reports)--> Association --(involves)--> Variant + Trait. Variants map to genomic positions and nearby genes.
HAL+JSON Response Structure
All API responses follow HAL (Hypertext Application Language) format:
| Top-level Section | Contents | Access Pattern |
|---|---|---|
_embedded |
Primary data objects (studies, associations, etc.) | response["_embedded"]["studies"] |
_links |
Navigation links (self, next, prev, first, last) | response["_links"]["next"]["href"] |
page |
Pagination metadata (size, totalElements, totalPages, number) | response["page"]["totalElements"] |
Genome-wide Significance
The standard genome-wide significance threshold is p <= 5 x 10^-8, correcting for approximately 1 million independent tests across the human genome. Associations below this threshold are considered suggestive. The GWAS Catalog includes associations at various significance levels -- always check p-values when filtering results.
Key Identifiers
- GCST IDs: GWAS Catalog study accessions (e.g.,
GCST000392) - rs numbers: dbSNP reference SNP identifiers (e.g.,
rs7903146) - EFO terms: Experimental Factor Ontology for trait standardization (e.g.,
EFO_0001360for type 2 diabetes) - Cytogenetic bands: Chromosomal location notation (e.g.,
10q25.2)
Common Workflows
Workflow 1: Disease Genetic Architecture
Goal: Map the genetic landscape of a disease by collecting all genome-wide significant loci.
import requests, time
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
def gwas_get(endpoint, params=None):
url = f"{BASE}/{endpoint}"
resp = requests.get(url, params=params or {})
resp.raise_for_status()
time.sleep(0.2)
return resp.json()
# Step 1: Resolve trait → current shortForm via findByEfoTrait
# (the older `findByDescription` endpoint was removed).
traits = gwas_get("efoTraits/search/findByEfoTrait", {"trait": "schizophrenia"})
efo_id = traits["_embedded"]["efoTraits"][0]["shortForm"]
print(f"Using EFO: {efo_id}")
# Step 2: Get all associations for this trait — nested path
# `efoTraits/{shortForm}/associations` works once you have the canonical shortForm.
all_assocs = []
page = 0
while True:
data = gwas_get(f"efoTraits/{efo_id}/associations",
{"size": 500, "page": page})
assocs = data["_embedded"]["associations"]
all_assocs.extend(assocs)
if page >= data["page"]["totalPages"] - 1:
break
page += 1
# Step 3: Filter genome-wide significant
significant = [a for a in all_assocs if a.get("pvalue") and a["pvalue"] < 5e-8]
print(f"Total associations: {len(all_assocs)}, genome-wide significant: {len(significant)}")
# Step 4: Extract variant and effect details
for a in significant[:10]:
risk_alleles = a.get("loci", [{}])[0].get("strongestRiskAlleles", [])
snp = risk_alleles[0].get("snps", [{}])[0].get("rsId", "N/A") if risk_alleles else "N/A"
or_val = a.get("orPerCopyNum", "N/A")
beta = a.get("betaNum", "N/A")
print(f" {snp} | p={a['pvalue']:.2e} | OR={or_val} | beta={beta}")
Workflow 2: Variant Pleiotropy Analysis
Goal: Determine how many distinct traits a single variant is associated with.
import requests, time
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
def gwas_get(endpoint, params=None):
url = f"{BASE}/{endpoint}"
resp = requests.get(url, params=params or {})
resp.raise_for_status()
time.sleep(0.2)
return resp.json()
rs_id = "rs7903146" # Well-known pleiotropic variant
# Get all associations for this variant
data = gwas_get(f"singleNucleotidePolymorphisms/{rs_id}/associations", {"size": 500})
assocs = data["_embedded"]["associations"]
# Association records don't embed efoTraits inline — follow the HAL
# `_links.efoTraits.href` link per association. Cache per-href to avoid
# duplicate fetches.
trait_set = {}
href_cache = {}
def fetch_traits(href):
if href in href_cache:
return href_cache[href]
r = requests.get(href, timeout=15)
href_cache[href] = (r.json().get("_embedded", {}).get("efoTraits", [])) if r.ok else []
return href_cache[href]
for a in assocs:
href = (a.get("_links") or {}).get("efoTraits", {}).get("href")
if not href:
continue
for t in fetch_traits(href):
tid = t.get("shortForm", "unknown")
if tid not in trait_set:
trait_set[tid] = {"trait": t.get("trait", "N/A"),
"best_pval": a.get("pvalue", 1), "count": 0}
trait_set[tid]["count"] += 1
if a.get("pvalue") and a["pvalue"] < trait_set[tid]["best_pval"]:
trait_set[tid]["best_pval"] = a["pvalue"]
time.sleep(0.1)
print(f"{rs_id} is associated with {len(trait_set)} distinct traits:")
for tid, info in sorted(trait_set.items(), key=lambda x: x[1]["best_pval"]):
print(f" {tid}: {info['trait']} (best p={info['best_pval']:.2e}, n={info['count']})")
Workflow 3: Summary Statistics Manhattan Plot
Goal: Download summary statistics for a study and create a Manhattan plot.
import numpy as np
import matplotlib.pyplot as plt
import csv, gzip, io, requests
# Simulated summary statistics for demonstration
# In practice: download from GWAS Catalog FTP
# url = "http://ftp.ebi.ac.uk/pub/databases/gwas/summary_statistics/GCSTXXXXXX/..."
# resp = requests.get(url); data = gzip.decompress(resp.content)
np.random.seed(42)
n_snps = 5000
chroms = np.random.choice(range(1, 23), size=n_snps)
positions = np.random.randint(1, 250_000_000, size=n_snps)
pvalues = np.random.uniform(0, 1, size=n_snps)
# Add some "hits"
pvalues[:20] = 10 ** np.random.uniform(-15, -8, size=20)
# Manhattan plot
log_p = -np.log10(pvalues)
chrom_offsets = {}
running_offset = 0
for c in range(1, 23):
chrom_offsets[c] = running_offset
running_offset += 250_000_000
x_positions = [positions[i] + chrom_offsets[chroms[i]] for i in range(n_snps)]
colors = ["#1f77b4" if c % 2 == 0 else "#ff7f0e" for c in chroms]
fig, ax = plt.subplots(figsize=(14, 5))
ax.scatter(x_positions, log_p, c=colors, s=4, alpha=0.6)
ax.axhline(y=-np.log10(5e-8), color="red", linestyle="--", linewidth=0.8,
label="Genome-wide significance (p=5e-8)")
ax.axhline(y=-np.log10(1e-5), color="blue", linestyle=":", linewidth=0.5,
label="Suggestive (p=1e-5)")
ax.set_xlabel("Chromosome")
ax.set_ylabel("-log10(p-value)")
ax.set_title("GWAS Manhattan Plot")
ax.legend(fontsize=8)
# Chromosome labels
tick_positions = [chrom_offsets[c] + 125_000_000 for c in range(1, 23)]
ax.set_xticks(tick_positions)
ax.set_xticklabels([str(c) for c in range(1, 23)], fontsize=7)
plt.tight_layout()
plt.savefig("manhattan_plot.png", dpi=300, bbox_inches="tight")
print("Saved manhattan_plot.png")
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
size |
All paginated endpoints | 20 |
1-500 |
Results per page |
page |
All paginated endpoints | 0 |
0-totalPages-1 |
Page number (0-indexed) |
diseaseTrait |
studies/search/findByDiseaseTrait |
-- | Any string | Trait keyword search |
pubmedId |
studies/search/findByPublicationIdPubmedId |
-- | Valid PMID | Study lookup by publication (findByPubmedId 404s on /studies/) |
geneName |
snps/search/findByGene |
-- | Gene symbol | Variants near a gene |
chrom, bpStart, bpEnd |
snps/search/findByChromBpLocationRange |
-- | chr:start-end | Regional variant query |
fullPvalueSet |
studies/search/findByFullPvalueSet |
-- | True/False |
Filter studies with summary stats |
trait |
efoTraits/search/findByEfoTrait |
-- | Canonical trait name | Trait lookup by exact name (findByDescription was removed) |
efoTrait |
associations/search/findByEfoTrait |
-- | Canonical trait name | All associations for a trait |
Best Practices
-
Paginate large result sets: Default page size is 20; set
size=500and loop over pages for complete data retrieval. Checkpage.totalElementsto know the full count before iterating. -
Use EFO IDs for precise trait queries: Free-text search may return related but different traits. Look up the exact EFO ID first, then query associations by EFO ID for precision.
-
Always check p-values: The catalog contains associations at various significance levels. Filter to p < 5e-8 for genome-wide significant results unless you specifically need suggestive associations.
-
Be ancestry-aware: Effect sizes and allele frequencies vary across populations. Check the
initialSampleSizeandreplicationSampleSizefields to understand the ancestry composition of each study. -
Add rate-limiting delays: Although no official limit exists,
time.sleep(0.2)between requests prevents server overload and avoids temporary blocks. -
Cache frequently accessed data: Study and trait metadata rarely change. Cache results locally when running batch analyses to reduce redundant API calls.
-
Anti-pattern -- Don't rely solely on reported genes: Author-reported genes may not be the causal gene. Cross-reference with functional annotation tools and eQTL databases for biological interpretation.
Common Recipes
Recipe 1: Cross-Reference with PGS Catalog
Identify polygenic scores available for a GWAS trait.
import requests, time
# Step 1: Get EFO/MONDO shortForm from GWAS Catalog
# (the old `findByDescription` endpoint was removed; use `findByEfoTrait`)
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
traits = requests.get(f"{BASE}/efoTraits/search/findByEfoTrait",
params={"trait": "coronary artery disease"}).json()
efo_id = traits["_embedded"]["efoTraits"][0]["shortForm"]
time.sleep(0.2)
# Step 2: Query PGS Catalog for scores using same EFO
pgs_url = f"https://www.pgscatalog.org/rest/score/search?trait_id={efo_id}"
pgs_data = requests.get(pgs_url).json()
print(f"PGS scores for {efo_id}: {pgs_data.get('count', 0)}")
for score in pgs_data.get("results", [])[:5]:
print(f" {score['id']}: {score['name']} (variants: {score.get('variants_number', '?')})")
Recipe 2: Regional Association Summary
Collect all GWAS associations in a genomic region.
import requests, time
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
# Query region: chr9:21,900,000-22,200,000 (CDKN2A/2B locus)
data = requests.get(f"{BASE}/singleNucleotidePolymorphisms/search/findByChromBpLocationRange",
params={"chrom": "9", "bpStart": "21900000",
"bpEnd": "22200000", "size": 200}).json()
time.sleep(0.2)
snps = data["_embedded"]["singleNucleotidePolymorphisms"]
print(f"Variants in CDKN2A/2B locus: {len(snps)}")
# Get associations for each variant
region_assocs = []
for v in snps[:10]: # Limit for demo
rs = v["rsId"]
try:
a_data = requests.get(f"{BASE}/singleNucleotidePolymorphisms/{rs}/associations",
params={"size": 50}).json()
time.sleep(0.2)
for a in a_data["_embedded"]["associations"]:
for t in a.get("efoTraits", []):
region_assocs.append({"rsId": rs, "trait": t["trait"],
"pvalue": a.get("pvalue")})
except Exception:
continue
print(f"Total associations in region: {len(region_assocs)}")
Recipe 3: Effect Size Forest Plot
Visualize effect sizes across studies for a single variant.
import matplotlib.pyplot as plt
import numpy as np
import requests, time
BASE = "https://www.ebi.ac.uk/gwas/rest/api"
data = requests.get(f"{BASE}/singleNucleotidePolymorphisms/rs1801282/associations",
params={"size": 100}).json()
time.sleep(0.2)
assocs = data["_embedded"]["associations"]
# Extract OR values (filter to those with OR data)
entries = []
for a in assocs:
or_val = a.get("orPerCopyNum")
ci_text = a.get("range", "")
trait = a.get("efoTraits", [{}])[0].get("trait", "N/A") if a.get("efoTraits") else "N/A"
if or_val and or_val > 0:
entries.append({"trait": trait[:30], "or": or_val, "ci": ci_text})
if entries:
fig, ax = plt.subplots(figsize=(8, max(3, len(entries) * 0.4)))
y_pos = range(len(entries))
ors = [e["or"] for e in entries]
labels = [f"{e['trait']} (OR={e['or']:.2f})" for e in entries]
ax.barh(y_pos, [np.log(o) for o in ors], color=["#d62728" if o > 1 else "#2ca02c" for o in ors])
ax.set_yticks(y_pos)
ax.set_yticklabels(labels, fontsize=8)
ax.axvline(x=0, color="black", linewidth=0.8)
ax.set_xlabel("log(OR)")
ax.set_title("rs1801282 (PPARG) Effect Sizes")
plt.tight_layout()
plt.savefig("forest_plot.png", dpi=300, bbox_inches="tight")
print(f"Saved forest_plot.png with {len(entries)} entries")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found |
Invalid GCST, rs ID, or EFO ID | Verify identifier exists via search endpoint first |
Empty _embedded |
No results for query | Broaden search terms or check spelling; try partial match |
ConnectionError / timeout |
EBI server temporarily down | Retry with exponential backoff; check https://www.ebi.ac.uk/gwas/status |
| Truncated results | Default pagination (20 per page) | Set size=500 and iterate over page parameter |
| Missing OR/beta values | Not all associations report effect sizes | Check both orPerCopyNum and betaNum fields; some studies report only p-values |
| Stale data | Catalog updated quarterly | Check lastUpdateDate on studies; re-query if needed |
KeyError: '_embedded' |
Endpoint returns single object, not list | Direct lookups (by ID) return a single object; _embedded only appears in search/list results |
| Summary stats unavailable | Not all studies deposit summary stats | Filter with findByFullPvalueSet=True to find studies with available data |
Bundled Resources
references/api_endpoints.md
Covers: Complete endpoint catalog for all 6 REST API groups (studies, associations, variants, traits, genes, summary statistics) with query parameters, plus response field tables for the main entity types (association fields, study fields, variant fields), HTTP error codes, and pagination patterns.
Relocated inline: Core query patterns and the most common endpoints are demonstrated in Core API modules with full code examples. HAL+JSON structure and key identifier formats are in Key Concepts.
Omitted from original api_reference.md (794 lines): Advanced query composition patterns (multi-parameter chaining beyond what Core API shows), child/parent trait traversal details, detailed changelog/versioning notes. These are specialized and covered by EBI documentation.
Related Skills
- gget-genomic-databases -- gene lookups, variant annotation, BLAST (upstream ID resolution)
- ensembl-database (planned) -- variant effect prediction, regulatory annotations
- opentargets-database (planned) -- drug target validation from GWAS hits
- clinvar-database (planned) -- clinical variant interpretation
- bioservices-multi-database -- cross-database queries integrating GWAS with pathway data
References
- GWAS Catalog website -- main portal for browsing and searching
- GWAS Catalog REST API documentation -- official API reference
- GWAS Catalog GitHub -- source code and issue tracker
- Summary Statistics FTP -- bulk summary statistics downloads
- PGS Catalog -- polygenic score database using GWAS data
skills/genomics-bioinformatics/databases/jaspar-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill jaspar-database -g -y
SKILL.md
Frontmatter
{
"name": "jaspar-database",
"license": "CC-BY-4.0",
"description": "JASPAR 2024 TF binding profiles via REST API and pyJASPAR. Retrieve PFMs\/PWMs by TF name, JASPAR ID, species, or structural class. Scan DNA for TFBS; browse by taxon (human, mouse) or TF family (bHLH, zinc finger). Use for motif enrichment input, TFBS scanning, and regulatory sequence analysis. For ChIP-seq peak motif discovery use homer-motif-analysis; for regulatory variant scoring use regulomedb-database."
}
JASPAR Database
Overview
JASPAR is a curated, open-access database of transcription factor (TF) binding profiles represented as position frequency matrices (PFMs). The 2024 release contains 1,209 profiles in the CORE vertebrate collection, covering 783 TFs with experimentally validated binding data from SELEX, ChIP-seq, and PBM experiments. Access is free via the JASPAR REST API at https://jaspar.elixir.no/api/v1/ — no authentication required — and through the pyJASPAR Python library for matrix retrieval and manipulation.
When to Use
- Looking up the PWM or PFM for a specific TF by name (e.g., CTCF, SP1, GATA1) to use as motif input for a scanning tool
- Retrieving all JASPAR profiles for a species (e.g., Homo sapiens, Mus musculus) to build a motif library for enrichment analysis
- Scanning a DNA promoter sequence for predicted TF binding sites using a known PWM
- Finding all TFs of a given structural class (bHLH, zinc finger, homeodomain) to build a TF family binding profile set
- Getting metadata for a JASPAR matrix: number of binding sites, information content, GC content, experiment type
- Downloading complete JASPAR collection sets (CORE, UNVALIDATED, CNE) in JASPAR or MEME format for batch analysis
- Use
homer-motif-analysisinstead when you need de novo motif discovery from ChIP-seq peaks; JASPAR is for retrieving known matrices - For regulatory element annotations tied to a genomic region use
encode-databaseorregulomedb-database
Prerequisites
- Python packages:
requests,pandas,matplotlib,numpy - Optional:
pyJASPAR(Python library wrapping JASPAR REST API with BIOPYTHON motif objects) - Data requirements: TF gene symbols, JASPAR matrix IDs (e.g.,
MA0139.1), or DNA sequences (string or FASTA) - Environment: internet connection; no API key required
- Rate limits: no official published limits; use
time.sleep(0.5)between batch requests
pip install requests pandas matplotlib numpy
pip install pyJASPAR # optional; pulls in biopython
Quick Start
import requests
JASPAR_API = "https://jaspar.elixir.no/api/v1"
# Search for CTCF profile in the CORE vertebrate collection
r = requests.get(f"{JASPAR_API}/matrix/", params={
"search": "CTCF",
"collection": "CORE",
"tax_group": "vertebrates",
"format": "json"
}, timeout=15)
r.raise_for_status()
results = r.json()
print(f"Profiles found: {results['count']}")
for m in results["results"][:3]:
print(f" {m['matrix_id']} {m['name']} sites={m['sites']} type={m['type']}")
# Profiles found: 2
# MA0139.1 CTCF sites=190 type=ChIP-seq
# MA1929.1 CTCF sites=2135 type=ChIP-seq
Core API
Query 1: Matrix Search
Search for TF profiles by TF name, species, collection, or taxonomic group. Returns a paginated list of matching profile records.
import requests, time
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def jaspar_search(search=None, collection="CORE", tax_id=None, tax_group=None,
tf_class=None, tf_family=None, page_size=50):
"""Search JASPAR matrices. Returns list of result dicts."""
params = {"format": "json", "page_size": page_size}
if search: params["search"] = search
if collection: params["collection"] = collection
if tax_id: params["tax_id"] = tax_id
if tax_group: params["tax_group"] = tax_group
if tf_class: params["tf_class"] = tf_class
if tf_family: params["tf_family"] = tf_family
all_results = []
url = f"{JASPAR_API}/matrix/"
while url:
r = requests.get(url, params=params if url == f"{JASPAR_API}/matrix/" else None, timeout=15)
r.raise_for_status()
data = r.json()
all_results.extend(data["results"])
url = data.get("next") # follow pagination
time.sleep(0.3)
return all_results
# Example: all CORE vertebrate profiles for GATA family
gata_profiles = jaspar_search(search="GATA", collection="CORE", tax_group="vertebrates")
print(f"GATA profiles: {len(gata_profiles)}")
for m in gata_profiles[:4]:
print(f" {m['matrix_id']} {m['name']:12s} {m.get('tf_class','')} sites={m['sites']}")
Query 2: Matrix Retrieval
Fetch the full profile record for a specific matrix ID, including the raw PFM counts, metadata, and TF annotations.
import requests
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_matrix(matrix_id):
"""Return full matrix record for a JASPAR ID (e.g. 'MA0139.1')."""
r = requests.get(f"{JASPAR_API}/matrix/{matrix_id}/", params={"format": "json"}, timeout=15)
r.raise_for_status()
return r.json()
m = get_matrix("MA0139.1") # CTCF
print(f"ID: {m['matrix_id']} Name: {m['name']}")
print(f"Collection: {m['collection']} Type: {m['type']}")
print(f"Species: {[s['name'] for s in m.get('species', [])]}")
print(f"UniProt: {m.get('uniprot_ids', [])}")
print(f"Sites: {m['sites']} Binding sites used to build matrix")
print(f"TF class: {m.get('class_name', 'n/a')} Family: {m.get('family_name', 'n/a')}")
# PFM structure: dict mapping position (as str) -> {A, C, G, T: count}
pfm = m["pfm"]
n_positions = len(pfm)
print(f"\nPFM length: {n_positions} positions")
print(f"Position 0: {pfm['0']}") # {A: x, C: y, G: z, T: w}
# Position 0: {'A': 87, 'C': 12, 'G': 22, 'T': 69}
Query 3: PWM Computation from PFM
Convert a raw PFM (count matrix) to a position weight matrix (PWM) using log-odds scoring. The PWM is used for binding site scanning.
import requests, numpy as np
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def pfm_to_pwm(pfm_dict, pseudocount=0.8, background=None):
"""
Convert JASPAR PFM dict to PWM (log2 odds).
pfm_dict: dict of str(position) -> {A, C, G, T: float}
Returns: numpy array shape (4, L), rows = [A, C, G, T]
"""
if background is None:
background = {"A": 0.25, "C": 0.25, "G": 0.25, "T": 0.25}
bases = ["A", "C", "G", "T"]
L = len(pfm_dict)
counts = np.array([[pfm_dict[str(i)][b] for i in range(L)] for b in bases], dtype=float)
counts += pseudocount
freqs = counts / counts.sum(axis=0, keepdims=True)
bg = np.array([background[b] for b in bases])[:, None]
pwm = np.log2(freqs / bg)
return pwm # shape (4, L)
r = requests.get(f"{JASPAR_API}/matrix/MA0139.1/", params={"format": "json"}, timeout=15)
pfm = r.json()["pfm"]
pwm = pfm_to_pwm(pfm)
print(f"PWM shape: {pwm.shape} (4 bases × {pwm.shape[1]} positions)")
print(f"Min score per position: {pwm.min(axis=0)[:5]}")
print(f"Max score per position: {pwm.max(axis=0)[:5]}")
# PWM shape: (19, ) -> transposed view: (4, 19)
# Min score per position: [-2.32 -3.64 -3.64 -1.32 -3.64]
# Max score per position: [ 1.87 1.82 1.87 1.71 1.90]
Query 4: Sequence Scanning
Scan a DNA sequence for TFBS matches by sliding the PWM across the sequence and computing log-odds scores at each position.
import requests, numpy as np
JASPAR_API = "https://jaspar.elixir.no/api/v1"
BASE_IDX = {"A": 0, "C": 1, "G": 2, "T": 3}
def pfm_to_pwm(pfm_dict, pseudocount=0.8):
bases = ["A", "C", "G", "T"]
L = len(pfm_dict)
counts = np.array([[pfm_dict[str(i)][b] for i in range(L)] for b in bases], dtype=float)
counts += pseudocount
freqs = counts / counts.sum(axis=0, keepdims=True)
return np.log2(freqs / 0.25)
def scan_sequence(seq, pwm, threshold_pct=0.80):
"""
Slide pwm over seq, return hits above threshold_pct of max possible score.
Returns list of (position, score, strand).
"""
seq = seq.upper()
L = pwm.shape[1]
max_score = pwm.clip(min=0).sum(axis=0).sum()
min_score = pwm.clip(max=0).sum(axis=0).sum()
threshold = min_score + threshold_pct * (max_score - min_score)
hits = []
for i in range(len(seq) - L + 1):
window = seq[i:i+L]
if "N" in window:
continue
score = sum(pwm[BASE_IDX[window[j]], j] for j in range(L))
if score >= threshold:
hits.append((i, round(score, 3), "+"))
return hits, max_score, threshold
# Fetch CTCF matrix and scan a synthetic CTCF-like sequence
r = requests.get(f"{JASPAR_API}/matrix/MA0139.1/", params={"format": "json"}, timeout=15)
pfm = r.json()["pfm"]
pwm = pfm_to_pwm(pfm)
# 100 bp sequence with known CTCF consensus embedded
seq = ("GCAGGTTTAAGCTTCCTGGCATTTAAGCTTCCTGGCATTTCCCCAGGGGGCGGAGGCAGAG"
"CCGCGAGCCGCGAGCCGCGAGCCGCGAGCCGCGAGTTTAAG")
hits, max_score, thresh = scan_sequence(seq, pwm, threshold_pct=0.80)
print(f"Max PWM score: {max_score:.2f} | Threshold (80%): {thresh:.2f}")
print(f"Hits: {len(hits)}")
for pos, score, strand in hits:
print(f" pos={pos} score={score:.2f} {strand} seq={seq[pos:pos+pwm.shape[1]]}")
Query 5: Taxon Browser
List all JASPAR CORE profiles for a specific organism, identified by NCBI taxonomy ID.
import requests, time, pandas as pd
JASPAR_API = "https://jaspar.elixir.no/api/v1"
# Common taxonomy IDs
TAX_IDS = {
"Homo sapiens": 9606,
"Mus musculus": 10090,
"Rattus norvegicus": 10116,
"Drosophila melanogaster": 7227,
"Saccharomyces cerevisiae": 4932,
}
def get_species_profiles(tax_id, collection="CORE"):
"""Return all matrices for a species tax ID."""
params = {"tax_id": tax_id, "collection": collection, "format": "json", "page_size": 100}
results = []
url = f"{JASPAR_API}/matrix/"
while url:
r = requests.get(url, params=params if url == f"{JASPAR_API}/matrix/" else None, timeout=15)
r.raise_for_status()
data = r.json()
results.extend(data["results"])
url = data.get("next")
time.sleep(0.3)
return results
human_profiles = get_species_profiles(TAX_IDS["Homo sapiens"])
print(f"Human CORE profiles: {len(human_profiles)}")
df = pd.DataFrame([{
"matrix_id": m["matrix_id"],
"name": m["name"],
"tf_class": m.get("class_name", ""),
"tf_family": m.get("family_name", ""),
"sites": m["sites"],
"type": m["type"],
} for m in human_profiles])
print(df.head(5).to_string(index=False))
print(f"\nExperiment types:\n{df['type'].value_counts().to_string()}")
Query 6: TF Class and Family Browser
Find all profiles belonging to a specific TF structural class or family, useful for building class-specific motif libraries.
import requests, time
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_class_profiles(tf_class=None, tf_family=None, collection="CORE", tax_group="vertebrates"):
"""Return all profiles for a TF structural class or family."""
params = {"collection": collection, "tax_group": tax_group, "format": "json", "page_size": 100}
if tf_class: params["tf_class"] = tf_class
if tf_family: params["tf_family"] = tf_family
results = []
url = f"{JASPAR_API}/matrix/"
while url:
r = requests.get(url, params=params if url == f"{JASPAR_API}/matrix/" else None, timeout=15)
r.raise_for_status()
data = r.json()
results.extend(data["results"])
url = data.get("next")
time.sleep(0.3)
return results
# Common TF classes: "Zinc-coordinating", "Basic leucine zipper", "Helix-turn-helix"
# Common TF families: "C2H2 ZF", "bHLH", "bZIP", "Homeodomain"
bhlh = get_class_profiles(tf_family="bHLH", collection="CORE", tax_group="vertebrates")
print(f"bHLH vertebrate profiles: {len(bhlh)}")
for m in bhlh[:5]:
print(f" {m['matrix_id']:12s} {m['name']:15s} sites={m['sites']:5d} {m['type']}")
# bHLH vertebrate profiles: 47
# MA0006.1 Ahr::Arnt sites=6 ChIP-seq
# MA0010.1 Tal1::Gata1 sites=5 SELEX
Key Concepts
JASPAR Collections
JASPAR organizes profiles into curated collections:
| Collection | Description | Profile count |
|---|---|---|
CORE |
Manually curated, non-redundant, high-quality profiles | ~1,200 (2024) |
CNE |
Profiles derived from conserved noncoding elements | ~100 |
UNVALIDATED |
Profiles not yet manually curated | ~1,000 |
PHYLOFACTS |
Profiles from phylogenetically constrained sites | ~100 |
POLII |
RNA polymerase II binding profiles | ~10 |
For most analyses use CORE. The JASPAR CORE 2024 vertebrate collection is the default reference for motif enrichment tools.
Matrix ID Versioning
JASPAR matrix IDs have the format MA{number}.{version} (e.g., MA0139.1). A new version is released when the binding data is updated. When scripting, use the versioned ID for reproducibility. Searching by name (e.g., CTCF) returns all versions; select the highest-numbered version for the most up-to-date matrix.
Information Content
The information content (IC) at each position (in bits) measures binding site specificity:
- IC = 2 - H(position), where H is the Shannon entropy
- High IC (close to 2 bits) = near-invariant base (e.g., always A)
- Low IC (close to 0) = little positional preference
- Total IC = sum over all positions; high total IC = more specific binding
import numpy as np
def information_content(pfm_dict, pseudocount=0.8):
"""Compute per-position IC and total IC for a JASPAR PFM."""
bases = ["A", "C", "G", "T"]
L = len(pfm_dict)
counts = np.array([[pfm_dict[str(i)][b] for i in range(L)] for b in bases], dtype=float)
counts += pseudocount
freqs = counts / counts.sum(axis=0, keepdims=True)
entropy = -np.sum(freqs * np.log2(freqs + 1e-12), axis=0)
ic_per_pos = 2 - entropy
return ic_per_pos, ic_per_pos.sum()
import requests
r = requests.get("https://jaspar.elixir.no/api/v1/matrix/MA0139.1/",
params={"format": "json"}, timeout=15)
pfm = r.json()["pfm"]
ic_pos, total_ic = information_content(pfm)
print(f"Total IC: {total_ic:.2f} bits")
print(f"Max IC position: {ic_pos.argmax()} ({ic_pos.max():.2f} bits)")
# Total IC: 19.47 bits
# Max IC position: 9 (1.99 bits)
Common Workflows
Workflow 1: Build a Human TF Motif Library and Export to MEME Format
Goal: Download all human CORE profiles and write them in MEME minimal format for use with FIMO, AME, or TOMTOM.
import requests, time, numpy as np
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_all_profiles(tax_id=9606, collection="CORE"):
params = {"tax_id": tax_id, "collection": collection, "format": "json", "page_size": 100}
results, url = [], f"{JASPAR_API}/matrix/"
while url:
r = requests.get(url, params=params if url == f"{JASPAR_API}/matrix/" else None, timeout=15)
r.raise_for_status()
data = r.json()
results.extend(data["results"])
url = data.get("next")
time.sleep(0.3)
return results
def pfm_to_freq(pfm_dict, pseudocount=0.1):
"""Return (4, L) frequency matrix [A, C, G, T]."""
bases = ["A", "C", "G", "T"]
L = len(pfm_dict)
counts = np.array([[pfm_dict[str(i)][b] for i in range(L)] for b in bases], dtype=float)
counts += pseudocount
return counts / counts.sum(axis=0, keepdims=True)
profiles = get_all_profiles(tax_id=9606, collection="CORE")
print(f"Downloaded {len(profiles)} human CORE profiles")
meme_lines = [
"MEME version 4",
"ALPHABET= ACGT",
"strands: + -",
"Background letter frequencies",
"A 0.25 C 0.25 G 0.25 T 0.25",
"",
]
for m in profiles:
pfm = m["pfm"]
freq = pfm_to_freq(pfm)
L = freq.shape[1]
meme_lines.append(f"MOTIF {m['matrix_id']} {m['name']}")
meme_lines.append(f"letter-probability matrix: alength= 4 w= {L} nsites= {m['sites']}")
for j in range(L):
row = " ".join(f"{freq[b, j]:.4f}" for b in range(4))
meme_lines.append(f" {row}")
meme_lines.append("")
output_path = "jaspar_human_core_2024.meme"
with open(output_path, "w") as f:
f.write("\n".join(meme_lines))
print(f"Written: {output_path} ({len(profiles)} motifs)")
Workflow 2: Promoter Scan for Multiple TFs and Visualize PWM Logos
Goal: Download PWMs for a set of TFs and scan a promoter sequence, then visualize the best-scoring hit as a bar logo.
import requests, numpy as np, time
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
JASPAR_API = "https://jaspar.elixir.no/api/v1"
BASE_IDX = {"A": 0, "C": 1, "G": 2, "T": 3}
BASE_COLORS = {"A": "#2ca02c", "C": "#1f77b4", "G": "#ff7f0e", "T": "#d62728"}
def fetch_pwm(matrix_id, pseudocount=0.8):
r = requests.get(f"{JASPAR_API}/matrix/{matrix_id}/", params={"format": "json"}, timeout=15)
r.raise_for_status()
pfm = r.json()["pfm"]
L = len(pfm)
counts = np.array([[pfm[str(i)][b] for i in range(L)] for b in ["A","C","G","T"]], dtype=float)
counts += pseudocount
freqs = counts / counts.sum(axis=0, keepdims=True)
return np.log2(freqs / 0.25), freqs
def scan(seq, pwm, pct=0.80):
seq = seq.upper()
L = pwm.shape[1]
max_s = pwm.clip(min=0).sum()
min_s = pwm.clip(max=0).sum()
thresh = min_s + pct * (max_s - min_s)
return [(i, sum(pwm[BASE_IDX[seq[i+j]], j] for j in range(L)))
for i in range(len(seq)-L+1) if "N" not in seq[i:i+L]
and sum(pwm[BASE_IDX[seq[i+j]], j] for j in range(L)) >= thresh]
def plot_logo(freqs, title, outfile):
"""Bar-chart sequence logo from frequency matrix (4, L)."""
L = freqs.shape[1]
entropy = -np.sum(freqs * np.log2(freqs + 1e-12), axis=0)
ic = 2 - entropy
bases = ["A", "C", "G", "T"]
fig, ax = plt.subplots(figsize=(max(6, L * 0.5), 2.5))
bottom = np.zeros(L)
for idx, base in enumerate(bases):
heights = freqs[idx] * ic
ax.bar(range(L), heights, bottom=bottom, color=BASE_COLORS[base], label=base, width=0.8)
bottom += heights
ax.set_xticks(range(L))
ax.set_xticklabels([str(i+1) for i in range(L)], fontsize=7)
ax.set_ylabel("Information Content (bits)")
ax.set_title(title, fontsize=10)
ax.legend(handles=[mpatches.Patch(color=BASE_COLORS[b], label=b) for b in bases],
loc="upper right", fontsize=7, ncol=4)
plt.tight_layout()
plt.savefig(outfile, dpi=150, bbox_inches="tight")
plt.close()
print(f"Saved {outfile}")
# TP53 promoter-region fragment (GRCh38 chr17:7,687,000-7,687,100)
promoter = ("GCAGAGGCGGAGGATTTGCCTTTTTTCGAGTTGGTGAGAGATCTGGGGCGGGGCAGGGCC"
"CTGGAACGGCAGGACGGAGAGCAAGGCCGGGGAAGGGCGGGAGCGGGCGGG")
# Scan for SP1 (MA0080.4) and TP53 (MA0106.3) hits
for matrix_id, tf_name in [("MA0080.4", "SP1"), ("MA0106.3", "TP53")]:
pwm, freqs = fetch_pwm(matrix_id)
hits = scan(promoter, pwm, pct=0.75)
print(f"{tf_name} ({matrix_id}): {len(hits)} hits at >=75% threshold")
for pos, score in hits[:3]:
print(f" pos={pos} score={score:.2f} {promoter[pos:pos+pwm.shape[1]]}")
plot_logo(freqs, f"{tf_name} ({matrix_id}) PWM logo", f"{tf_name}_logo.png")
time.sleep(0.5)
Workflow 3: TF Co-binding Partner Discovery via Shared Matrix Families
Goal: Find all TF families that have profiles in JASPAR, then retrieve family members to identify potential co-binding partners of a TF of interest.
import requests, time, pandas as pd
from collections import Counter
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_profiles_df(tax_group="vertebrates", collection="CORE"):
params = {"tax_group": tax_group, "collection": collection, "format": "json", "page_size": 100}
results, url = [], f"{JASPAR_API}/matrix/"
while url:
r = requests.get(url, params=params if url == f"{JASPAR_API}/matrix/" else None, timeout=15)
r.raise_for_status()
data = r.json()
results.extend(data["results"])
url = data.get("next")
time.sleep(0.3)
return pd.DataFrame([{
"matrix_id": m["matrix_id"],
"name": m["name"],
"tf_class": m.get("class_name", "Unknown"),
"tf_family": m.get("family_name", "Unknown"),
"sites": m["sites"],
"type": m["type"],
"uniprot": ";".join(m.get("uniprot_ids", [])),
} for m in results])
df = get_profiles_df(tax_group="vertebrates", collection="CORE")
print(f"Total CORE vertebrate profiles: {len(df)}")
# Top TF families
family_counts = df["tf_family"].value_counts()
print(f"\nTop 10 TF families:")
print(family_counts.head(10).to_string())
# Co-binding: find all TFs in the same family as CTCF
ctcf_row = df[df["name"] == "CTCF"].iloc[0]
ctcf_family = ctcf_row["tf_family"]
co_family = df[df["tf_family"] == ctcf_family][["matrix_id", "name", "sites", "type"]]
print(f"\nTFs in {ctcf_family} family (potential CTCF co-binders by class):")
print(co_family.to_string(index=False))
df.to_csv("jaspar_core_vertebrates.csv", index=False)
print(f"\nSaved jaspar_core_vertebrates.csv")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
search |
/matrix/ |
— | Any string (TF name, gene symbol) | Full-text search across name and aliases |
collection |
/matrix/ |
— | CORE, UNVALIDATED, CNE, POLII, PHYLOFACTS |
Restricts to a JASPAR sub-collection |
tax_group |
/matrix/ |
— | vertebrates, insects, plants, fungi, nematodes, urochordates |
Filter by broad taxonomic group |
tax_id |
/matrix/ |
— | NCBI taxonomy ID integer (e.g., 9606) |
Restrict to a single species |
tf_class |
/matrix/ |
— | "Zinc-coordinating", "Basic leucine zipper", "Helix-turn-helix", etc. |
Filter by TF structural class |
tf_family |
/matrix/ |
— | "C2H2 ZF", "bHLH", "bZIP", "Homeodomain", etc. |
Filter by TF structural family |
page_size |
/matrix/ |
10 | 1–100 | Results per page; use 100 for batch downloads |
pseudocount |
PFM→PWM (local) | 0.8 | 0.01–1.0 | Smooths zero-count positions; higher = less extreme PWM values |
threshold_pct |
scan (local) | 0.80 | 0.50–0.99 | Fraction of max score required to call a hit; lower = more permissive |
Best Practices
-
Pin matrix ID versions for reproducibility: Use
MA0139.1(not justCTCF) in scripts and manuscripts so results do not silently change across JASPAR releases. -
Always add pseudocounts when computing PWMs: Raw PFMs contain zero counts for rare positions. A zero count produces -inf in log space, eliminating any sequence with that nucleotide. Use
pseudocount = 0.8(JASPAR recommendation) orpseudocount = sqrt(sites) / 4. -
Use the CORE collection for standard analyses:
UNVALIDATEDprofiles have not been manually curated and may contain lower-confidence motifs. ReserveUNVALIDATEDfor exploratory analyses. -
Respect pagination: JASPAR returns at most 100 results per page. Always follow the
nextURL in responses when building complete profile sets. -
For batch scanning, use FIMO (MEME suite) rather than manual sliding window: The manual scanning above is educational. For production use, export matrices to MEME format (Workflow 1) and use
fimo --thresh 1e-4 motifs.meme sequence.fa.
Common Recipes
Recipe: Fetch All Versions of a TF's Matrix
When to use: Compare old and new profile versions of the same TF to check for changes.
import requests
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_all_versions(tf_name, collection="CORE"):
r = requests.get(f"{JASPAR_API}/matrix/", params={
"search": tf_name, "collection": collection, "format": "json", "page_size": 50
}, timeout=15)
r.raise_for_status()
return r.json()["results"]
versions = get_all_versions("SP1")
print(f"SP1 matrix versions in CORE: {len(versions)}")
for m in sorted(versions, key=lambda x: x["matrix_id"]):
print(f" {m['matrix_id']:12s} sites={m['sites']:5d} type={m['type']}")
# SP1 matrix versions in CORE: 4
# MA0080.1 sites= 18 type=SELEX
# MA0080.2 sites= 20 type=SELEX
# MA0080.3 sites= 146 type=ChIP-seq
# MA0080.4 sites= 481 type=ChIP-seq
Recipe: Retrieve Matrix in JASPAR Flat-File Format
When to use: Download a matrix in the legacy JASPAR text format for tools that accept it directly.
import requests
JASPAR_API = "https://jaspar.elixir.no/api/v1"
def get_jaspar_format(matrix_id):
"""Return matrix as JASPAR flat-file string."""
r = requests.get(f"{JASPAR_API}/matrix/{matrix_id}/", params={"format": "jaspar"}, timeout=15)
r.raise_for_status()
return r.text
jaspar_str = get_jaspar_format("MA0139.1")
print(jaspar_str[:200])
# >MA0139.1 CTCF
# A [ 87 167 281 56 8 32 15 4 55 ...]
# C [ 12 30 98 42 111 30 ...]
# G [ 22 36 160 40 66 21 ...]
# T [ 69 67 ... ]
with open("CTCF_MA0139.1.jaspar", "w") as f:
f.write(jaspar_str)
print("Saved CTCF_MA0139.1.jaspar")
Recipe: pyJASPAR Quick Motif Retrieval
When to use: Retrieve a motif as a BioPython motifs.Motif object when downstream tools expect that interface.
# Requires: pip install pyJASPAR
import pyJASPAR
db = pyJASPAR.JASPAR2024(auto_reverse_complement=True)
motif = db.fetch_motif_by_id("MA0139.1") # CTCF
print(f"Name: {motif.name}")
print(f"Matrix ID: {motif.matrix_id}")
print(f"Length: {len(motif)}")
print(f"Consensus: {motif.consensus}")
# Name: CTCF
# Matrix ID: MA0139.1
# Length: 19
# Consensus: CCGCGNGGNGGCAG
# Fetch multiple motifs for a TF family
motifs = db.fetch_motifs(collection="CORE", tax_id=9606, tf_family="bHLH")
print(f"Human bHLH motifs: {len(motifs)}")
for m in motifs[:3]:
print(f" {m.matrix_id} {m.name} len={len(m)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Empty results list from search |
TF not in JASPAR, wrong collection, or wrong tax_group |
Try collection=None to search all collections; check TF alias (e.g., NF-kB → RELA) |
404 Not Found for matrix ID |
Invalid or misspelled matrix ID | Verify ID format: MA + 4 digits + . + version (e.g., MA0139.1); search by name first |
pfm['0'] missing key |
Some JASPAR profiles have 0-indexed positions as integers, not strings | Cast position keys: pfm_dict = {str(k): v for k, v in pfm.items()} |
| PWM scan produces no hits | Threshold too strict or sequence too short | Lower threshold_pct to 0.70; check sequence length vs motif length |
pyJASPAR install fails |
Requires Python ≥3.8 and C extensions for BIOPYTHON | Use requests-based API directly; pyJASPAR is optional |
| Pagination stops early | next field is null before expected total |
Check count field in first response vs len(results) after loop |
| High IC positions show wrong base | PFM row order assumed incorrectly | JASPAR always returns {A, C, G, T} keys; never assume positional ordering |
Related Skills
homer-motif-analysis— de novo motif discovery from ChIP-seq or ATAC-seq peak sets; complements JASPAR known-motif libraryregulomedb-database— regulatory variant scoring using TF binding evidence overlapping JASPAR motifsencode-database— download TF ChIP-seq peak files that can be cross-referenced with JASPAR profilesremap-database— TF binding peak sets from ChIP-seq experiments for binding site validationmacs3-peak-calling— produce ChIP-seq peak BED files for downstream JASPAR motif enrichment
References
- JASPAR REST API v1 documentation — interactive browser and endpoint reference
- Castro-Mondragon et al., Nucleic Acids Research 2022 — JASPAR 2022 flagship paper describing collection structure and validation
- pyJASPAR GitHub repository — Python library wrapping the JASPAR API with BioPython motif objects
- Fornes et al., Nucleic Acids Research 2020 — JASPAR 2020 paper introducing the CORE 2020 collection
skills/genomics-bioinformatics/databases/kegg-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill kegg-database -g -y
SKILL.md
Frontmatter
{
"name": "kegg-database",
"license": "Non-academic use of KEGG requires a commercial license",
"description": "KEGG REST API (academic only). Pathways, genes, compounds, enzymes, diseases, drugs via 7 ops (info\/list\/find\/get\/conv\/link\/ddi). ID conversion (NCBI\/UniProt\/PubChem). Use bioservices for multi-DB Python."
}
KEGG Database — Biological Pathway & Molecular Network Queries
Overview
KEGG (Kyoto Encyclopedia of Genes and Genomes) is a comprehensive bioinformatics resource for biological pathway analysis, molecular interaction networks, and cross-database ID conversion. Access is via a direct REST API with no authentication — all operations use simple HTTP GET requests returning tab-delimited text.
When to Use
- Mapping genes to biological pathways (e.g., "which pathways involve TP53?")
- Retrieving metabolic pathway details, gene lists, or compound structures
- Converting identifiers between KEGG, NCBI Gene, UniProt, and PubChem
- Checking drug-drug interactions from KEGG's pharmacological database
- Building pathway enrichment context (all genes per pathway for an organism)
- Cross-referencing compounds, reactions, enzymes, and pathways
- For Python-native multi-database queries (KEGG + UniProt + Ensembl in one script), prefer
bioservicesinstead - For pathway visualization, use KEGG Mapper (https://www.kegg.jp/kegg/mapper/) directly
Prerequisites
pip install requests
API constraints:
- Academic use only — commercial use requires a separate KEGG license
- Max 10 entries per
get/list/conv/link/ddicall (image/kgml/json: 1 entry only) - No explicit rate limit, but add
time.sleep(0.5)between batch requests to avoid server-side throttling - Base URL:
https://rest.kegg.jp/
Quick Start
import requests
import time
BASE = "https://rest.kegg.jp"
def kegg_get(operation, *args):
"""Generic KEGG REST API caller."""
url = f"{BASE}/{operation}/{'/'.join(args)}"
resp = requests.get(url)
resp.raise_for_status()
return resp.text
# Find pathways linked to human gene TP53
pathways = kegg_get("link", "pathway", "hsa:7157")
print(pathways[:200])
# hsa:7157 path:hsa04010
# hsa:7157 path:hsa04110
# ...
# Get pathway details
detail = kegg_get("get", "hsa04110")
print(detail[:300])
Core API
1. Database Information — kegg_info
Retrieve metadata and statistics about KEGG databases.
import requests
BASE = "https://rest.kegg.jp"
# Database-level info
info = requests.get(f"{BASE}/info/pathway").text
print(info[:200])
# pathway Pathway
# Release 112.0, Dec 2025
# Kanehisa Laboratories
# ...
# Organism-level info
hsa_info = requests.get(f"{BASE}/info/hsa").text
print(hsa_info[:200])
Common databases: kegg, pathway, module, brite, genes, genome, compound, glycan, reaction, enzyme, disease, drug
2. Listing Entries — kegg_list
List entry identifiers and names from any KEGG database.
import requests
BASE = "https://rest.kegg.jp"
# All human pathways
hsa_pathways = requests.get(f"{BASE}/list/pathway/hsa").text
for line in hsa_pathways.strip().split("\n")[:5]:
pathway_id, name = line.split("\t")
print(f"{pathway_id}: {name}")
# path:hsa00010: Glycolysis / Gluconeogenesis - Homo sapiens (human)
# ...
# Specific entries (max 10, joined with +)
genes = requests.get(f"{BASE}/list/hsa:10458+hsa:10459").text
print(genes)
Common organism codes: hsa (human), mmu (mouse), dme (fruit fly), sce (yeast), eco (E. coli)
3. Keyword Search — kegg_find
Search databases by keywords or molecular properties.
import requests
import time
BASE = "https://rest.kegg.jp"
# Keyword search in genes
results = requests.get(f"{BASE}/find/genes/p53").text
print(f"Found {len(results.strip().split(chr(10)))} entries")
time.sleep(0.5)
# Chemical formula search (exact match)
compounds = requests.get(f"{BASE}/find/compound/C7H10N4O2/formula").text
print(compounds[:200])
time.sleep(0.5)
# Molecular weight range search
drugs = requests.get(f"{BASE}/find/drug/300-310/exact_mass").text
print(drugs[:200])
Search options: append /formula (exact match), /exact_mass (range), /mol_weight (range) to compound/drug queries.
4. Entry Retrieval — kegg_get
Retrieve complete database entries or specific data formats.
import requests
import time
BASE = "https://rest.kegg.jp"
# Full pathway entry (text format)
pathway = requests.get(f"{BASE}/get/hsa00010").text
print(pathway[:500])
time.sleep(0.5)
# Multiple entries (max 10, joined with +)
genes = requests.get(f"{BASE}/get/hsa:10458+hsa:10459").text
# Protein sequence (FASTA)
fasta = requests.get(f"{BASE}/get/hsa:10458/aaseq").text
print(fasta[:200])
time.sleep(0.5)
# Compound structure (MOL format)
mol = requests.get(f"{BASE}/get/cpd:C00002/mol").text # ATP
# Pathway image (PNG, single entry only)
img_resp = requests.get(f"{BASE}/get/hsa05130/image")
with open("pathway.png", "wb") as f:
f.write(img_resp.content)
print(f"Saved pathway image: {len(img_resp.content)} bytes")
Output formats: aaseq (protein FASTA), ntseq (nucleotide FASTA), mol (MOL), kcf (KCF), image (PNG), kgml (XML), json (pathway JSON). Image/KGML/JSON accept one entry only.
5. ID Conversion — kegg_conv
Convert identifiers between KEGG and external databases.
import requests
import time
BASE = "https://rest.kegg.jp"
# KEGG gene → NCBI Gene ID (specific gene)
ncbi = requests.get(f"{BASE}/conv/ncbi-geneid/hsa:10458").text
print(ncbi.strip())
# hsa:10458 ncbi-geneid:10458
time.sleep(0.5)
# KEGG gene → UniProt
uniprot = requests.get(f"{BASE}/conv/uniprot/hsa:10458").text
print(uniprot.strip())
time.sleep(0.5)
# Bulk conversion: all human genes → NCBI Gene IDs
all_conv = requests.get(f"{BASE}/conv/ncbi-geneid/hsa").text
lines = all_conv.strip().split("\n")
print(f"Total conversions: {len(lines)}")
# Reverse: NCBI Gene ID → KEGG
reverse = requests.get(f"{BASE}/conv/hsa/ncbi-geneid:7157").text
print(reverse.strip()) # TP53
Supported external databases: ncbi-geneid, ncbi-proteinid, uniprot, pubchem, chebi
6. Cross-Referencing — kegg_link
Find related entries within and between KEGG databases.
import requests
import time
BASE = "https://rest.kegg.jp"
# Genes in glycolysis pathway
genes = requests.get(f"{BASE}/link/genes/hsa00010").text
gene_list = [line.split("\t")[1] for line in genes.strip().split("\n") if line]
print(f"Glycolysis genes: {len(gene_list)}")
time.sleep(0.5)
# Pathways containing a specific gene
pathways = requests.get(f"{BASE}/link/pathway/hsa:7157").text # TP53
print(pathways[:300])
time.sleep(0.5)
# Compounds in a pathway
compounds = requests.get(f"{BASE}/link/compound/hsa00010").text
print(f"Compounds in glycolysis: {len(compounds.strip().split(chr(10)))}")
# Map genes to KO (orthology) groups
ko = requests.get(f"{BASE}/link/ko/hsa:10458").text
print(ko.strip())
Common links: genes ↔ pathway, pathway ↔ compound, pathway ↔ enzyme, genes ↔ ko (orthology)
7. Drug-Drug Interactions — kegg_ddi
Check pharmacological interactions between drugs.
import requests
BASE = "https://rest.kegg.jp"
# Single drug — all known interactions
interactions = requests.get(f"{BASE}/ddi/D00001").text
print(f"Interactions: {len(interactions.strip().split(chr(10)))}")
# Pairwise check (max 10 drugs, joined with +)
pair = requests.get(f"{BASE}/ddi/D00001+D00002+D00003").text
print(pair[:300])
Key Concepts
Identifier Formats
| Type | Format | Example |
|---|---|---|
| Reference pathway | map##### |
map00010 (Glycolysis, generic) |
| Organism pathway | {org}##### |
hsa00010 (Glycolysis, human) |
| Gene | {org}:{number} |
hsa:7157 (TP53) |
| Compound | cpd:C##### |
cpd:C00002 (ATP) |
| Drug | dr:D##### |
dr:D00001 |
| Enzyme | ec:{EC_number} |
ec:1.1.1.1 |
| KO (orthology) | ko:K##### |
ko:K00001 |
Pathway Categories
KEGG organizes pathways into seven major categories:
- Metabolism —
map001xx(Glycolysis, TCA cycle, amino acid metabolism) - Genetic Information Processing —
map030xx(Ribosome, Spliceosome, DNA repair) - Environmental Information Processing —
map040xx(MAPK signaling, ABC transporters) - Cellular Processes —
map041xx(Autophagy, Apoptosis, Cell cycle) - Organismal Systems —
map046xx(Immune, Endocrine, Nervous) - Human Diseases —
map052xx(Cancer, Neurodegenerative, Infectious) - Drug Development — Chronological and target-based classifications
Common Workflows
Workflow: Gene to Pathway Mapping
Find all pathways associated with a gene of interest.
import requests
import time
BASE = "https://rest.kegg.jp"
# Step 1: Find gene by keyword
results = requests.get(f"{BASE}/find/genes/BRCA1+homo+sapiens").text
print("Gene search results:")
for line in results.strip().split("\n")[:5]:
print(f" {line}")
time.sleep(0.5)
# Step 2: Get pathways linked to BRCA1
pathways = requests.get(f"{BASE}/link/pathway/hsa:672").text
pathway_ids = [line.split("\t")[1].replace("path:", "") for line in pathways.strip().split("\n") if line]
print(f"\nBRCA1 is in {len(pathway_ids)} pathways:")
time.sleep(0.5)
# Step 3: Get pathway names
for pid in pathway_ids[:5]:
info = requests.get(f"{BASE}/get/{pid}").text
# Extract NAME field
for line in info.split("\n"):
if line.startswith("NAME"):
print(f" {pid}: {line.replace('NAME', '').strip()}")
break
time.sleep(0.5)
Workflow: Pathway Enrichment Context
Build a gene-set collection for all pathways of an organism.
import requests
import time
BASE = "https://rest.kegg.jp"
# Step 1: List all human pathways
pathways_text = requests.get(f"{BASE}/list/pathway/hsa").text
pathways = {}
for line in pathways_text.strip().split("\n"):
pid, name = line.split("\t", 1)
pathways[pid.replace("path:", "")] = name
print(f"Total human pathways: {len(pathways)}")
time.sleep(0.5)
# Step 2: Get genes for each pathway (sample first 3 for demo)
gene_sets = {}
for pid in list(pathways.keys())[:3]:
genes_text = requests.get(f"{BASE}/link/genes/{pid}").text
gene_ids = [line.split("\t")[1] for line in genes_text.strip().split("\n") if line]
gene_sets[pid] = gene_ids
print(f" {pid}: {len(gene_ids)} genes")
time.sleep(0.5)
# Step 3: Convert to NCBI Gene IDs for enrichment tools
# (use kegg_conv for bulk conversion)
Workflow: Compound-Pathway-Reaction Analysis
Trace a compound through metabolic reactions and pathways.
import requests
import time
BASE = "https://rest.kegg.jp"
# Step 1: Search for compound
results = requests.get(f"{BASE}/find/compound/glucose").text
print("Compound search:")
for line in results.strip().split("\n")[:3]:
print(f" {line}")
time.sleep(0.5)
# Step 2: Find reactions involving glucose (C00031)
reactions = requests.get(f"{BASE}/link/reaction/cpd:C00031").text
rxn_ids = [line.split("\t")[1] for line in reactions.strip().split("\n") if line]
print(f"\nReactions involving glucose: {len(rxn_ids)}")
time.sleep(0.5)
# Step 3: Find pathways for a specific reaction
pathways = requests.get(f"{BASE}/link/pathway/rn:R00299").text
print(f"\nPathways for R00299:")
print(pathways[:300])
time.sleep(0.5)
# Step 4: Get pathway detail
detail = requests.get(f"{BASE}/get/map00010").text
print(f"\nGlycolysis pathway detail (first 500 chars):")
print(detail[:500])
Workflow: Cross-Database ID Integration
Map KEGG identifiers to UniProt, NCBI, and PubChem for multi-database workflows.
import requests
import time
BASE = "https://rest.kegg.jp"
# Step 1: Convert gene to multiple external IDs
gene = "hsa:7157" # TP53
uniprot = requests.get(f"{BASE}/conv/uniprot/{gene}").text.strip()
print(f"UniProt: {uniprot}")
time.sleep(0.5)
ncbi = requests.get(f"{BASE}/conv/ncbi-geneid/{gene}").text.strip()
print(f"NCBI Gene: {ncbi}")
time.sleep(0.5)
# Step 2: Get protein sequence from KEGG
fasta = requests.get(f"{BASE}/get/{gene}/aaseq").text
print(f"\nProtein sequence (first 200 chars):\n{fasta[:200]}")
time.sleep(0.5)
# Step 3: Convert compounds to PubChem CIDs
cpd_conv = requests.get(f"{BASE}/conv/pubchem/cpd:C00002").text.strip() # ATP
print(f"\nATP PubChem: {cpd_conv}")
Key Parameters
| Parameter | Function/Endpoint | Default | Options | Effect |
|---|---|---|---|---|
organism |
list, link, conv |
None | 3-4 letter code | Filter by organism (e.g., hsa, mmu) |
option |
find |
None | formula, exact_mass, mol_weight |
Search mode for compounds/drugs |
format |
get |
text | aaseq, ntseq, mol, kcf, image, kgml, json |
Output format |
+ separator |
get, list, ddi |
— | Max 10 entries | Batch query (join IDs with +) |
target_db |
conv |
— | ncbi-geneid, uniprot, pubchem, chebi |
External database for ID conversion |
target_db |
link |
— | pathway, genes, compound, ko, enzyme |
Related KEGG database |
Best Practices
-
Add delays between batch requests: No explicit rate limit, but
time.sleep(0.5)between requests prevents throttling and is courteous to the shared academic resource. -
Anti-pattern — fetching all entries without filtering: Use
kegg_listto enumerate IDs first, thenkegg_getfor specific entries. Avoid downloading entire databases when you need a subset. -
Parse tab-delimited output consistently: All KEGG responses use
\tas field separator and\nas record separator. Always.strip()before splitting. -
Respect the 10-entry batch limit:
kegg_get,kegg_list,kegg_conv,kegg_link,kegg_ddiaccept max 10 entries (joined with+). Image/KGML/JSON formats accept only 1. -
Use organism-specific pathway IDs:
hsa00010(human glycolysis) returns organism-specific gene mappings;map00010(reference) returns generic entries. Always prefer organism-specific when analyzing a known organism. -
Cache frequently-used conversions: Full organism ID conversions (
kegg_conv('ncbi-geneid', 'hsa')) return large results. Cache locally rather than repeating.
Common Recipes
Recipe: Parse KEGG Flat-File Entry
def parse_kegg_entry(text):
"""Parse a KEGG flat-file entry into a dictionary."""
entry = {}
current_key = None
for line in text.split("\n"):
if line.startswith("///"):
break
if line[:12].strip(): # New field
current_key = line[:12].strip()
entry[current_key] = line[12:].strip()
elif current_key: # Continuation
entry[current_key] += "\n" + line[12:].strip()
return entry
import requests
pathway = requests.get("https://rest.kegg.jp/get/hsa00010").text
parsed = parse_kegg_entry(pathway)
print(f"Name: {parsed.get('NAME', 'N/A')}")
print(f"Description: {parsed.get('DESCRIPTION', 'N/A')[:200]}")
Recipe: Organism Comparison
import requests
import time
BASE = "https://rest.kegg.jp"
organisms = {"hsa": "Human", "mmu": "Mouse", "sce": "Yeast"}
pathway = "00010" # Glycolysis
for org, name in organisms.items():
genes = requests.get(f"{BASE}/link/genes/{org}{pathway}").text
count = len([l for l in genes.strip().split("\n") if l])
print(f"{name} ({org}): {count} genes in Glycolysis")
time.sleep(0.5)
# Human (hsa): 68 genes in Glycolysis
# Mouse (mmu): 67 genes in Glycolysis
# Yeast (sce): 31 genes in Glycolysis
Recipe: Build Gene-to-Pathway Mapping Table
import requests
import time
BASE = "https://rest.kegg.jp"
# Get all human gene-pathway links
links = requests.get(f"{BASE}/link/pathway/hsa").text
gene_pathways = {}
for line in links.strip().split("\n"):
if not line:
continue
gene, pathway = line.split("\t")
gene_pathways.setdefault(gene, []).append(pathway.replace("path:", ""))
print(f"Genes with pathway annotations: {len(gene_pathways)}")
# Show top genes by pathway count
top = sorted(gene_pathways.items(), key=lambda x: -len(x[1]))[:5]
for gene, paths in top:
print(f" {gene}: {len(paths)} pathways")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found |
Entry or database doesn't exist | Verify ID format and organism code; use kegg_list to check valid IDs |
400 Bad Request |
Malformed API URL | Check URL path: /{operation}/{arg1}/{arg2}; no query params |
| Empty response | Search term too specific or no matches | Broaden keywords; try partial matches; check organism code |
| Image/KGML returns error | Batch query with image/kgml/json format | These formats accept one entry only — remove + joins |
403 Forbidden |
Server-side rate limiting | Add time.sleep(1) between requests; reduce batch frequency |
| Wrong gene IDs returned | Using reference pathway (map) instead of organism-specific |
Use organism prefix: hsa00010 not map00010 for gene links |
| ID conversion returns empty | External DB doesn't cover that entry | Not all KEGG entries have UniProt/NCBI mappings; check with kegg_list first |
| Response encoding issues | Non-ASCII characters in compound names | Use resp.encoding = 'utf-8' or resp.text (requests auto-detects) |
Related Skills
- gget-genomic-databases — unified Python interface to Ensembl, NCBI, UniProt; use for gene-level queries when KEGG pathway context isn't needed
- biopython-molecular-biology — BioPython's
Bio.KEGGmodule provides an alternative Python API for KEGG parsing - pubchem-compound-search — for compound property lookups beyond KEGG's structural data; use
kegg_conv('pubchem', ...)to bridge IDs
References
- KEGG REST API documentation — official API specification
- KEGG website — pathway browser, KEGG Mapper, BlastKOALA
- KEGG organism codes — full list of 3-4 letter organism codes
- Kanehisa, M. et al. (2023) "KEGG for taxonomy-based analysis of pathways and genomes" Nucleic Acids Research 51:D483-D489
skills/genomics-bioinformatics/databases/monarch-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill monarch-database -g -y
SKILL.md
Frontmatter
{
"name": "monarch-database",
"license": "BSD-3-Clause",
"description": "Monarch Initiative knowledge graph REST API for disease-gene-phenotype associations and cross-species orthology. MONDO disease-to-gene\/phenotype, HP phenotype profiles, cross-species comparisons. Use for rare disease gene prioritization and phenotype-based candidate ranking. For GWAS use gwas-database; for clinical pathogenicity use clinvar-database."
}
monarch-database
Overview
The Monarch Initiative integrates disease-phenotype-gene relationships from 30+ biomedical databases (OMIM, Orphanet, ClinVar, MGI, ZFIN, Reactome) into a unified knowledge graph. The REST API at https://api.monarchinitiative.org/v3/api provides access to associations between genes, diseases, and phenotypes using MONDO disease IDs, Human Phenotype Ontology (HPO) terms, and standard gene identifiers. No authentication is required; the service is free for academic use.
When to Use
- Mapping a disease (MONDO ID) to all associated causal genes and their evidence sources
- Retrieving phenotype profiles (HP terms) for a disease to build phenotypic similarity models
- Ranking candidate genes by phenotypic similarity to a patient's HPO symptom list
- Querying cross-species gene-phenotype associations (mouse, zebrafish, fly) for model organism comparisons
- Exploring rare disease gene-phenotype networks for diagnostic candidate generation
- Resolving entity metadata (gene symbol, disease name, phenotype label) from a MONDO/HP/HGNC ID
- Use
opentargets-databaseinstead when you need drug-target evidence scores or tractability data alongside disease associations - Use
clinvar-databasewhen you need clinical pathogenicity classifications with submitter review status
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: MONDO IDs (e.g.,
MONDO:0007374), HP term IDs (e.g.,HP:0001250), or gene symbols/HGNC IDs - Environment: internet connection; no API key required
- Rate limits: no published rate limit; use
time.sleep(0.3)between batch requests; avoid bursts over 10 requests/second
pip install requests pandas matplotlib
Quick Start
import requests
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def monarch_get(endpoint: str, params: dict = None) -> dict:
"""GET request to Monarch API; raises on HTTP errors."""
r = requests.get(f"{MONARCH_API}{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
# Get all genes associated with Marfan syndrome (MONDO:0007374)
result = monarch_get("/association/all", params={
"subject": "MONDO:0007374",
"category": "biolink:GeneToDiseaseAssociation",
"limit": 10
})
print(f"Total gene associations: {result['total']}")
for item in result["items"][:5]:
obj = item.get("object", {})
print(f" Gene: {obj.get('label', 'N/A')} ({obj.get('id', 'N/A')})")
# Total gene associations: 3
# Gene: FBN1 (HGNC:3603)
Core API
Query 1: Disease-Gene Associations
Retrieve all genes associated with a disease by MONDO ID. Returns causal gene records with evidence metadata.
import requests
import pandas as pd
import time
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def monarch_get(endpoint, params=None):
r = requests.get(f"{MONARCH_API}{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
def get_disease_genes(mondo_id: str, limit: int = 200) -> pd.DataFrame:
"""Return DataFrame of genes associated with a disease."""
result = monarch_get("/association/all", params={
"subject": mondo_id,
"category": "biolink:CausalGeneToDiseaseAssociation",
"limit": limit
})
rows = []
for item in result.get("items", []):
obj = item.get("object", {})
rows.append({
"gene_id": obj.get("id"),
"gene_symbol": obj.get("label"),
"taxon": obj.get("taxon", {}).get("label") if obj.get("taxon") else None,
"relation": item.get("predicate"),
"evidence_count": len(item.get("evidence", [])),
})
return pd.DataFrame(rows)
# Cystic fibrosis (MONDO:0009861)
df = get_disease_genes("MONDO:0009861")
print(f"Genes for cystic fibrosis: {len(df)}")
print(df[["gene_symbol", "gene_id", "relation"]].to_string(index=False))
# Genes for cystic fibrosis: 1
# gene_symbol gene_id relation
# CFTR HGNC:1884 biolink:causes
Query 2: Disease-Phenotype Associations
Retrieve HPO phenotype terms linked to a disease. Useful for building phenotype profiles and similarity scoring.
def get_disease_phenotypes(mondo_id: str, limit: int = 200) -> pd.DataFrame:
"""Return DataFrame of phenotypes (HP terms) for a disease."""
result = monarch_get("/association/all", params={
"subject": mondo_id,
"category": "biolink:DiseaseToPhenotypicFeatureAssociation",
"limit": limit
})
rows = []
for item in result.get("items", []):
obj = item.get("object", {})
rows.append({
"hp_id": obj.get("id"),
"phenotype": obj.get("label"),
"frequency": item.get("frequency", {}).get("label") if item.get("frequency") else None,
"onset": item.get("onset", {}).get("label") if item.get("onset") else None,
})
return pd.DataFrame(rows)
# Marfan syndrome (MONDO:0007374)
df = get_disease_phenotypes("MONDO:0007374", limit=50)
print(f"Phenotypes for Marfan syndrome: {len(df)}")
print(df[["phenotype", "hp_id", "frequency"]].head(8).to_string(index=False))
# Phenotypes for Marfan syndrome: 26
# phenotype hp_id frequency
# Aortic root aneurysm HP:0002616 HP:0040281 ...
Query 3: Entity Lookup
Retrieve metadata for any Monarch entity (gene, disease, phenotype) by its identifier.
def get_entity(entity_id: str) -> dict:
"""Retrieve metadata for a gene, disease, or phenotype by its ID."""
result = monarch_get(f"/entity/{entity_id}")
return result
# Look up HP:0001250 (Seizure)
hp = get_entity("HP:0001250")
print(f"Name: {hp.get('name')}")
print(f"ID: {hp.get('id')}")
print(f"Description: {hp.get('description', '')[:120]}")
print(f"Synonyms: {[s.get('val') for s in hp.get('synonyms', [])[:3]]}")
# Name: Seizure
# ID: HP:0001250
# Description: A seizure is an intermittent abnormality of nervous system physiology ...
# Look up a MONDO disease
disease = get_entity("MONDO:0007374")
print(f"\nDisease: {disease.get('name')}")
print(f"ID: {disease.get('id')}")
Query 4: Text Search for Entities
Search for entities by free-text label, useful for resolving disease names or phenotype terms to IDs.
def search_entities(query: str, category: str = None, limit: int = 10) -> list:
"""Search Monarch entities by label/synonym."""
params = {"q": query, "limit": limit}
if category:
params["category"] = category
result = monarch_get("/search", params=params)
return result.get("items", [])
# Search for "Ehlers-Danlos" diseases
hits = search_entities("Ehlers-Danlos", category="biolink:Disease", limit=8)
for hit in hits:
print(f" {hit.get('id'):<25} {hit.get('name', 'N/A')}")
# MONDO:0020066 Ehlers-Danlos syndrome
# MONDO:0007522 classical Ehlers-Danlos syndrome
# MONDO:0007528 hypermobile Ehlers-Danlos syndrome
# MONDO:0007523 kyphoscoliotic Ehlers-Danlos syndrome
Query 5: Gene-to-Disease Associations
Retrieve diseases associated with a gene. Useful for understanding a gene's disease spectrum.
def get_gene_diseases(gene_id: str, limit: int = 100) -> pd.DataFrame:
"""Return DataFrame of diseases associated with a gene."""
result = monarch_get("/association/all", params={
"subject": gene_id,
"category": "biolink:GeneToDiseaseAssociation",
"limit": limit
})
rows = []
for item in result.get("items", []):
obj = item.get("object", {})
rows.append({
"disease_id": obj.get("id"),
"disease_name": obj.get("label"),
"predicate": item.get("predicate"),
})
return pd.DataFrame(rows)
# Diseases caused by FBN1 (HGNC:3603)
df = get_gene_diseases("HGNC:3603")
print(f"Diseases linked to FBN1: {len(df)}")
print(df[["disease_name", "disease_id"]].head(5).to_string(index=False))
# Diseases linked to FBN1: 8
# disease_name disease_id
# Marfan syndrome MONDO:0007374
# Stiff skin syndrome MONDO:0007926
Query 6: Gene-Phenotype Associations (Cross-Species)
Query phenotypes linked to a gene across species including mouse, zebrafish, and human.
def get_gene_phenotypes(gene_id: str, limit: int = 100) -> pd.DataFrame:
"""Return gene-phenotype associations, optionally across species."""
result = monarch_get("/association/all", params={
"subject": gene_id,
"category": "biolink:GeneToPhenotypicFeatureAssociation",
"limit": limit
})
rows = []
for item in result.get("items", []):
subj = item.get("subject", {})
obj = item.get("object", {})
rows.append({
"gene_id": subj.get("id"),
"gene_symbol": subj.get("label"),
"taxon": subj.get("taxon", {}).get("label") if subj.get("taxon") else None,
"phenotype_id": obj.get("id"),
"phenotype": obj.get("label"),
})
return pd.DataFrame(rows)
# Phenotypes for human FBN1
df = get_gene_phenotypes("HGNC:3603")
print(f"FBN1 phenotype associations: {len(df)}")
print(df[["taxon", "phenotype"]].value_counts("taxon"))
# Homo sapiens 18
# Mus musculus 6
Query 7: Histopheno — Phenotype Distribution for a Disease
Retrieve summarized phenotype counts by anatomical system for a disease, useful for phenotype spectrum overviews.
def get_histopheno(mondo_id: str) -> dict:
"""Retrieve summarized phenotype distribution for a disease."""
result = monarch_get(f"/histopheno/{mondo_id}")
return result
hist = get_histopheno("MONDO:0007374") # Marfan syndrome
items = hist.get("items", [])
print(f"Phenotype categories for Marfan syndrome ({len(items)} systems):")
for item in sorted(items, key=lambda x: x.get("count", 0), reverse=True)[:8]:
print(f" {item.get('label', 'N/A'):<40} n={item.get('count', 0)}")
# Connective tissue n=12
# Cardiovascular system n=8
# Eye n=6
Query 8: Phenotype-to-Gene Associations
Given a set of HP phenotype terms, retrieve associated genes — the basis of phenotype-matching tools.
def get_phenotype_genes(hp_id: str, limit: int = 50) -> pd.DataFrame:
"""Return genes associated with a phenotype term."""
result = monarch_get("/association/all", params={
"object": hp_id,
"category": "biolink:GeneToPhenotypicFeatureAssociation",
"limit": limit
})
rows = []
for item in result.get("items", []):
subj = item.get("subject", {})
rows.append({
"gene_id": subj.get("id"),
"gene_symbol": subj.get("label"),
"taxon": subj.get("taxon", {}).get("label") if subj.get("taxon") else None,
})
return pd.DataFrame(rows)
# HP:0001631 — Atrial septal defect
df = get_phenotype_genes("HP:0001631")
print(f"Genes associated with Atrial septal defect: {len(df)}")
print(df[df["taxon"] == "Homo sapiens"]["gene_symbol"].head(8).tolist())
# ['TBX5', 'GATA4', 'NKX2-5', 'MYH6', 'ACTC1', ...]
Key Concepts
Monarch Identifier System
Monarch uses ontology-based compact URIs (CURIEs) as identifiers:
| Prefix | Namespace | Example |
|---|---|---|
MONDO |
Mondo Disease Ontology | MONDO:0007374 (Marfan syndrome) |
HP |
Human Phenotype Ontology | HP:0001250 (Seizure) |
HGNC |
HGNC human genes | HGNC:3603 (FBN1) |
NCBIGene |
NCBI Gene IDs | NCBIGene:2200 (FBN1) |
MGI |
Mouse Genome Informatics | MGI:95489 (Fbn1 mouse) |
ZFIN |
Zebrafish Information Network | ZFIN:ZDB-GENE-... |
Use the /search endpoint to convert free-text names to IDs before querying associations.
Association Categories
Monarch uses biolink model categories for associations:
| Category | Meaning |
|---|---|
biolink:CausalGeneToDiseaseAssociation |
Gene causes the disease |
biolink:DiseaseToPhenotypicFeatureAssociation |
Disease → phenotype (HPO terms) |
biolink:GeneToPhenotypicFeatureAssociation |
Gene → phenotype (any species) |
biolink:GeneToDiseaseAssociation |
Any gene-disease link (broader) |
Use CausalGeneToDiseaseAssociation for pathogenic gene lists; use GeneToDiseaseAssociation for broader evidence including susceptibility loci.
Common Workflows
Workflow 1: Rare Disease Gene Prioritization
Goal: Given a set of HPO terms from a patient, retrieve all diseases with overlapping phenotypes and their causal genes.
import requests
import pandas as pd
import time
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def monarch_get(endpoint, params=None):
r = requests.get(f"{MONARCH_API}{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
# Patient HPO profile
patient_hp_terms = ["HP:0001250", "HP:0000252", "HP:0001263"] # Seizure, Microcephaly, DD
gene_scores = {}
for hp_id in patient_hp_terms:
result = monarch_get("/association/all", params={
"object": hp_id,
"category": "biolink:DiseaseToPhenotypicFeatureAssociation",
"limit": 50
})
diseases = [item.get("subject", {}).get("id") for item in result.get("items", [])]
# For each disease, get causal genes
for disease_id in diseases[:5]: # limit per phenotype for demo
gene_result = monarch_get("/association/all", params={
"subject": disease_id,
"category": "biolink:CausalGeneToDiseaseAssociation",
"limit": 20
})
for item in gene_result.get("items", []):
gene_sym = item.get("object", {}).get("label", "")
if gene_sym:
gene_scores[gene_sym] = gene_scores.get(gene_sym, 0) + 1
time.sleep(0.3)
# Rank genes by co-occurrence with patient phenotypes
df = pd.DataFrame(
[(gene, score) for gene, score in gene_scores.items()],
columns=["gene_symbol", "phenotype_overlap_score"]
).sort_values("phenotype_overlap_score", ascending=False)
print(f"Candidate genes ranked by phenotype overlap (n={len(df)})")
print(df.head(10).to_string(index=False))
df.to_csv("candidate_genes_phenotype_ranked.csv", index=False)
Workflow 2: Disease Phenotype Profile and Visualization
Goal: Retrieve all HPO terms for a disease, summarize by anatomical category, and plot a bar chart.
import requests
import pandas as pd
import matplotlib.pyplot as plt
import time
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def monarch_get(endpoint, params=None):
r = requests.get(f"{MONARCH_API}{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
mondo_id = "MONDO:0009861" # Cystic fibrosis
disease_info = monarch_get(f"/entity/{mondo_id}")
disease_name = disease_info.get("name", mondo_id)
# Step 1: Get phenotype associations
result = monarch_get("/association/all", params={
"subject": mondo_id,
"category": "biolink:DiseaseToPhenotypicFeatureAssociation",
"limit": 200
})
items = result.get("items", [])
print(f"Phenotypes for {disease_name}: {len(items)}")
# Step 2: Gather HP term labels
rows = []
for item in items:
obj = item.get("object", {})
rows.append({
"hp_id": obj.get("id"),
"phenotype": obj.get("label"),
"frequency": item.get("frequency", {}).get("label") if item.get("frequency") else "Unknown"
})
df = pd.DataFrame(rows)
# Step 3: Histopheno summary for bar chart
hist = monarch_get(f"/histopheno/{mondo_id}")
hist_items = sorted(hist.get("items", []), key=lambda x: x.get("count", 0), reverse=True)[:12]
systems = [x.get("label", "Other")[:25] for x in hist_items]
counts = [x.get("count", 0) for x in hist_items]
fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(systems[::-1], counts[::-1], color="#2196F3")
ax.bar_label(bars, fmt="%d", padding=3)
ax.set_xlabel("Phenotype Count")
ax.set_title(f"Phenotype Distribution by System\n{disease_name} ({mondo_id})")
plt.tight_layout()
plt.savefig("monarch_phenotype_distribution.png", dpi=150, bbox_inches="tight")
print(f"Saved monarch_phenotype_distribution.png ({len(df)} total phenotypes)")
# Step 4: Export HPO terms
df.to_csv(f"{mondo_id.replace(':', '_')}_phenotypes.csv", index=False)
print(df[["hp_id", "phenotype", "frequency"]].head(8).to_string(index=False))
Workflow 3: Cross-Species Gene-Disease Network
Goal: Build a table of disease-gene associations including mouse model genes for a list of rare diseases.
import requests
import pandas as pd
import time
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def monarch_get(endpoint, params=None):
r = requests.get(f"{MONARCH_API}{endpoint}", params=params, timeout=30)
r.raise_for_status()
return r.json()
diseases = {
"MONDO:0007374": "Marfan syndrome",
"MONDO:0009861": "Cystic fibrosis",
"MONDO:0007522": "Classical EDS",
}
all_rows = []
for mondo_id, disease_name in diseases.items():
# Human causal genes
result = monarch_get("/association/all", params={
"subject": mondo_id,
"category": "biolink:CausalGeneToDiseaseAssociation",
"limit": 50
})
for item in result.get("items", []):
obj = item.get("object", {})
all_rows.append({
"disease_id": mondo_id,
"disease_name": disease_name,
"gene_id": obj.get("id"),
"gene_symbol": obj.get("label"),
"species": "Homo sapiens",
})
time.sleep(0.3)
df = pd.DataFrame(all_rows)
df.to_csv("rare_disease_gene_network.csv", index=False)
print(f"Associations collected: {len(df)}")
print(df.groupby("disease_name")["gene_symbol"].apply(list).to_string())
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
category |
/association/all |
(none) | biolink:CausalGeneToDiseaseAssociation, biolink:DiseaseToPhenotypicFeatureAssociation, biolink:GeneToPhenotypicFeatureAssociation, biolink:GeneToDiseaseAssociation |
Filters association type |
subject |
/association/all |
(none) | CURIE string (e.g., MONDO:0007374) |
Source entity (disease or gene) |
object |
/association/all |
(none) | CURIE string (e.g., HP:0001250) |
Target entity (phenotype or disease) |
limit |
/association/all, /search |
20 |
1–500 |
Max items returned per page |
offset |
/association/all |
0 |
integer | Pagination offset |
q |
/search |
(none) | free-text string | Label/synonym text search |
entity_id |
/entity/{id} |
(none) | CURIE string | Entity ID for metadata lookup |
mondo_id |
/histopheno/{id} |
(none) | MONDO CURIE | Disease ID for phenotype histogram |
Best Practices
-
Resolve names to IDs first using
/search: All association queries require CURIE IDs (e.g.,MONDO:0007374), not free-text. Usesearch_entities()to resolve "Marfan syndrome" →MONDO:0007374before querying associations. -
Use
CausalGeneToDiseaseAssociationfor gene lists, notGeneToDiseaseAssociation: The broader category includes susceptibility associations and ambiguous links. Causal associations have stronger evidence support. -
Paginate large result sets with
offset: The defaultlimitis 20 and max is 500. Checkresult["total"]and paginate withoffsetincrements to retrieve all records for diseases with many phenotypes:total = monarch_get("/association/all", params={"subject": mondo_id, "category": "...", "limit": 1})["total"] all_items = [] for offset in range(0, total, 200): batch = monarch_get("/association/all", params={"subject": mondo_id, "category": "...", "limit": 200, "offset": offset}) all_items.extend(batch.get("items", [])) time.sleep(0.3) -
Use
time.sleep(0.3)between requests in batch loops: The API is publicly accessible without rate limit documentation; polite access avoids throttling for multi-disease workflows. -
Cross-reference gene IDs with HGNC for human genes: Monarch may return
HGNC:XXXXorNCBIGene:XXXXIDs. Use the HGNC prefix for downstream tools that require HGNC; use the/entity/{id}endpoint to retrieve the alternative ID.
Common Recipes
Recipe: Resolve Disease Name to MONDO ID
When to use: Convert a disease name string to the canonical MONDO identifier before querying.
import requests
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def resolve_disease(name: str, top_n: int = 5) -> list:
"""Search for disease name and return top MONDO ID candidates."""
r = requests.get(f"{MONARCH_API}/search",
params={"q": name, "category": "biolink:Disease", "limit": top_n},
timeout=15)
r.raise_for_status()
return [(h.get("id"), h.get("name")) for h in r.json().get("items", [])]
candidates = resolve_disease("Huntington disease")
for mondo_id, label in candidates:
print(f" {mondo_id:<25} {label}")
# MONDO:0007739 Huntington disease
# MONDO:0024321 Huntington disease-like 1
Recipe: Batch Disease-Gene Lookup
When to use: Retrieve causal genes for a list of MONDO IDs in one call each, with results combined into a single DataFrame.
import requests
import pandas as pd
import time
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def get_causal_genes(mondo_id):
r = requests.get(f"{MONARCH_API}/association/all",
params={"subject": mondo_id,
"category": "biolink:CausalGeneToDiseaseAssociation",
"limit": 100},
timeout=30)
r.raise_for_status()
data = r.json()
return [(item.get("object", {}).get("id"), item.get("object", {}).get("label"))
for item in data.get("items", [])]
disease_ids = ["MONDO:0007374", "MONDO:0009861", "MONDO:0007739"]
rows = []
for mondo_id in disease_ids:
for gene_id, gene_sym in get_causal_genes(mondo_id):
rows.append({"disease_id": mondo_id, "gene_id": gene_id, "gene_symbol": gene_sym})
time.sleep(0.3)
df = pd.DataFrame(rows)
print(df.to_string(index=False))
df.to_csv("batch_disease_genes.csv", index=False)
print(f"\nTotal disease-gene pairs: {len(df)}")
Recipe: HPO Profile Similarity Seed
When to use: Retrieve disease HPO profiles to use as input seeds for phenotype similarity tools (e.g., Phenomizer, LIRICAL).
import requests, json
MONARCH_API = "https://api.monarchinitiative.org/v3/api"
def get_hp_profile(mondo_id, limit=500):
"""Return list of HP term IDs for a disease."""
r = requests.get(f"{MONARCH_API}/association/all",
params={"subject": mondo_id,
"category": "biolink:DiseaseToPhenotypicFeatureAssociation",
"limit": limit},
timeout=30)
r.raise_for_status()
items = r.json().get("items", [])
return [item.get("object", {}).get("id") for item in items if item.get("object", {}).get("id")]
hp_terms = get_hp_profile("MONDO:0007374") # Marfan syndrome
print(f"HP terms for Marfan syndrome: {len(hp_terms)}")
print(hp_terms[:8])
# ['HP:0002616', 'HP:0001166', 'HP:0000768', 'HP:0001083', ...]
# Save for downstream phenotype similarity tool input
with open("MONDO_0007374_hp_profile.json", "w") as f:
json.dump({"disease": "MONDO:0007374", "hpo_terms": hp_terms}, f, indent=2)
print("Saved MONDO_0007374_hp_profile.json")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Empty items list |
Wrong category string or entity has no associations of that type | Check the category name exactly; try biolink:GeneToDiseaseAssociation as a broader fallback |
404 Not Found for /entity/{id} |
Malformed CURIE or deprecated ID | Verify ID format (e.g., MONDO:0007374 not MONDO_0007374); use /search to find current IDs |
total is 0 but entity exists |
Subject/object direction reversed | Check whether you need subject or object parameter; gene→disease uses subject=gene_id; disease→phenotype uses subject=disease_id |
requests.exceptions.Timeout |
API overloaded or network issue | Increase timeout=60; retry with exponential backoff |
Gene ID returned as NCBIGene instead of HGNC |
Monarch may use either namespace | Use /entity/{id} to retrieve xrefs field for alternative IDs including HGNC, Ensembl |
| Results differ between API calls for same entity | Monarch knowledge graph is updated regularly | Pin your data collection date; note API version in methods section |
| Rate-limited or slow responses | Too many rapid requests | Add time.sleep(0.5) between batch requests; use limit=200 to reduce total requests |
Related Skills
clinvar-database— clinical pathogenicity classifications for specific variants (complements Monarch's gene-disease associations)gwas-database— GWAS Catalog associations for common variants and traitsopentargets-database— drug-target evidence with tractability and safety scoresensembl-database— gene/transcript annotation and cross-species orthology via Ensembl REST APIgseapy-gene-enrichment— gene set enrichment analysis using the Monarch-derived gene lists
References
- Monarch Initiative API v3 — Interactive Swagger/OpenAPI documentation
- Monarch Initiative — Main portal with disease-gene-phenotype knowledge graph browser
- Mungall et al., Genome Biology 2017 — Monarch Integration methodology paper
- Mondo Disease Ontology — MONDO ID reference and ontology browser
- Human Phenotype Ontology — HPO term browser and clinical applications
skills/genomics-bioinformatics/databases/quickgo-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill quickgo-database -g -y
SKILL.md
Frontmatter
{
"name": "quickgo-database",
"license": "Apache-2.0",
"description": "Query EBI QuickGO REST API for GO terms and protein annotations. Fetch term metadata by ID, search by keyword, walk ancestor\/descendant hierarchies, download annotations filtered by taxon, evidence code, aspect. Use for GO resolution, ontology traversal, annotation retrieval before enrichment. Use gseapy-gene-enrichment for enrichment; uniprot-protein-database for proteins."
}
QuickGO Database
Overview
QuickGO is the EBI's Gene Ontology annotation browser and REST API. It provides programmatic access to the GO ontology (terms, synonyms, hierarchies) and to the manually curated and electronic GO annotations for proteins across all species. The API is free, requires no authentication, and returns JSON responses. All endpoints live under https://www.ebi.ac.uk/QuickGO/services/.
When to Use
- Resolving a GO term ID (e.g.,
GO:0006915) to its name, definition, and aspect (biological_process, molecular_function, cellular_component) - Retrieving all GO annotations for a UniProt protein, filtered by evidence code and taxon
- Searching GO terms by keyword (e.g., "apoptosis") to find relevant term IDs before enrichment analysis
- Walking the GO DAG upward (ancestors) or downward (descendants) from a specific term
- Getting annotation counts stratified by evidence code or GO aspect for a set of proteins
- Resolving multiple GO IDs in one batch request to avoid looping over individual term lookups
- For enrichment analysis (ORA/GSEA) on a gene list use
gseapy-gene-enrichment; QuickGO provides the raw annotation data - For comprehensive protein function annotations in Swiss-Prot format use
uniprot-protein-database
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: GO term IDs (
GO:XXXXXXX) or UniProt accessions; taxon IDs (e.g.,9606for human) - Environment: internet connection; no API key required
- Rate limits: no published hard limit; use
time.sleep(1.0)between requests in batch loops for polite access
pip install requests pandas matplotlib
Quick Start
import requests
import time
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def quickgo_get(endpoint: str, params: dict = None) -> dict:
"""Send a GET request to a QuickGO endpoint and return parsed JSON."""
url = f"{QUICKGO_BASE}/{endpoint}"
headers = {"Accept": "application/json"}
r = requests.get(url, params=params, headers=headers, timeout=30)
r.raise_for_status()
return r.json()
# Fetch metadata for the apoptotic process GO term
result = quickgo_get("ontology/go/terms/GO:0006915")
term = result["results"][0]
print(f"ID : {term['id']}")
print(f"Name : {term['name']}")
print(f"Aspect : {term['aspect']}")
print(f"Def : {term['definition']['text'][:100]}...")
# ID : GO:0006915
# Name : apoptotic process
# Aspect : biological_process
# Def : A programmed cell death process which begins when a cell receives ...
Core API
Query 1: GO Term Lookup
Fetch term metadata — name, definition, aspect, synonyms, and is-obsolete status — for one or more GO IDs.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def get_go_term(go_id: str) -> dict:
"""Retrieve metadata for a single GO term by ID."""
headers = {"Accept": "application/json"}
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{go_id}",
headers=headers, timeout=30
)
r.raise_for_status()
results = r.json().get("results", [])
return results[0] if results else {}
term = get_go_term("GO:0005515")
print(f"Name : {term['name']}")
print(f"Aspect : {term['aspect']}")
print(f"Obsolete: {term.get('isObsolete', False)}")
print(f"Synonyms: {[s['name'] for s in term.get('synonyms', [])[:3]]}")
# Name : protein binding
# Aspect : molecular_function
# Obsolete: False
# Synonyms: ['protein-protein interaction', 'protein binding activity']
# Batch lookup: resolve multiple GO IDs in one request
go_ids = ["GO:0006915", "GO:0005515", "GO:0016020"]
ids_param = ",".join(go_ids)
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{ids_param}",
headers={"Accept": "application/json"}, timeout=30
)
r.raise_for_status()
for t in r.json().get("results", []):
print(f"{t['id']} {t['aspect']:<25} {t['name']}")
# GO:0006915 biological_process apoptotic process
# GO:0005515 molecular_function protein binding
# GO:0016020 cellular_component membrane
Query 2: Annotation Search
Retrieve GO annotations for a protein or a set of proteins. Filter by evidence code and taxon.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def get_protein_annotations(uniprot_id: str, evidence_codes: list = None,
limit: int = 100) -> list:
"""Fetch GO annotations for a UniProt protein."""
params = {
"geneProductId": f"UniProtKB:{uniprot_id}",
"limit": limit,
"page": 1,
}
if evidence_codes:
params["evidenceCode"] = ",".join(evidence_codes)
headers = {"Accept": "application/json"}
r = requests.get(
f"{QUICKGO_BASE}/annotation/search",
params=params, headers=headers, timeout=30
)
r.raise_for_status()
return r.json().get("results", [])
# Fetch experimental annotations for TP53 (P04637)
annotations = get_protein_annotations(
"P04637",
evidence_codes=["EXP", "IDA", "IPI", "IMP", "IGI", "IEP"]
)
print(f"Experimental annotations for TP53: {len(annotations)}")
for ann in annotations[:4]:
print(f" {ann['goId']} {ann['goName']:<40} {ann['evidenceCode']}")
# Experimental annotations for TP53: 87
# GO:0006977 DNA damage response, ... IDA
# GO:0043065 positive regulation of apoptosis IMP
# Annotations for a taxon (human, 9606) + specific GO term
params = {
"goId": "GO:0006915",
"taxonId": "9606",
"evidenceCode": "EXP,IDA,IPI,IMP,IGI,IEP",
"limit": 100,
"page": 1,
}
r = requests.get(
f"{QUICKGO_BASE}/annotation/search",
params=params,
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
data = r.json()
print(f"Total annotations: {data.get('numberOfHits', 'N/A')}")
print(f"Retrieved : {len(data.get('results', []))}")
for ann in data["results"][:3]:
print(f" {ann['geneProductId']} {ann['goId']} {ann['evidenceCode']}")
Query 3: Term Hierarchy
Get ancestors (terms more general than the query term) or descendants (more specific terms) by traversing the GO DAG.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def get_ancestors(go_id: str, relations: str = "is_a,part_of") -> list:
"""Return ancestor GO IDs for a term via the ontology hierarchy."""
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{go_id}/ancestors",
params={"relations": relations},
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
results = r.json().get("results", [])
return results[0].get("ancestors", []) if results else []
def get_descendants(go_id: str, relations: str = "is_a,part_of") -> list:
"""Return descendant GO IDs for a term via the ontology hierarchy."""
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{go_id}/descendants",
params={"relations": relations},
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
results = r.json().get("results", [])
return results[0].get("descendants", []) if results else []
ancestors = get_ancestors("GO:0006915")
descendants = get_descendants("GO:0006915")
print(f"Ancestors of GO:0006915 (apoptotic process): {len(ancestors)}")
print(f"Descendants of GO:0006915 : {len(descendants)}")
print(f"First 5 ancestors : {ancestors[:5]}")
# Ancestors of GO:0006915 (apoptotic process): 6
# Descendants of GO:0006915 : 53
# First 5 ancestors : ['GO:0008219', 'GO:0009987', ...]
Query 4: Ontology Search
Text-search for GO terms by keyword. Useful for discovering relevant GO IDs before building annotation queries.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def search_go_terms(query: str, limit: int = 20) -> list:
"""Search GO terms by keyword; returns list of term dicts."""
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/search",
params={"query": query, "limit": limit, "page": 1},
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
return r.json().get("results", [])
hits = search_go_terms("autophagy")
print(f"GO terms matching 'autophagy': {len(hits)}")
for h in hits[:5]:
print(f" {h['id']} {h['aspect']:<25} {h['name']}")
# GO terms matching 'autophagy': 20
# GO:0006914 biological_process autophagy
# GO:0016236 biological_process macroautophagy
# GO:0061709 biological_process reticulophagy
Query 5: Annotation Statistics
Get counts of annotations grouped by evidence code, GO aspect, or taxon for a gene product or GO term.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def get_annotation_stats(uniprot_id: str) -> dict:
"""Retrieve annotation counts by evidence type and GO aspect."""
params = {
"geneProductId": f"UniProtKB:{uniprot_id}",
"limit": 200,
"page": 1,
}
r = requests.get(
f"{QUICKGO_BASE}/annotation/search",
params=params,
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
results = r.json().get("results", [])
by_evidence = {}
by_aspect = {}
for ann in results:
ec = ann.get("evidenceCode", "unknown")
asp = ann.get("goAspect", "unknown")
by_evidence[ec] = by_evidence.get(ec, 0) + 1
by_aspect[asp] = by_aspect.get(asp, 0) + 1
return {"by_evidence": by_evidence, "by_aspect": by_aspect,
"total": len(results)}
stats = get_annotation_stats("P04637") # TP53
print(f"Total annotations (first page): {stats['total']}")
print("\nBy evidence code:")
for ec, n in sorted(stats["by_evidence"].items(), key=lambda x: -x[1]):
print(f" {ec:<5} : {n}")
print("\nBy GO aspect:")
for asp, n in stats["by_aspect"].items():
print(f" {asp}: {n}")
Query 6: Batch GO Term Query
Resolve a list of GO IDs to their names and aspects in a single API call (up to ~200 IDs per request).
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def batch_resolve_go_terms(go_ids: list) -> dict:
"""Resolve a list of GO IDs → {id: {name, aspect, definition}} in one call."""
ids_param = ",".join(go_ids)
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{ids_param}",
headers={"Accept": "application/json"},
timeout=60
)
r.raise_for_status()
return {
t["id"]: {
"name": t["name"],
"aspect": t["aspect"],
"definition": t.get("definition", {}).get("text", ""),
"obsolete": t.get("isObsolete", False),
}
for t in r.json().get("results", [])
}
go_ids = ["GO:0006915", "GO:0005515", "GO:0016020", "GO:0006281", "GO:0051301"]
resolved = batch_resolve_go_terms(go_ids)
print(f"Resolved {len(resolved)}/{len(go_ids)} GO IDs")
for gid, info in resolved.items():
print(f" {gid} [{info['aspect'][:2].upper()}] {info['name']}")
# Resolved 5/5 GO IDs
# GO:0006915 [BI] apoptotic process
# GO:0005515 [MO] protein binding
# GO:0016020 [CE] membrane
Key Concepts
GO Ontology Structure
The Gene Ontology is a directed acyclic graph (DAG) organized into three independent root aspects:
| Aspect code | Aspect name | Root term |
|---|---|---|
biological_process |
Biological process (BP) | GO:0008150 |
molecular_function |
Molecular function (MF) | GO:0003674 |
cellular_component |
Cellular component (CC) | GO:0005575 |
Terms are connected by two primary relation types: is_a (subclass) and part_of (mereological). When filtering annotation enrichment results, always check the aspect field to avoid mixing BP, MF, and CC terms.
Evidence Code Categories
The evidence code determines annotation reliability. Filter to experimental codes for high-confidence annotations; exclude IEA in clinical or mechanistic analyses.
| Category | Codes | Meaning |
|---|---|---|
| Experimental | EXP, IDA, IPI, IMP, IGI, IEP | Direct biochemical or genetic experiments |
| Computational/similarity | ISS, ISO, ISA, IBA, RCA | Inferred by sequence or phylogenetic similarity |
| Author statement | TAS, IC | Curator or author assertion without experiment |
| Electronic | IEA | Automated; no human review — lowest confidence |
| High throughput | HTP, HDA, HMP, HGI, HEP | High-throughput experimental methods |
Pagination
QuickGO annotation searches return paginated results. The numberOfHits field in the response gives the total count. Use the page parameter to iterate through all results when numberOfHits > limit.
import requests, time
def get_all_annotations(go_id: str, taxon_id: str = "9606",
evidence_codes: str = "EXP,IDA,IPI,IMP",
page_size: int = 100) -> list:
"""Retrieve all annotation pages for a GO term + taxon combination."""
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
all_results = []
page = 1
while True:
params = {"goId": go_id, "taxonId": taxon_id,
"evidenceCode": evidence_codes, "limit": page_size,
"page": page}
r = requests.get(f"{QUICKGO_BASE}/annotation/search",
params=params, headers={"Accept": "application/json"},
timeout=30)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
all_results.extend(results)
total = data.get("numberOfHits", 0)
if len(all_results) >= total or not results:
break
page += 1
time.sleep(1.0) # polite delay
return all_results
Common Workflows
Workflow 1: GO Annotation Profile for a Protein
Goal: Retrieve all GO annotations for a protein, split by aspect and evidence category, and visualize the evidence code distribution.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def get_all_annotations_for_protein(uniprot_id: str, page_size: int = 200) -> list:
all_results = []
page = 1
while True:
params = {"geneProductId": f"UniProtKB:{uniprot_id}",
"limit": page_size, "page": page}
r = requests.get(f"{QUICKGO_BASE}/annotation/search",
params=params, headers={"Accept": "application/json"},
timeout=30)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
all_results.extend(results)
if len(all_results) >= data.get("numberOfHits", 0) or not results:
break
page += 1
time.sleep(1.0)
return all_results
UNIPROT_ID = "P04637" # TP53 human
annotations = get_all_annotations_for_protein(UNIPROT_ID)
print(f"Total annotations for {UNIPROT_ID}: {len(annotations)}")
df = pd.DataFrame([{
"goId": a["goId"],
"goName": a.get("goName", ""),
"aspect": a.get("goAspect", ""),
"evidenceCode": a.get("evidenceCode", ""),
"reference": a.get("reference", ""),
"assignedBy": a.get("assignedBy", ""),
} for a in annotations])
# Evidence code distribution bar chart
ec_counts = df["evidenceCode"].value_counts()
fig, ax = plt.subplots(figsize=(9, 4))
bars = ax.bar(ec_counts.index, ec_counts.values, color="#2171B5", edgecolor="white")
ax.bar_label(bars, fontsize=8, padding=2)
ax.set_xlabel("Evidence Code")
ax.set_ylabel("Annotation Count")
ax.set_title(f"GO Annotation Evidence Codes — {UNIPROT_ID} (TP53)")
plt.tight_layout()
plt.savefig(f"{UNIPROT_ID}_evidence_codes.png", dpi=150, bbox_inches="tight")
print(f"Saved {UNIPROT_ID}_evidence_codes.png")
print(df.groupby(["aspect", "evidenceCode"]).size().to_string())
Workflow 2: Ontology-Aware Annotation Retrieval
Goal: For a GO term of interest, find all descendant terms and then retrieve annotations for all of them combined — capturing the full semantic scope.
import requests, time, pandas as pd
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def quickgo_get(endpoint, params=None):
r = requests.get(f"{QUICKGO_BASE}/{endpoint}",
params=params, headers={"Accept": "application/json"},
timeout=30)
r.raise_for_status()
return r.json()
# Step 1: Get all descendants of "cell death" (GO:0008219)
parent_go_id = "GO:0008219"
desc_data = quickgo_get(f"ontology/go/terms/{parent_go_id}/descendants",
params={"relations": "is_a,part_of"})
descendants = desc_data["results"][0]["descendants"] if desc_data["results"] else []
all_ids = [parent_go_id] + descendants
print(f"GO terms in '{parent_go_id}' subtree: {len(all_ids)}")
# Step 2: Batch-resolve term names (chunk to ≤ 100 per request)
resolved = {}
chunk_size = 100
for i in range(0, len(all_ids), chunk_size):
chunk = all_ids[i:i + chunk_size]
data = quickgo_get(f"ontology/go/terms/{','.join(chunk)}")
for t in data.get("results", []):
resolved[t["id"]] = t["name"]
time.sleep(1.0)
print(f"Resolved {len(resolved)} term names")
# Step 3: Fetch experimental annotations for human for all descendant terms
rows = []
for go_id in all_ids[:10]: # limit for demo; remove slice for full run
params = {"goId": go_id, "taxonId": "9606",
"evidenceCode": "EXP,IDA,IPI,IMP,IGI,IEP",
"limit": 100, "page": 1}
data = quickgo_get("annotation/search", params=params)
for ann in data.get("results", []):
rows.append({
"query_go_id": go_id,
"query_go_name": resolved.get(go_id, ""),
"protein": ann["geneProductId"],
"evidence": ann["evidenceCode"],
})
time.sleep(1.0)
df = pd.DataFrame(rows)
df.to_csv("cell_death_annotations.csv", index=False)
print(f"Saved {len(df)} annotation rows → cell_death_annotations.csv")
Workflow 3: Cross-Protein GO Term Comparison
Goal: Compare GO term coverage across a list of proteins and export a presence/absence matrix.
import requests, time, pandas as pd
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
proteins = {
"TP53": "P04637",
"BRCA1": "P38398",
"MDM2": "Q00987",
"BCL2": "P10415",
}
records = {}
for gene, uniprot_id in proteins.items():
params = {"geneProductId": f"UniProtKB:{uniprot_id}",
"evidenceCode": "EXP,IDA,IPI,IMP,IGI,IEP",
"limit": 200, "page": 1}
r = requests.get(f"{QUICKGO_BASE}/annotation/search",
params=params, headers={"Accept": "application/json"},
timeout=30)
r.raise_for_status()
go_ids = {ann["goId"] for ann in r.json().get("results", [])}
records[gene] = go_ids
print(f"{gene}: {len(go_ids)} experimental GO annotations")
time.sleep(1.0)
# Build presence/absence matrix
all_terms = sorted(set().union(*records.values()))
matrix = pd.DataFrame(
{gene: [1 if t in s else 0 for t in all_terms] for gene, s in records.items()},
index=all_terms
)
shared = matrix[matrix.sum(axis=1) == len(proteins)]
print(f"\nGO terms shared by all {len(proteins)} proteins: {len(shared)}")
print(shared.index.tolist()[:10])
matrix.to_csv("protein_go_matrix.csv")
print(f"Saved protein_go_matrix.csv ({matrix.shape[0]} GO terms)")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
goId |
annotation/search |
— | GO:XXXXXXX |
Filter annotations to a specific GO term |
geneProductId |
annotation/search |
— | UniProtKB:ACCESSION |
Filter to a specific protein |
taxonId |
annotation/search |
— | NCBI taxon integer (e.g., 9606) |
Filter annotations by species |
evidenceCode |
annotation/search |
— | Comma-separated codes, e.g., EXP,IDA |
Filter by annotation evidence type |
goAspect |
annotation/search |
— | biological_process, molecular_function, cellular_component |
Filter by ontology namespace |
relations |
terms/{id}/ancestors, terms/{id}/descendants |
is_a |
is_a, part_of, occurs_in, regulates |
Relation types for hierarchy traversal |
limit |
annotation/search, ontology/go/search |
25 |
1–200 |
Results per page |
page |
annotation/search, ontology/go/search |
1 |
positive integer | Pagination control |
query |
ontology/go/search |
— | free-text string | Keyword search across GO term names and definitions |
Best Practices
-
Use
batch_resolve_go_termsinstead of per-ID loops: The terms endpoint accepts a comma-separated list of IDs and resolves all in one round trip. For lists of up to 200 IDs this is 100× faster than one request per term. -
Exclude IEA for mechanistic conclusions: Electronic annotations (
IEA) are assigned by automated pipelines without manual review. They can inflate annotation counts and introduce false positives. SetevidenceCode=EXP,IDA,IPI,IMP,IGI,IEP,TASfor curated-only results. -
Add
time.sleep(1.0)in batch loops: QuickGO is shared EBI infrastructure with no published hard limit. One request per second keeps your scripts well within fair-use bounds. -
Use descendants for ontology-aware queries: Searching only the exact
goIdmisses proteins annotated to more specific child terms. Retrieve descendants first, then query each or combine into anevidenceCode-filtered batch. -
Check
numberOfHitsbefore assuming completeness: The defaultlimit=25often returns a fraction of total annotations. Always inspectnumberOfHitsand paginate whennumberOfHits > limit.
Common Recipes
Recipe: Resolve GO IDs from an Enrichment Result
When to use: Convert a list of GO IDs returned by gseapy or another enrichment tool to human-readable names.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
def resolve_go_names(go_ids: list) -> dict:
"""Return {go_id: name} for a list of GO IDs (single batch call)."""
ids_str = ",".join(go_ids)
r = requests.get(
f"{QUICKGO_BASE}/ontology/go/terms/{ids_str}",
headers={"Accept": "application/json"}, timeout=60
)
r.raise_for_status()
return {t["id"]: t["name"] for t in r.json().get("results", [])}
# Example: map enrichment result GO IDs
enriched_ids = ["GO:0006915", "GO:0043066", "GO:0097553", "GO:0008219", "GO:0006281"]
names = resolve_go_names(enriched_ids)
for gid, name in names.items():
print(f"{gid} {name}")
# GO:0006915 apoptotic process
# GO:0043066 negative regulation of apoptotic process
Recipe: Get All Experimental Annotations for a Human Protein
When to use: Pull curated evidence for a protein before manually reviewing its GO function landscape.
import requests, time
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
EXP_CODES = "EXP,IDA,IPI,IMP,IGI,IEP"
def get_experimental_annotations(uniprot_id: str) -> list:
results, page = [], 1
while True:
r = requests.get(
f"{QUICKGO_BASE}/annotation/search",
params={"geneProductId": f"UniProtKB:{uniprot_id}",
"evidenceCode": EXP_CODES,
"limit": 200, "page": page},
headers={"Accept": "application/json"}, timeout=30
)
r.raise_for_status()
data = r.json()
batch = data.get("results", [])
results.extend(batch)
if not batch or len(results) >= data.get("numberOfHits", 0):
break
page += 1
time.sleep(1.0)
return results
anns = get_experimental_annotations("P04637") # TP53
print(f"Experimental GO annotations for TP53: {len(anns)}")
for a in anns[:5]:
print(f" {a['goId']} {a.get('goName', '')[:40]} ({a['evidenceCode']})")
Recipe: Check if a GO Term Is Experimental or Inferred
When to use: Quickly decide whether an annotation is trustworthy before including it in a pathway model.
import requests
QUICKGO_BASE = "https://www.ebi.ac.uk/QuickGO/services"
EXPERIMENTAL = {"EXP", "IDA", "IPI", "IMP", "IGI", "IEP",
"HTP", "HDA", "HMP", "HGI", "HEP"}
def annotation_is_experimental(uniprot_id: str, go_id: str) -> bool:
"""Return True if any experimental annotation exists for protein + GO term."""
r = requests.get(
f"{QUICKGO_BASE}/annotation/search",
params={"geneProductId": f"UniProtKB:{uniprot_id}",
"goId": go_id, "limit": 10, "page": 1},
headers={"Accept": "application/json"}, timeout=30
)
r.raise_for_status()
return any(a["evidenceCode"] in EXPERIMENTAL
for a in r.json().get("results", []))
print(annotation_is_experimental("P04637", "GO:0006977")) # True
print(annotation_is_experimental("P04637", "GO:0016020")) # False (membrane — IEA only)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 400 on term lookup |
Malformed GO ID (spaces, wrong prefix) | Ensure format is GO:XXXXXXX (7 digits, colon, uppercase GO) |
results: [] for a known GO ID |
Term is obsolete or merged into another | Check isObsolete field; look up the replacement in consider or replacedBy |
| Annotation search returns 0 hits for a protein | UniProt accession format wrong | Prefix with UniProtKB: (case-sensitive), e.g., UniProtKB:P04637 |
numberOfHits >> len(results) |
Default limit=25 is too small |
Set limit=200 and implement pagination with page parameter |
| Batch term resolve returns fewer than expected | Some IDs are obsolete or malformed | Check returned id set against input; missing IDs are invalid or obsolete |
Rate limit / 503 Service Unavailable |
Too many rapid requests | Add time.sleep(1.0) between paged calls; backoff on 5xx errors |
| Descendant list is very large (1000+) | Broad root term selected | Use a more specific child term, or process descendants in chunks of 100 |
Related Skills
gseapy-gene-enrichment— ORA and GSEA enrichment analysis against GO and other gene set databases; use QuickGO to resolve term IDs from gseapy outputuniprot-protein-database— UniProt REST API for Swiss-Prot GO annotations integrated with protein sequence and feature dataensembl-database— Ensembl REST API for variant-level GO annotations and cross-species gene lookupskegg-database— KEGG pathways as an alternative functional annotation vocabulary
References
- QuickGO API documentation — Full Swagger/OpenAPI endpoint reference
- Binns et al., Bioinformatics 2009 — QuickGO: a web-based tool for Gene Ontology searching
- Gene Ontology Consortium — GO evidence codes, ontology files, and annotation downloads
- Ashburner et al., Nature Genetics 2000 — Original Gene Ontology paper
skills/genomics-bioinformatics/databases/regulomedb-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill regulomedb-database -g -y
SKILL.md
Frontmatter
{
"name": "regulomedb-database",
"license": "CC-BY-4.0",
"description": "Query RegulomeDB v2 GET REST API to score variants for regulatory function and retrieve overlapping evidence (TF binding, histone marks, DNase peaks, footprints, motifs, eQTLs, chromatin state). Scores range 1a (strongest) to 7 (none). Use for GWAS hit prioritization, regulatory variant annotation, cis-regulatory discovery. Use clinvar-database for pathogenicity; gwas-database for trait associations."
}
RegulomeDB Database
Overview
RegulomeDB integrates large-scale functional genomics data (ENCODE, Roadmap Epigenomics) to score genetic variants for regulatory potential. Each variant receives a ranking from 1a (highest regulatory confidence: eQTL + TF + DNase + motif + chromatin) to 7 (no known regulatory function). The v2 API is exposed as GET https://regulomedb.org/regulome-search/; the legacy POST /regulome-search/, POST /regulome-summary/, and GET /regulome-datasets/ JSON endpoints are no longer functional (return regulome-notfound stubs or 500). Access is free and requires no authentication.
When to Use
- Prioritizing GWAS hits for regulatory follow-up — identify which SNPs land in active regulatory elements
- Annotating a VCF or variant list with regulatory scores to filter to functionally relevant variants
- Identifying which transcription factors bind near a variant of interest (via the
@graphevidence rows) - Checking whether a non-coding variant overlaps a QTL and active chromatin simultaneously (
features.QTL) - Retrieving all annotated rsIDs in a genomic region for cis-regulatory analysis (region query with
nearby_snps) - Use
clinvar-databaseinstead when you need clinical pathogenicity classifications; RegulomeDB scores regulatory function, not germline disease association - Use
gwas-databaseinstead when you want published GWAS associations with traits
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: rsIDs (e.g.,
rs4946036), genomic positions (chr1:1000000), or region coordinates (chr1:1000000-2000000) - Genome build: GRCh38 (default) or GRCh37; specify in all requests
- Rate limits: No published rate limits; use
time.sleep(0.3)between requests in batch workflows
pip install requests pandas matplotlib
Quick Start
import requests
BASE = "https://regulomedb.org"
def regulome_score(variant, genome="GRCh38"):
"""Score a single variant (rsID or chr:pos-pos) via the GET /regulome-search/ endpoint."""
r = requests.get(
f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=30,
)
r.raise_for_status()
d = r.json()
rs = d.get("regulome_score", {})
vs = d.get("variants", [])
return {
"query": variant,
"ranking": rs.get("ranking"), # 1a / 1b / ... / 7
"probability": float(rs.get("probability", 0)),
"rsids": vs[0].get("rsids") if vs else [],
"chrom": vs[0].get("chrom") if vs else None,
"pos": vs[0].get("start") if vs else None,
}
print(regulome_score("rs4946036"))
# {'query': 'rs4946036', 'ranking': '7', 'probability': 0.18412,
# 'rsids': ['rs4946036'], 'chrom': 'chr6', 'pos': 114819799}
Core API
Query 1: Score a Single Variant (rsID or position)
The GET /regulome-search/ endpoint accepts an rsID or coordinate as regions=. Returns a regulome_score block (probability, ranking, tissue-specific scores) plus features flags and the per-dataset @graph evidence rows.
import requests
BASE = "https://regulomedb.org"
def score_variant(variant, genome="GRCh38"):
"""Return the regulome_score block and resolved coordinates."""
r = requests.get(
f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=30,
)
r.raise_for_status()
d = r.json()
rs = d.get("regulome_score", {})
vs = d.get("variants", [])
feats = d.get("features", {})
print(f"Variant : {variant}")
print(f"Resolved : {vs[0]['chrom']}:{vs[0]['start']} ({', '.join(vs[0].get('rsids', []))})")
print(f"Ranking : {rs.get('ranking')} prob={rs.get('probability')}")
print(f"Features : ChIP={feats['ChIP']} Chromatin_accessibility={feats['Chromatin_accessibility']} "
f"QTL={feats['QTL']} Footprint={feats['Footprint']} PWM_matched={feats['PWM_matched']}")
return d
# Strong-regulatory locus example
score_variant("chr11:5226739-5226740")
# Ranking: 1a (HBB beta-globin promoter, multi-evidence)
# Score by chromosomal position alone
score_variant("chr17:7670000-7670001") # TP53 region
Query 2: Region Scan — List Annotated Variants in a Window
A range query returns up to limit resolved variants (variants[]) and all @graph evidence rows in the window, plus nearby_snps (rsIDs adjacent to the resolved hits).
import requests, pandas as pd
BASE = "https://regulomedb.org"
def scan_region(chrom, start, end, genome="GRCh38", limit=200):
"""List variants in a region with their resolved positions and overlapping rsIDs."""
r = requests.get(
f"{BASE}/regulome-search/",
params={"regions": f"{chrom}:{start}-{end}", "genome": genome,
"format": "json", "limit": limit},
timeout=60,
)
r.raise_for_status()
d = r.json()
variants = d.get("variants", [])
print(f"Variants in {chrom}:{start}-{end}: {len(variants)} (total indexed = {d.get('total')})")
rows = [{"rsids": ", ".join(v.get("rsids", [])),
"chrom": v.get("chrom"),
"start": v.get("start"),
"end": v.get("end")} for v in variants]
return pd.DataFrame(rows)
df = scan_region("chr11", 5226000, 5227000)
print(df.head(10).to_string(index=False))
Query 3: Full Evidence — Parse the @graph Rows
Each @graph[i] row is one experimental piece of evidence overlapping the query. Fields: method, target_label, biosample_ontology{term_name, organ_slims, classification}, dataset, file, value, chrom, start, end, strand, ancestry, disease_term_name.
import requests, pandas as pd
BASE = "https://regulomedb.org"
def evidence_rows(variant, genome="GRCh38"):
r = requests.get(
f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=60,
)
r.raise_for_status()
g = r.json().get("@graph", [])
rows = []
for row in g:
bs = row.get("biosample_ontology") or {}
rows.append({
"method": row.get("method"),
"target_label": row.get("target_label"),
"biosample": bs.get("term_name"),
"organ_slims": ", ".join(bs.get("organ_slims") or []),
"dataset": row.get("dataset", "").split("/")[-2] if row.get("dataset") else None,
"value": row.get("value"),
})
return pd.DataFrame(rows)
df_evidence = evidence_rows("chr11:5226739-5226740")
# Each method is one of: ChIP-seq, Histone ChIP-seq, ATAC-seq, DNase-seq,
# footprints, PWMs, chromatin state, eQTLs
print(df_evidence["method"].value_counts())
Query 4: TF ChIP-seq Hits — Filter Evidence by Method
To list the transcription factors binding near a variant, filter @graph rows where method == "ChIP-seq" and read target_label + biosample_ontology.term_name.
import requests, pandas as pd
BASE = "https://regulomedb.org"
def tf_binding(variant, genome="GRCh38"):
r = requests.get(f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=60)
r.raise_for_status()
rows = []
for g in r.json().get("@graph", []):
if g.get("method") != "ChIP-seq":
continue
bs = g.get("biosample_ontology") or {}
rows.append({
"tf": g.get("target_label"),
"biosample": bs.get("term_name"),
"classification": bs.get("classification"),
})
return pd.DataFrame(rows)
df_tfs = tf_binding("chr11:5226739-5226740")
print(f"TF ChIP-seq peaks overlapping query: {len(df_tfs)}")
print(df_tfs.groupby("tf").size().sort_values(ascending=False).head(10))
Query 5: Tissue-Specific Regulatory Score
regulome_score.tissue_specific_scores maps ~50 tissues to per-tissue regulatory probabilities (0–1). Rank tissues to identify where the variant has the strongest regulatory signal.
import requests, pandas as pd
BASE = "https://regulomedb.org"
def tissue_scores(variant, genome="GRCh38", top_n=10):
r = requests.get(f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=30)
r.raise_for_status()
ts = r.json().get("regulome_score", {}).get("tissue_specific_scores", {})
s = pd.Series({k: float(v) for k, v in ts.items()})
return s.sort_values(ascending=False).head(top_n)
print("Top tissues by regulatory probability for chr11:5226739-5226740:")
print(tissue_scores("chr11:5226739-5226740"))
Query 6: Nearby SNPs — List rsIDs Adjacent to a Position
nearby_snps carries dbSNP rsIDs near the resolved coordinates, with reference/alt allele frequencies (when GnomAD-indexed).
import requests, pandas as pd
BASE = "https://regulomedb.org"
def nearby(variant, genome="GRCh38"):
r = requests.get(f"{BASE}/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=30)
r.raise_for_status()
rows = []
for s in r.json().get("nearby_snps", []):
rows.append({
"rsid": s.get("rsid"),
"chrom": s.get("chrom"),
"pos": s.get("coordinates", {}).get("gte"),
"type": s.get("variation_type"),
"maf": s.get("maf"),
})
return pd.DataFrame(rows)
df_nearby = nearby("rs4946036")
print(f"Nearby SNPs to rs4946036: {len(df_nearby)}")
print(df_nearby.head(10).to_string(index=False))
Key Concepts
RegulomeDB Scoring Schema
RegulomeDB ranks encode the strength of evidence overlapping a variant. The regulome_score.ranking string is one of:
| Ranking | Evidence | Confidence |
|---|---|---|
| 1a | eQTL + TF + DNase + motif + matched footprint | Highest |
| 1b–1f | Multi-evidence (sub-ranks reflect which inputs match) | Very high |
| 2a | TF binding + DNase + motif | High |
| 2b | TF binding + any DNase (no motif required) | High |
| 2c | TF binding + DNase (limited) | Moderate-high |
| 3a | DNase + motif (no TF ChIP-seq) | Moderate |
| 3b | Motif only (no DNase) | Moderate |
| 4 | Single TF binding evidence | Low-moderate |
| 5 | DNase peak only | Low |
| 6 | Other regulatory evidence | Minimal |
| 7 | No known regulatory function | None |
regulome_score.probability is the numeric model score (0–1) underlying the discrete ranking.
features Booleans vs @graph Detail
features is a high-level summary — boolean flags indicating presence of ChIP, Chromatin_accessibility, Footprint, PWM, QTL, etc. For per-dataset detail (which exact TF / cell type / experiment), iterate @graph[] and filter by method.
Variant Input Formats
regions= accepts:
# rsID — resolved server-side to current-build coordinates
"rs4946036"
# Single-position range
"chr11:5226739-5226740"
# Wider region (returns multiple variants[] entries + larger @graph)
"chr11:5226000-5227000"
Common Workflows
Workflow 1: GWAS Hit Prioritization
Goal: Score a list of GWAS lead SNPs and rank by regulatory confidence.
import requests, time, pandas as pd
import matplotlib.pyplot as plt
BASE = "https://regulomedb.org"
gwas_snps = ["rs7903146", "rs10811661", "rs1801282", "rs4946036",
"rs2268177", "rs10830963", "rs1111875"]
records = []
for snp in gwas_snps:
r = requests.get(f"{BASE}/regulome-search/",
params={"regions": snp, "genome": "GRCh38", "format": "json"},
timeout=30)
r.raise_for_status()
d = r.json()
rs = d.get("regulome_score", {})
feats = d.get("features", {})
g = d.get("@graph", [])
tfs = sorted({row["target_label"] for row in g
if row.get("method") == "ChIP-seq" and row.get("target_label")})
records.append({
"snp": snp,
"ranking": rs.get("ranking"),
"probability": float(rs.get("probability", 0)),
"has_qtl": feats.get("QTL", False),
"tf_count": len(tfs),
"num_evidence_rows": len(g),
})
time.sleep(0.3)
df = pd.DataFrame(records).sort_values("probability", ascending=False)
print(df.to_string(index=False))
df.to_csv("gwas_regulatory_priority.csv", index=False)
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(df["snp"], df["probability"], color="steelblue", edgecolor="black")
ax.set_ylabel("Regulatory probability")
ax.set_title("GWAS lead-SNP regulatory probabilities")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig("gwas_score_distribution.png", dpi=150, bbox_inches="tight")
Workflow 2: Locus Evidence Profile
Goal: Summarize the methods underlying the score at a locus (e.g., HBB promoter).
import requests, pandas as pd
import matplotlib.pyplot as plt
BASE = "https://regulomedb.org"
def locus_profile(region, genome="GRCh38"):
r = requests.get(f"{BASE}/regulome-search/",
params={"regions": region, "genome": genome, "format": "json"},
timeout=60)
r.raise_for_status()
d = r.json()
rs = d.get("regulome_score", {})
g = d.get("@graph", [])
counts = pd.Series([row.get("method") for row in g]).value_counts()
print(f"\n=== {region} | ranking={rs.get('ranking')} prob={rs.get('probability')} ===")
print(counts.to_string())
return counts
counts = locus_profile("chr11:5226739-5226740") # HBB
fig, ax = plt.subplots(figsize=(8, 4))
counts.plot(kind="barh", color="seagreen", ax=ax)
ax.set_xlabel("Evidence rows in @graph")
ax.set_title("Regulatory evidence by method (HBB promoter)")
plt.tight_layout()
plt.savefig("locus_evidence_profile.png", dpi=150, bbox_inches="tight")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
regions |
GET /regulome-search/ | required | rsID, chrN:start-end, or chrN:pos-pos |
Variant/region to score |
genome |
GET /regulome-search/ | "GRCh38" |
"GRCh38", "GRCh37" |
Reference genome assembly |
format |
GET /regulome-search/ | "html" |
"json", "tsv", "html" |
Use "json" for programmatic access |
limit |
GET /regulome-search/ | 200 |
1–1000 |
Max resolved variants in variants[] for region queries |
from |
GET /regulome-search/ | 0 |
non-negative int | Offset for paging through large @graph lists |
Best Practices
-
Use GET, not POST. The POST
/regulome-search/, POST/regulome-summary/, and GET/regulome-datasets/JSON endpoints return aregulome-notfoundstub or HTTP 500. OnlyGET /regulome-search/?regions=...&genome=...&format=jsonreturns real data. -
Read
regulome_score.ranking, notregulomedb_score. The field used to be namedregulomedb_scorein legacy docs; the live API exposes it asregulome_score.ranking(string like"1a","7"). -
Add
time.sleep(0.3)between calls. RegulomeDB has no published rate limit, but polite spacing prevents intermittent 502s under load. -
Score 7 ≠ "no regulation". Score 7 means absence of evidence in RegulomeDB's curated datasets, not biological absence of regulation. Cross-check with ENCODE/Roadmap directly via
encode-databasefor negative results. -
For tissue specificity, use
tissue_specific_scores. The singlerankingis an aggregate; the per-tissue probabilities reveal where the variant has the strongest regulatory signal. -
Watch the GRCh37 vs GRCh38 build. rsIDs are auto-resolved to the requested build, but coordinate-based
regions=chrN:start-endqueries are build-specific — pass the matchinggenome=value.
Common Recipes
Recipe: Quick Score Lookup
When to use: One-off check before kicking off a larger pipeline.
import requests
def quick_score(variant, genome="GRCh38"):
r = requests.get("https://regulomedb.org/regulome-search/",
params={"regions": variant, "genome": genome, "format": "json"},
timeout=20)
r.raise_for_status()
rs = r.json().get("regulome_score", {})
print(f"{variant}: ranking={rs.get('ranking')} prob={rs.get('probability')}")
quick_score("rs4946036") # ranking=7 prob=0.18412
quick_score("chr11:5226739-5226740") # ranking=1a (HBB promoter)
Recipe: Filter Variants to High-Confidence Regulatory Set
import requests, time, pandas as pd
HIGH_CONF = {"1a", "1b", "1c", "1d", "1e", "1f", "2a", "2b"}
def high_conf_only(variants, genome="GRCh38"):
keep = []
for v in variants:
r = requests.get("https://regulomedb.org/regulome-search/",
params={"regions": v, "genome": genome, "format": "json"},
timeout=30)
ranking = r.json().get("regulome_score", {}).get("ranking")
if ranking in HIGH_CONF:
keep.append({"variant": v, "ranking": ranking})
time.sleep(0.3)
return pd.DataFrame(keep)
df = high_conf_only(["rs4946036", "rs7903146", "chr11:5226739-5226740"])
print(df.to_string(index=False))
Recipe: QTL-Linked Variants
When to use: Find variants with features.QTL == True, i.e. those overlapping a curated QTL row in @graph (method "QTLs").
import requests, time, pandas as pd
def qtl_overlap(variants, genome="GRCh38"):
rows = []
for v in variants:
r = requests.get("https://regulomedb.org/regulome-search/",
params={"regions": v, "genome": genome, "format": "json"},
timeout=30)
d = r.json()
if not d.get("features", {}).get("QTL"):
time.sleep(0.3); continue
for g in d.get("@graph", []):
if g.get("method") == "QTLs":
rows.append({
"variant": v,
"ranking": d.get("regulome_score", {}).get("ranking"),
"qtl_target": g.get("target_label"),
"value": g.get("value"),
"biosample": (g.get("biosample_ontology") or {}).get("term_name"),
})
time.sleep(0.3)
return pd.DataFrame(rows)
print(qtl_overlap(["rs4946036", "chr11:5226739-5226740"]))
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Response body {"@id":"/regulome-notfound","@type":["regulome-help"]...} |
Using the legacy POST /regulome-search/ with a JSON body |
Switch to GET with regions= query param + format=json |
| HTTP 500 | Hitting the deprecated /regulome-summary/ endpoint |
Endpoint is dead; aggregate counts client-side from @graph[].method |
KeyError: 'regulomedb_score' |
Field renamed | Use regulome_score.ranking (string) and regulome_score.probability (float-as-string) |
peaks / eqtls / assay_type missing |
Old schema | Iterate @graph[] and filter by method (ChIP-seq, DNase-seq, Histone ChIP-seq, ATAC-seq, footprints, PWMs, QTLs, chromatin state) |
Empty variants[] for an rsID |
rsID not in RegulomeDB index or build mismatch | Try the chr:pos form; check genome= matches the coordinates |
Region search returns 0 @graph rows |
Region size too small or in an uncharacterized chromosome | Widen the window to ≥ 200 bp; avoid alt contigs (chrUn_*, *_random) |
| Region query truncates at 200 results | Default limit=200 |
Pass limit=1000 or page with from=0,200,400,... |
Related Skills
gwas-database— NHGRI-EBI GWAS Catalog for published SNP-trait associations; pair with RegulomeDB to prioritize GWAS hitsclinvar-database— Clinical pathogenicity classifications; complements RegulomeDB's functional regulatory evidenceencode-database— Direct ENCODE REST API access for the TF ChIP-seq / ATAC-seq peak sets that underlie RegulomeDB scoresensembl-database— Variant annotation and gene coordinate lookup; use to map rsIDs to genomic positions before region queries
References
- RegulomeDB Official Help — Documentation and scoring schema
- RegulomeDB search endpoint — Main REST API entry point (GET only)
- Boyle AP et al. "Annotation of functional variation in personal genomes using RegulomeDB." Genome Research 22(9): 1790–1797 (2012). https://doi.org/10.1101/gr.137323.112
- Dong S et al. "Annotating genomic variants and their functional effects using RegulomeDB 2.0." Bioinformatics 35(24): 5341–5343 (2019). https://doi.org/10.1093/bioinformatics/btz560
skills/genomics-bioinformatics/databases/remap-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill remap-database -g -y
SKILL.md
Frontmatter
{
"name": "remap-database",
"license": "CC-BY-4.0",
"description": "Query ReMap 2022 TF ChIP-seq peak database via REST API and BED downloads. Retrieve TF peaks overlapping a region (chr:start-end), peaks near a gene, TFs by species, peaks filtered by biotype (promoter, enhancer), and BED files for a TF-cell type pair. Use for TF co-occupancy, regulatory annotation, and TF binding atlases. Use jaspar-database for PWM motifs; encode-database for ENCODE tracks."
}
ReMap Database
Overview
ReMap 2022 is an integrative database of transcription factor (TF), cofactor, and chromatin regulator binding sites derived from uniformly reprocessed ChIP-seq experiments. The 2022 release catalogs 165 million non-redundant peaks from 8,113 ChIP-seq datasets covering 1,210 TFs across human (hg38/hg19), mouse (mm10), Drosophila, and Arabidopsis genomes. All peaks are called with a consistent pipeline from public GEO/ArrayExpress experiments. Access is via the ReMap 2022 REST API at https://remap2022.univ-amu.fr/api/ and bulk BED file downloads; no authentication required.
When to Use
- Finding all TFs with ChIP-seq peaks overlapping a genomic region of interest (e.g., a GWAS SNP locus or candidate enhancer)
- Retrieving TF peaks near a gene's transcription start site to map its proximal regulatory landscape
- Listing all TFs available in ReMap for human or mouse with their peak and dataset counts
- Filtering ChIP-seq peaks by regulatory biotype annotation (promoter, enhancer, exon, intron, intergenic) for a TF in a specific cell line
- Downloading a BED file of all binding peaks for a TF across all cell types for offline analysis
- Identifying co-binding TFs at a locus by querying all overlapping peaks and grouping by TF name
- Use
jaspar-databaseinstead when you need PWM/PFM sequence models of TF binding specificity rather than ChIP-seq peak locations - For ENCODE-specific regulatory tracks and accessibility data use
encode-database; ReMap aggregates TF binding peaks from many sources including ENCODE
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: genomic coordinates (GRCh38/hg38 or hg19), gene names, or TF names
- Environment: internet connection; no API key required
- Rate limits: no official published limits; use
time.sleep(0.5)between batch requests to avoid server overload - Note: The ReMap API is a research API; endpoint availability may vary. All examples include a BED download fallback.
pip install requests pandas matplotlib
Quick Start
import requests
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
# Query TF peaks overlapping a genomic region
r = requests.get(f"{REMAP_API}/peaks/overlap/", params={
"chr": "chr17",
"start": 7_670_000,
"end": 7_690_000,
"assembly": "hg38"
}, timeout=30)
r.raise_for_status()
peaks = r.json()
print(f"Peaks overlapping TP53 locus: {len(peaks)}")
tfs = set(p.get("name", "").split(":")[0] for p in peaks)
print(f"Unique TFs: {len(tfs)}")
print(f"TF names (first 10): {sorted(tfs)[:10]}")
Core API
Query 1: Region Overlap
Find all TF ChIP-seq peaks overlapping a specified genomic window. Returns peak records including TF name, cell type, coordinates, and score.
import requests, time, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def query_region(chrom, start, end, assembly="hg38", timeout=30):
"""Return all ReMap peaks overlapping [chrom:start-end]."""
r = requests.get(f"{REMAP_API}/peaks/overlap/", params={
"chr": chrom, "start": start, "end": end, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
# Query 100 kb window on chr17 around TP53
peaks = query_region("chr17", 7_670_000, 7_690_000, assembly="hg38")
print(f"Total peaks: {len(peaks)}")
# Parse name field: format is "TF:experiment_id:cell_type"
rows = []
for p in peaks:
parts = p.get("name", "::").split(":")
tf = parts[0] if len(parts) > 0 else ""
exp = parts[1] if len(parts) > 1 else ""
cell = parts[2] if len(parts) > 2 else ""
rows.append({
"chr": p.get("chr", p.get("chrom", "")),
"start": p.get("start", 0),
"end": p.get("end", 0),
"tf_name": tf,
"experiment_id": exp,
"cell_type": cell,
"score": p.get("score", 0),
})
df = pd.DataFrame(rows)
print(f"\nUnique TFs: {df['tf_name'].nunique()}")
print(f"Top TFs by peak count:\n{df['tf_name'].value_counts().head(10).to_string()}")
# Fallback: if API is unavailable, use a locally downloaded BED file
# Download from: https://remap2022.univ-amu.fr/download_page
# e.g., remap2022_all_macs2_hg38_v1_0.bed.gz
import pandas as pd
def query_region_from_bed(bed_file, chrom, start, end):
"""Filter a ReMap BED file for overlapping peaks."""
cols = ["chr", "start", "end", "name", "score", "strand",
"thick_start", "thick_end", "color"]
df = pd.read_csv(bed_file, sep="\t", header=None, names=cols,
compression="infer")
mask = (df["chr"] == chrom) & (df["end"] > start) & (df["start"] < end)
return df[mask].reset_index(drop=True)
# Usage (requires downloaded BED):
# df = query_region_from_bed("remap2022_all_macs2_hg38_v1_0.bed.gz",
# "chr17", 7_670_000, 7_690_000)
Query 2: Gene-Centric Query
Retrieve all TF ChIP-seq peaks near a gene's TSS, providing a promoter-proximal regulatory landscape for the gene.
import requests, time, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def query_gene_peaks(gene_name, assembly="hg38", timeout=30):
"""Return all ReMap peaks near a gene TSS."""
r = requests.get(f"{REMAP_API}/peaks/gene/", params={
"gene": gene_name, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
peaks = query_gene_peaks("MYC", assembly="hg38")
print(f"Peaks near MYC TSS: {len(peaks)}")
rows = []
for p in peaks:
parts = p.get("name", "::").split(":")
rows.append({
"tf_name": parts[0] if parts else "",
"cell_type": parts[2] if len(parts) > 2 else "",
"chr": p.get("chr", p.get("chrom", "")),
"start": p.get("start", 0),
"end": p.get("end", 0),
"score": p.get("score", 0),
"biotype": p.get("biotype", ""),
})
df = pd.DataFrame(rows)
print(f"\nTFs near MYC TSS ({df['tf_name'].nunique()} unique):")
print(df["tf_name"].value_counts().head(10).to_string())
print(f"\nCell types represented: {df['cell_type'].nunique()}")
Query 3: TF Browser
List all TFs available in ReMap for a given genome assembly, with peak and experiment counts.
import requests, time, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def list_tfs(assembly="hg38", timeout=30):
"""Return all TFs in ReMap for the given assembly with statistics."""
r = requests.get(f"{REMAP_API}/tfbs/list/", params={"assembly": assembly}, timeout=timeout)
r.raise_for_status()
return r.json()
def get_database_stats(assembly="hg38", timeout=30):
"""Return overall database statistics for the assembly."""
r = requests.get(f"{REMAP_API}/stats/", params={"assembly": assembly}, timeout=timeout)
r.raise_for_status()
return r.json()
# Database overview
try:
stats = get_database_stats("hg38")
print(f"ReMap 2022 hg38 statistics:")
for k, v in stats.items():
print(f" {k}: {v}")
except Exception as e:
print(f"Stats endpoint unavailable: {e}")
print("ReMap 2022 hg38: 165M peaks, 1,210 TFs, 8,113 datasets (from publication)")
# TF list
try:
tfs = list_tfs("hg38")
df_tfs = pd.DataFrame(tfs)
print(f"\nTFs available (hg38): {len(df_tfs)}")
if "peak_count" in df_tfs.columns:
top = df_tfs.nlargest(10, "peak_count")[["name", "peak_count", "dataset_count"]]
print("Top 10 TFs by peak count:")
print(top.to_string(index=False))
except Exception as e:
print(f"TF list endpoint unavailable: {e}")
print("Use TF name queries directly (Query 4) or download TF-specific BED files.")
Query 4: TF-Specific Peak Query
Retrieve all peaks for a named TF in a given assembly, optionally filtered by cell type.
import requests, time, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def query_tf_peaks(tf_name, assembly="hg38", timeout=30):
"""Return all ChIP-seq peaks for a TF across all cell types."""
r = requests.get(f"{REMAP_API}/tfbs/name/", params={
"name": tf_name, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
peaks = query_tf_peaks("CTCF", assembly="hg38")
print(f"CTCF peaks (all cell types): {len(peaks)}")
# Parse and summarize
rows = []
for p in peaks:
parts = p.get("name", "::").split(":")
rows.append({
"tf_name": parts[0] if parts else "",
"cell_type": parts[2] if len(parts) > 2 else "",
"chr": p.get("chr", p.get("chrom", "")),
"start": p.get("start", 0),
"end": p.get("end", 0),
"score": p.get("score", 0),
"biotype": p.get("biotype", ""),
})
df = pd.DataFrame(rows)
print(f"Cell types: {df['cell_type'].nunique()}")
print(f"Chromosomes: {df['chr'].nunique()}")
print(f"Peak width stats (bp):")
df["width"] = df["end"] - df["start"]
print(f" Median: {df['width'].median():.0f} Mean: {df['width'].mean():.0f} "
f"Min: {df['width'].min()} Max: {df['width'].max()}")
Query 5: Biotype Filter and Regulatory Annotation
Filter peaks by regulatory biotype annotation to identify binding at promoters, enhancers, or intergenic regions.
import requests, pandas as pd, matplotlib.pyplot as plt
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def get_biotypes(assembly="hg38", timeout=30):
"""List all regulatory biotype categories available."""
r = requests.get(f"{REMAP_API}/biotypes/", params={"assembly": assembly}, timeout=timeout)
r.raise_for_status()
return r.json()
def query_tf_by_biotype(tf_name, biotype, assembly="hg38", timeout=30):
"""Retrieve TF peaks filtered by regulatory biotype."""
r = requests.get(f"{REMAP_API}/peaks/biotype/", params={
"name": tf_name, "biotype": biotype, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
# List available biotypes
try:
biotypes = get_biotypes("hg38")
print(f"Available biotypes: {biotypes}")
except Exception:
biotypes = ["promoter", "enhancer", "exon", "intron", "intergenic", "UTR"]
print(f"Using known biotypes: {biotypes}")
# Query CTCF peaks and plot biotype distribution
peaks = query_tf_peaks("CTCF", assembly="hg38") # from Query 4 function above
def query_tf_peaks(tf_name, assembly="hg38", timeout=30):
r = requests.get(f"https://remap2022.univ-amu.fr/api/v1/tfbs/name/",
params={"name": tf_name, "assembly": assembly}, timeout=timeout)
r.raise_for_status()
return r.json()
peaks = query_tf_peaks("CTCF")
rows = [{"biotype": p.get("biotype", "unknown"),
"cell_type": p.get("name", "::").split(":")[2] if len(p.get("name","").split(":")) > 2 else ""}
for p in peaks]
df = pd.DataFrame(rows)
biotype_counts = df["biotype"].value_counts()
biotype_counts = biotype_counts[biotype_counts > 0]
print(f"\nCTCF peak biotype distribution:")
print(biotype_counts.to_string())
# Stacked bar chart across top 5 cell types
top_cells = df["cell_type"].value_counts().head(5).index.tolist()
pivot = (df[df["cell_type"].isin(top_cells)]
.groupby(["cell_type", "biotype"])
.size()
.unstack(fill_value=0))
fig, ax = plt.subplots(figsize=(9, 5))
pivot.plot(kind="bar", stacked=True, ax=ax, colormap="tab10", edgecolor="white")
ax.set_xlabel("Cell Type")
ax.set_ylabel("Peak Count")
ax.set_title("CTCF ChIP-seq Peak Biotype Distribution by Cell Type (ReMap 2022, hg38)")
ax.legend(title="Biotype", bbox_to_anchor=(1.01, 1), loc="upper left", fontsize=8)
plt.tight_layout()
plt.savefig("CTCF_biotype_distribution.png", dpi=150, bbox_inches="tight")
print("Saved CTCF_biotype_distribution.png")
Key Concepts
Peak Name Field Format
The name field in every ReMap peak record encodes three pieces of information as a colon-separated string:
TF_NAME:EXPERIMENT_ID:CELL_TYPE
For example: CTCF:GSE30263.SRX028592:GM12878
Always parse with .split(":") and guard against missing parts. Some records may have fewer than three components if metadata is incomplete.
Assemblies
| Assembly code | Organism | Notes |
|---|---|---|
hg38 |
Homo sapiens (GRCh38) | Primary human assembly in ReMap 2022 |
hg19 |
Homo sapiens (GRCh37) | Legacy human assembly; fewer datasets |
mm10 |
Mus musculus | Primary mouse assembly |
dm6 |
Drosophila melanogaster | Smaller dataset collection |
tair10 |
Arabidopsis thaliana | Plant TF dataset |
BED File Download (API Fallback)
When the REST API is unavailable or for offline bulk analysis, ReMap provides pre-built BED files at https://remap2022.univ-amu.fr/download_page. Key files:
remap2022_all_macs2_hg38_v1_0.bed.gz— all peaks, hg38 (large, ~5 GB)remap2022_{TF}_macs2_hg38_v1_0.bed.gz— per-TF peak filesremap2022_crm_macs2_hg38_v1_0.bed.gz— cis-regulatory modules (merged peaks)
import pandas as pd
def load_remap_bed(bed_path, chrom=None, start=None, end=None):
"""
Load a ReMap BED file with optional region filter.
Columns: chr, start, end, name (TF:exp:cell), score, strand,
thick_start, thick_end, itemRgb
"""
cols = ["chr", "start", "end", "name", "score", "strand",
"thick_start", "thick_end", "itemRgb"]
df = pd.read_csv(bed_path, sep="\t", header=None, names=cols,
compression="infer", low_memory=False)
if chrom:
df = df[df["chr"] == chrom]
if start is not None and end is not None:
df = df[(df["end"] > start) & (df["start"] < end)]
# Parse name field
parts = df["name"].str.split(":", expand=True)
df["tf_name"] = parts[0]
df["experiment_id"] = parts[1] if 1 in parts.columns else ""
df["cell_type"] = parts[2] if 2 in parts.columns else ""
return df.reset_index(drop=True)
# Usage example (offline):
# df = load_remap_bed("remap2022_CTCF_macs2_hg38_v1_0.bed.gz",
# chrom="chr17", start=7_670_000, end=7_690_000)
# print(df.head())
Common Workflows
Workflow 1: TF Co-occupancy Analysis at a Locus
Goal: Identify all TFs with ChIP-seq evidence at a genomic locus and rank by peak count, then export a co-occupancy matrix.
import requests, time, pandas as pd, matplotlib.pyplot as plt
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def query_region(chrom, start, end, assembly="hg38", timeout=30):
r = requests.get(f"{REMAP_API}/peaks/overlap/", params={
"chr": chrom, "start": start, "end": end, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
def parse_peaks(peaks):
rows = []
for p in peaks:
parts = p.get("name", "::").split(":")
rows.append({
"tf_name": parts[0] if len(parts) > 0 else "unknown",
"cell_type": parts[2] if len(parts) > 2 else "unknown",
"chr": p.get("chr", p.get("chrom", "")),
"start": p.get("start", 0),
"end": p.get("end", 0),
"score": p.get("score", 0),
})
return pd.DataFrame(rows)
# BRCA1 promoter region (GRCh38)
peaks = query_region("chr17", 43_044_000, 43_050_000, assembly="hg38")
df = parse_peaks(peaks)
print(f"Peaks at BRCA1 promoter: {len(df)}")
# TF occupancy summary
tf_summary = (df.groupby("tf_name")
.agg(peak_count=("tf_name", "count"),
cell_types=("cell_type", "nunique"),
mean_score=("score", "mean"))
.sort_values("peak_count", ascending=False))
print(f"\nTop TFs at BRCA1 promoter:")
print(tf_summary.head(15).to_string())
tf_summary.to_csv("BRCA1_promoter_TF_occupancy.csv")
# Horizontal bar chart
top = tf_summary.head(20)
fig, ax = plt.subplots(figsize=(8, 6))
ax.barh(top.index[::-1], top["peak_count"][::-1], color="#1f77b4", edgecolor="white")
ax.set_xlabel("Number of ChIP-seq Peaks")
ax.set_title("TF Co-occupancy at BRCA1 Promoter (ReMap 2022, hg38)")
plt.tight_layout()
plt.savefig("BRCA1_promoter_TF_cooccupancy.png", dpi=150, bbox_inches="tight")
print("Saved BRCA1_promoter_TF_cooccupancy.png")
Workflow 2: Gene Regulatory Profile — TSS-Proximal TF Binding Atlas
Goal: For a list of genes, retrieve their promoter-proximal TF binding profiles and compare the TF repertoires across genes.
import requests, time, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def query_gene_peaks(gene_name, assembly="hg38", timeout=30):
try:
r = requests.get(f"{REMAP_API}/peaks/gene/", params={
"gene": gene_name, "assembly": assembly
}, timeout=timeout)
r.raise_for_status()
return r.json()
except Exception as e:
print(f" Warning: {gene_name} failed — {e}")
return []
genes_of_interest = ["MYC", "TP53", "BRCA1", "EGFR", "CDK4"]
gene_tf_profiles = {}
for gene in genes_of_interest:
peaks = query_gene_peaks(gene, assembly="hg38")
if peaks:
tfs = set()
for p in peaks:
parts = p.get("name", "").split(":")
if parts:
tfs.add(parts[0])
gene_tf_profiles[gene] = tfs
print(f"{gene}: {len(peaks)} peaks, {len(tfs)} unique TFs")
time.sleep(0.5)
# Build binary TF presence matrix
all_tfs = sorted(set().union(*gene_tf_profiles.values()))
matrix = pd.DataFrame(
{gene: [1 if tf in gene_tf_profiles.get(gene, set()) else 0 for tf in all_tfs]
for gene in genes_of_interest},
index=all_tfs
)
print(f"\nTF × Gene matrix: {matrix.shape}")
print(f"TFs shared by all genes: {(matrix.sum(axis=1) == len(genes_of_interest)).sum()}")
matrix.to_csv("gene_TF_binding_atlas.csv")
print("Saved gene_TF_binding_atlas.csv")
Workflow 3: Download and Analyze TF Peak BED File
Goal: Download a TF-specific ReMap BED file and analyze its genomic distribution with pandas.
import requests, gzip, io, pandas as pd, time
# ReMap provides per-TF BED files. For large-scale offline analysis:
REMAP_DOWNLOAD_BASE = "https://remap2022.univ-amu.fr/storage/remap2022/hg38/MACS2"
def download_tf_bed(tf_name, assembly="hg38", save_path=None):
"""
Attempt to download TF-specific BED file from ReMap.
Falls back to API region query if download unavailable.
"""
filename = f"remap2022_{tf_name}_macs2_{assembly}_v1_0.bed.gz"
url = f"{REMAP_DOWNLOAD_BASE}/{filename}"
print(f"Attempting download: {url}")
r = requests.get(url, stream=True, timeout=60)
if r.status_code == 200:
if save_path:
with open(save_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Saved: {save_path}")
return save_path
else:
# Read directly into DataFrame
content = b"".join(r.iter_content(chunk_size=8192))
cols = ["chr", "start", "end", "name", "score", "strand",
"thick_start", "thick_end", "itemRgb"]
with gzip.open(io.BytesIO(content), "rt") as gz:
df = pd.read_csv(gz, sep="\t", header=None, names=cols)
return df
else:
print(f"Download returned {r.status_code}; use API query as fallback")
return None
# Analyze a downloaded BED file
def analyze_remap_bed(df):
"""Compute summary statistics for a ReMap peak DataFrame."""
parts = df["name"].str.split(":", expand=True)
df = df.copy()
df["tf_name"] = parts[0]
df["cell_type"] = parts[2] if 2 in parts.columns else "unknown"
df["width"] = df["end"] - df["start"]
print(f"Total peaks: {len(df):,}")
print(f"Unique TFs: {df['tf_name'].nunique()}")
print(f"Unique cell types: {df['cell_type'].nunique()}")
print(f"\nPeak width (bp): median={df['width'].median():.0f} "
f"mean={df['width'].mean():.0f} range=[{df['width'].min()}, {df['width'].max()}]")
print(f"\nChromosome distribution:")
chr_counts = df["chr"].value_counts().head(5)
print(chr_counts.to_string())
return df
# Example usage (requires BED download or substitute with API results):
# df_raw = download_tf_bed("CTCF", save_path="CTCF_hg38.bed.gz")
# if df_raw is not None:
# df_analyzed = analyze_remap_bed(df_raw)
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
chr |
/peaks/overlap/ |
— | chr1–chrX, chrY, chrM |
Chromosome for region query (include chr prefix) |
start |
/peaks/overlap/ |
— | Integer genomic coordinate | Region start (0-based) |
end |
/peaks/overlap/ |
— | Integer genomic coordinate | Region end (exclusive) |
assembly |
All endpoints | — | hg38, hg19, mm10, dm6, tair10 |
Genome assembly for coordinates and peak lookup |
gene |
/peaks/gene/ |
— | HGNC gene symbol (e.g., TP53, MYC) |
Queries peaks near the gene's annotated TSS |
name |
/tfbs/name/ |
— | TF name as in ReMap (e.g., CTCF, SP1) |
TF name is case-sensitive; match ReMap TF naming |
biotype |
/peaks/biotype/ |
— | promoter, enhancer, exon, intron, intergenic, UTR |
Filters peaks by Ensembl regulatory biotype |
timeout |
All requests | 30 | Integer seconds | Increase to 60–120 for large gene/TF queries |
Best Practices
-
Parse the
namefield defensively: TheTF:experiment:cell_typeformat may have fewer than three components for some records. Always guard withparts[n] if len(parts) > n else "". -
Use BED downloads for genome-wide analyses: Querying large genomic regions or all peaks for a TF via the REST API can time out. For whole-genome or per-chromosome scans, download the per-TF or per-assembly BED files from the ReMap download page and filter locally with pandas or bedtools.
-
Cross-reference with JASPAR for sequence evidence: ReMap peaks show where TF binding was detected by ChIP-seq (positional evidence); JASPAR PWMs show what sequence the TF prefers (motif evidence). For robust regulatory annotation, require both: a ReMap peak in the region AND a JASPAR motif hit within the peak.
-
Use
time.sleep(0.5)in batch loops: The ReMap API serves a research community; polite request pacing prevents throttling. -
Validate assembly coordinates: ReMap 2022 hg38 peaks use 0-based half-open BED coordinates (
[start, end)). When comparing with VCF or 1-based GFF coordinates, add 1 tostart.
Common Recipes
Recipe: Find TFs Binding at a GWAS SNP
When to use: Prioritize functional candidates from a GWAS hit by identifying which TFs bind at the SNP location.
import requests
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def tfs_at_snp(chrom, pos, window=500, assembly="hg38"):
"""Find TFs with ChIP-seq peaks overlapping a SNP position ± window bp."""
r = requests.get(f"{REMAP_API}/peaks/overlap/", params={
"chr": chrom, "start": pos - window, "end": pos + window,
"assembly": assembly
}, timeout=30)
r.raise_for_status()
peaks = r.json()
tfs = {}
for p in peaks:
parts = p.get("name", "::").split(":")
tf = parts[0] if parts else "unknown"
tfs[tf] = tfs.get(tf, 0) + 1
return dict(sorted(tfs.items(), key=lambda x: -x[1]))
# Example: rs2736100 (TERT locus, chr5:1,286,401)
snp_tfs = tfs_at_snp("chr5", 1_286_401, window=500, assembly="hg38")
print(f"TFs at TERT GWAS SNP (±500 bp): {len(snp_tfs)}")
for tf, count in list(snp_tfs.items())[:10]:
print(f" {tf:<20s} {count:3d} peaks")
Recipe: Compare TF Binding Profiles of Two Genes
When to use: Check whether two co-regulated genes share the same upstream TF binding landscape.
import requests, time
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def get_gene_tfs(gene, assembly="hg38"):
try:
r = requests.get(f"{REMAP_API}/peaks/gene/", params={"gene": gene, "assembly": assembly}, timeout=30)
r.raise_for_status()
peaks = r.json()
return set(p.get("name", "").split(":")[0] for p in peaks if p.get("name", ""))
except Exception as e:
print(f"Warning: {gene} → {e}")
return set()
gene_a, gene_b = "MYC", "MYCN"
tfs_a = get_gene_tfs(gene_a)
time.sleep(0.5)
tfs_b = get_gene_tfs(gene_b)
shared = tfs_a & tfs_b
only_a = tfs_a - tfs_b
only_b = tfs_b - tfs_a
print(f"{gene_a} TFs: {len(tfs_a)} | {gene_b} TFs: {len(tfs_b)}")
print(f"Shared: {len(shared)} | {gene_a}-only: {len(only_a)} | {gene_b}-only: {len(only_b)}")
print(f"\nShared TFs (first 15): {sorted(shared)[:15]}")
print(f"\n{gene_a}-only (first 10): {sorted(only_a)[:10]}")
Recipe: Export Region Peaks as BED
When to use: Export ReMap query results to BED format for downstream bedtools intersection or IGV visualization.
import requests, pandas as pd
REMAP_API = "https://remap2022.univ-amu.fr/api/v1"
def export_region_as_bed(chrom, start, end, outfile, assembly="hg38"):
"""Query ReMap region and save as 6-column BED file."""
r = requests.get(f"{REMAP_API}/peaks/overlap/", params={
"chr": chrom, "start": start, "end": end, "assembly": assembly
}, timeout=30)
r.raise_for_status()
peaks = r.json()
rows = [{
"chr": p.get("chr", p.get("chrom", "")),
"start": p.get("start", 0),
"end": p.get("end", 0),
"name": p.get("name", "."),
"score": p.get("score", 0),
"strand": p.get("strand", "."),
} for p in peaks]
df = pd.DataFrame(rows)
df = df.sort_values(["chr", "start"])
df.to_csv(outfile, sep="\t", header=False, index=False)
print(f"Saved {len(df)} peaks to {outfile}")
return df
export_region_as_bed("chr17", 7_670_000, 7_690_000, "TP53_locus_remap.bed")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found from API |
Endpoint path changed or unavailable | Check https://remap2022.univ-amu.fr/api/ for current endpoint list; fall back to BED download |
Empty JSON list [] from region query |
No peaks in region, or assembly mismatch | Verify coordinates are on the correct assembly; try a wider window (±10 kb) |
| Gene query returns empty | Gene symbol not recognized by ReMap | Try Ensembl gene symbol; some aliases are not mapped — verify with HGNC |
requests.exceptions.Timeout |
Large region or slow server | Increase timeout=60; for regions >1 Mb use BED file download instead |
name field has only one component |
Incomplete metadata in ReMap for that experiment | Guard with parts[n] if len(parts) > n else "unknown" |
| BED download 404 | Per-TF files use exact ReMap TF naming | Check TF name case and spelling at https://remap2022.univ-amu.fr/download_page |
| Duplicate peaks for same TF | Multiple experiments per TF in a cell type | Group by tf_name and count unique experiments; deduplicate peaks with bedtools merge |
Related Skills
jaspar-database— TF binding motif matrices (PWMs/PFMs); use alongside ReMap peak evidence for sequence-level validationencode-database— ENCODE regulatory tracks including TF ChIP-seq, DNase-seq, and ATAC-seq; partially overlaps with ReMaphomer-motif-analysis— de novo motif discovery in ChIP-seq peak sets from ReMap or MACS3macs3-peak-calling— call peaks from raw ChIP-seq BAM files; ReMap provides pre-called peaks from the same approachregulomedb-database— regulatory variant scoring that integrates TF binding evidence similar to ReMap
References
- ReMap 2022 API documentation — REST API endpoint reference and interactive explorer
- Hammal et al., Nucleic Acids Research 2022 — ReMap 2022 paper describing the 2022 release (165M peaks, 1,210 TFs)
- ReMap portal and download page — web browser, download page for BED files and cis-regulatory modules
- Chèneby et al., Nucleic Acids Research 2020 — ReMap 2020 paper describing the reprocessing pipeline and quality control methodology
skills/genomics-bioinformatics/databases/ucsc-genome-browser/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill ucsc-genome-browser -g -y
SKILL.md
Frontmatter
{
"name": "ucsc-genome-browser",
"license": "Apache-2.0",
"description": "Query UCSC Genome Browser REST API for DNA sequences, tracks, gene models, and conservation across 100+ assemblies. Retrieve sequence by region, list\/fetch BED\/bigWig tracks, chromosome sizes, RefSeq\/GENCODE gene structures, PhyloP\/PhastCons scores. Use for UCSC annotations; Ensembl REST API for Ensembl gene IDs and VEP variant annotation."
}
UCSC Genome Browser
Overview
The UCSC Genome Browser REST API at https://api.genome.ucsc.edu/ provides programmatic access to genome sequences, annotation tracks, and hub data for 100+ assemblies including hg38, mm39, and dm6. The API is free, requires no authentication, and returns JSON. Use it with the requests library to fetch DNA sequences for genomic regions, retrieve track data (genes, repeats, conservation), list available tracks, and query chromosome sizes for genome-scale coordinate arithmetic.
When to Use
- Fetching the reference DNA sequence for any genomic region (e.g., promoter, exon, CRISPR target) across human, mouse, or other assemblies
- Retrieving RefSeq or GENCODE gene structure (exon coordinates, CDS boundaries, strand) for a locus of interest
- Looking up PhyloP or PhastCons conservation scores to assess evolutionary constraint at a variant site
- Listing and querying any of UCSC's 1000+ annotation tracks (repeats, regulatory elements, conservation) for a region
- Getting chromosome sizes for a genome assembly to set up bedtools, pysam, or coverage pipelines
- Accessing public UCSC track hubs (e.g., ENCODE, Roadmap Epigenomics) without downloading data locally
- Use
ensembl-databaseinstead when you need Ensembl stable IDs, VEP variant annotation, or cross-species comparative genomics via the Ensembl REST API - For bulk local queries across millions of regions, use
bedtools-genomic-intervalswith pre-downloaded UCSC annotation files
Prerequisites
- Python packages:
requests,matplotlib(for visualization) - Data requirements: genomic coordinates (chrom, start, end in 0-based half-open BED format), genome assembly name (e.g.,
hg38,mm39) - Environment: internet connection; no authentication required
- Rate limits: no official published limit; add 0.5s delays for batch requests (>100 queries)
pip install requests matplotlib
Quick Start
import requests
BASE = "https://api.genome.ucsc.edu"
def get_sequence(genome, chrom, start, end):
"""Fetch DNA sequence for a genomic region (0-based, half-open)."""
r = requests.get(f"{BASE}/getData/sequence",
params={"genome": genome, "chrom": chrom,
"start": start, "end": end})
r.raise_for_status()
return r.json()["dna"]
# Fetch 1 kb around the BRCA1 TSS on hg38
seq = get_sequence("hg38", "chr17", 43044294, 43045294)
print(f"Length: {len(seq)} bp")
print(f"Sequence: {seq[:60]}...")
# Length: 1000 bp
# Sequence: ATGATTGGTGGTTACATGCACAGTTGCTCTGGGAAGTTTCTTCTTCAGTTGAGAAAAGGT...
Core API
Query 1: Sequence Retrieval
Fetch the reference DNA sequence for any genomic region using the getData/sequence endpoint. Coordinates are 0-based, half-open (BED format).
import requests
BASE = "https://api.genome.ucsc.edu"
def get_sequence(genome, chrom, start, end):
"""Return DNA sequence string for the given region."""
r = requests.get(f"{BASE}/getData/sequence",
params={"genome": genome, "chrom": chrom,
"start": start, "end": end})
r.raise_for_status()
data = r.json()
return data["dna"]
# TP53 exon 4 region (hg38)
seq = get_sequence("hg38", "chr17", 7676520, 7676620)
print(f"Region: chr17:7,676,520-7,676,620 ({len(seq)} bp)")
print(f"Sequence: {seq}")
# Reverse-complement for minus-strand genes
def revcomp(seq):
comp = str.maketrans("ACGTacgt", "TGCAtgca")
return seq.translate(comp)[::-1]
# BRCA2 on minus strand (hg38)
seq_fwd = get_sequence("hg38", "chr13", 32315086, 32315186)
seq_rc = revcomp(seq_fwd)
print(f"Forward: {seq_fwd[:30]}...")
print(f"RevComp: {seq_rc[:30]}...")
Query 2: Track Data Query
Retrieve annotation data (BED records) from any UCSC track for a genomic region.
import requests
BASE = "https://api.genome.ucsc.edu"
def get_track_data(genome, track, chrom, start, end):
"""Fetch annotation records from a UCSC track for a region."""
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": track,
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
data = r.json()
# Track data is under the key matching the track name
return data.get(track, data.get("data", []))
# Fetch RepeatMasker annotations in the MYC locus (hg38)
repeats = get_track_data("hg38", "rmsk", "chr8", 127_735_434, 127_742_951)
print(f"Repeat elements in MYC locus: {len(repeats)}")
for r in repeats[:3]:
print(f" {r.get('repName', r.get('name'))} | {r['chromStart']}-{r['chromEnd']}")
# Fetch CpG islands near a promoter
cpg_islands = get_track_data("hg38", "cpgIslandExt", "chr17", 43_044_000, 43_050_000)
print(f"CpG islands found: {len(cpg_islands)}")
for island in cpg_islands:
print(f" {island['name']}: {island['chromStart']}-{island['chromEnd']}, "
f"obsExp={island.get('obsExp', 'n/a')}")
Query 3: Track List
List all available annotation tracks for a genome assembly to discover what data is available.
import requests
BASE = "https://api.genome.ucsc.edu"
def list_tracks(genome):
"""Return a dict of {track_name: track_metadata} for a genome assembly."""
r = requests.get(f"{BASE}/list/tracks", params={"genome": genome})
r.raise_for_status()
return r.json().get("tracks", {})
tracks = list_tracks("hg38")
print(f"Total tracks in hg38: {len(tracks)}")
# Find conservation-related tracks
conserv = {k: v for k, v in tracks.items() if "conserv" in k.lower() or "phylop" in k.lower()}
for name, meta in list(conserv.items())[:5]:
print(f" {name}: {meta.get('shortLabel', '')}")
Query 4: Chromosome Sizes
Get the length of every chromosome (or scaffold) for a genome assembly.
import requests
BASE = "https://api.genome.ucsc.edu"
def get_chrom_sizes(genome):
"""Return {chrom: size_in_bp} for a genome assembly."""
r = requests.get(f"{BASE}/list/chromosomes", params={"genome": genome})
r.raise_for_status()
return r.json().get("chromosomeSizes", {})
sizes = get_chrom_sizes("hg38")
print(f"hg38 chromosome count: {len(sizes)}")
# Show canonical autosomes + sex chromosomes
canonical = {c: sizes[c] for c in sorted(sizes) if c in
[f"chr{i}" for i in range(1, 23)] + ["chrX", "chrY", "chrM"]}
for chrom, length in sorted(canonical.items(),
key=lambda x: int(x[0].replace("chr", "").replace("X", "23").replace("Y", "24").replace("M", "25"))):
print(f" {chrom}: {length:,} bp")
Query 5: Gene Annotation
Query RefSeq gene models (exon coordinates, CDS, strand) for a genomic region.
import requests
BASE = "https://api.genome.ucsc.edu"
def get_refgene(genome, chrom, start, end):
"""Retrieve RefSeq gene annotations for a region."""
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": "refGene",
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
return r.json().get("refGene", [])
# Query EGFR gene region (hg38)
genes = get_refgene("hg38", "chr7", 55_019_017, 55_211_628)
for g in genes:
exon_count = g.get("exonCount", 0)
print(f" {g['name2']} ({g['name']}) | {g['strand']} | "
f"tx: {g['txStart']}-{g['txEnd']} | exons: {exon_count}")
# Parse exon intervals from a refGene record
def parse_exons(gene_record):
"""Return list of (exon_start, exon_end) from a refGene record."""
starts = [int(s) for s in gene_record["exonStarts"].strip(",").split(",") if s]
ends = [int(e) for e in gene_record["exonEnds"].strip(",").split(",") if e]
return list(zip(starts, ends))
genes = get_refgene("hg38", "chr7", 55_019_017, 55_211_628)
if genes:
g = genes[0]
exons = parse_exons(g)
print(f"{g['name2']}: {len(exons)} exons")
for i, (s, e) in enumerate(exons[:4], 1):
print(f" Exon {i}: {s}-{e} ({e-s} bp)")
Query 6: Conservation Scores
Fetch per-base PhyloP or PhastCons conservation scores for a genomic region.
import requests
BASE = "https://api.genome.ucsc.edu"
def get_conservation(genome, track, chrom, start, end):
"""Retrieve per-base conservation scores from a bigWig track."""
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": track,
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
data = r.json()
# bigWig tracks return a list of {start, end, value} intervals
return data.get(track, [])
# PhyloP 100-way conservation at TP53 mutation hotspot (hg38)
# chr17:7,676,594 = codon 248 (R248W/Q common hotspot)
scores = get_conservation("hg38", "phyloP100way", "chr17", 7_676_580, 7_676_610)
print(f"PhyloP 100way scores ({len(scores)} intervals):")
for s in scores[:5]:
print(f" chr17:{s['start']}-{s['end']}: phyloP = {s['value']:.3f}")
# Positive scores = conserved; negative = fast-evolving
Query 7: Hub Access
Access public UCSC track hubs and list their available assemblies and tracks.
import requests
BASE = "https://api.genome.ucsc.edu"
def list_ucsc_genomes():
"""Return all UCSC-hosted genome assemblies."""
r = requests.get(f"{BASE}/list/ucscGenomes")
r.raise_for_status()
return r.json().get("ucscGenomes", {})
genomes = list_ucsc_genomes()
print(f"Total UCSC genome assemblies: {len(genomes)}")
# Find all human assemblies
human = {k: v for k, v in genomes.items() if "Homo sapiens" in v.get("scientificName", "")}
for name, meta in sorted(human.items()):
print(f" {name}: {meta.get('description', '')}")
Key Concepts
0-Based vs. 1-Based Coordinates
The UCSC REST API uses 0-based, half-open intervals (BED format): start is inclusive, end is exclusive. This matches BED files and Python slicing. The UCSC Genome Browser web interface displays 1-based positions. To convert: API start = browser_start - 1, API end = browser_end.
# Browser position: chr17:7,676,521-7,676,620 (1-based, closed)
# API query (0-based, half-open):
start_api = 7_676_520 # browser_start - 1
end_api = 7_676_620 # browser_end unchanged
seq = get_sequence("hg38", "chr17", start_api, end_api)
print(f"Fetched {len(seq)} bp (expected 100)")
Track Data Response Format
Track data returned from /getData/track is keyed by track name. BED-like tracks return a list of dicts with chrom, chromStart, chromEnd, name, score, strand. bigWig tracks (conservation, signal) return {start, end, value} intervals. Always check the actual key in the response JSON, which matches the track parameter name.
Common Workflows
Workflow 1: Extract Promoter Sequences for a Gene List
Goal: Retrieve 2 kb upstream of the TSS for each gene in a list, for motif analysis or primer design.
import requests
import time
BASE = "https://api.genome.ucsc.edu"
GENOME = "hg38"
PROMOTER_UP = 2000 # bp upstream of TSS
def get_refgene(genome, chrom, start, end):
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": "refGene",
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
return r.json().get("refGene", [])
def get_sequence(genome, chrom, start, end):
r = requests.get(f"{BASE}/getData/sequence",
params={"genome": genome, "chrom": chrom,
"start": start, "end": end})
r.raise_for_status()
return r.json()["dna"]
def revcomp(seq):
comp = str.maketrans("ACGTacgt", "TGCAtgca")
return seq.translate(comp)[::-1]
# Genes of interest: query a known locus for each
gene_loci = {
"BRCA1": ("chr17", 43_044_294, 43_125_482),
"TP53": ("chr17", 7_661_779, 7_687_538),
"EGFR": ("chr7", 55_019_017, 55_211_628),
}
results = {}
for gene, (chrom, locus_start, locus_end) in gene_loci.items():
records = get_refgene(GENOME, chrom, locus_start, locus_end)
# Pick the longest transcript
records = [r for r in records if r.get("name2") == gene]
if not records:
print(f" {gene}: not found")
continue
g = max(records, key=lambda x: x["txEnd"] - x["txStart"])
if g["strand"] == "+":
prom_start = max(0, g["txStart"] - PROMOTER_UP)
prom_end = g["txStart"]
else:
prom_start = g["txEnd"]
prom_end = g["txEnd"] + PROMOTER_UP
seq = get_sequence(GENOME, chrom, prom_start, prom_end)
if g["strand"] == "-":
seq = revcomp(seq)
results[gene] = {"chrom": chrom, "start": prom_start, "end": prom_end,
"strand": g["strand"], "seq": seq}
print(f" {gene}: {chrom}:{prom_start}-{prom_end} | strand={g['strand']} | {len(seq)} bp")
time.sleep(0.5)
# Write FASTA
with open("promoters.fa", "w") as fh:
for gene, d in results.items():
fh.write(f">{gene} {d['chrom']}:{d['start']}-{d['end']}({d['strand']})\n")
fh.write(d["seq"] + "\n")
print(f"\nSaved {len(results)} promoter sequences → promoters.fa")
Workflow 2: Visualize Gene Structure from refGene Track
Goal: Draw an exon-intron diagram for a gene using matplotlib from refGene track data.
import requests
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
BASE = "https://api.genome.ucsc.edu"
def get_refgene(genome, chrom, start, end):
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": "refGene",
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
return r.json().get("refGene", [])
def parse_exons(rec):
starts = [int(s) for s in rec["exonStarts"].strip(",").split(",") if s]
ends = [int(e) for e in rec["exonEnds"].strip(",").split(",") if e]
return list(zip(starts, ends))
# Fetch BRCA1 transcripts (hg38)
genes = get_refgene("hg38", "chr17", 43_044_294, 43_125_482)
brca1 = [g for g in genes if g.get("name2") == "BRCA1"]
print(f"BRCA1 transcripts: {len(brca1)}")
# Plot the canonical transcript (longest)
g = max(brca1, key=lambda x: x["txEnd"] - x["txStart"])
exons = parse_exons(g)
tx_start, tx_end = g["txStart"], g["txEnd"]
cds_start, cds_end = g["cdsStart"], g["cdsEnd"]
fig, ax = plt.subplots(figsize=(12, 2.5))
ax.set_xlim(tx_start - 500, tx_end + 500)
ax.set_ylim(-0.5, 1.5)
# Intron line
ax.hlines(0.5, tx_start, tx_end, color="#555", lw=1.5, zorder=1)
# Exon boxes
for exon_s, exon_e in exons:
# UTR portion (thin) vs CDS (thick)
cds_s = max(exon_s, cds_start)
cds_e = min(exon_e, cds_end)
# Full exon box (UTR height)
ax.add_patch(mpatches.FancyBboxPatch(
(exon_s, 0.25), exon_e - exon_s, 0.5,
boxstyle="square,pad=0", fc="#a8c4e0", ec="#2c6fad", lw=0.8, zorder=2))
# CDS box (taller)
if cds_s < cds_e:
ax.add_patch(mpatches.FancyBboxPatch(
(cds_s, 0.15), cds_e - cds_s, 0.7,
boxstyle="square,pad=0", fc="#2c6fad", ec="#1a4a7a", lw=0.8, zorder=3))
strand_arrow = "→" if g["strand"] == "+" else "←"
ax.set_title(f"{g['name2']} ({g['name']}) {strand_arrow} — hg38 {g['chrom']}:"
f"{tx_start:,}-{tx_end:,} | {g['exonCount']} exons", fontsize=11)
ax.set_xlabel("Genomic position (bp)")
ax.set_yticks([])
plt.tight_layout()
plt.savefig("brca1_gene_structure.png", dpi=150, bbox_inches="tight")
print("Saved: brca1_gene_structure.png")
plt.show()
Workflow 3: Batch Conservation Score Lookup
Goal: Retrieve mean PhyloP conservation for a list of variants or regions.
import requests
import time
import pandas as pd
BASE = "https://api.genome.ucsc.edu"
def get_conservation(genome, track, chrom, start, end):
r = requests.get(f"{BASE}/getData/track",
params={"genome": genome, "track": track,
"chrom": chrom, "start": start, "end": end})
r.raise_for_status()
return r.json().get(track, [])
# Variants to score (1-based positions → convert to 0-based)
variants = [
{"id": "rs28897672", "chrom": "chr17", "pos": 7_676_594}, # TP53 R248
{"id": "rs80357906", "chrom": "chr17", "pos": 43_094_692}, # BRCA1
{"id": "rs1042522", "chrom": "chr17", "pos": 7_676_147}, # TP53 R72P (common)
]
results = []
for v in variants:
# Query ±5 bp window around each variant
scores = get_conservation("hg38", "phyloP100way",
v["chrom"], v["pos"] - 6, v["pos"] + 5)
values = [s["value"] for s in scores]
mean_score = sum(values) / len(values) if values else float("nan")
results.append({**v, "phyloP100way_mean": round(mean_score, 3),
"n_intervals": len(scores)})
print(f" {v['id']}: mean phyloP = {mean_score:.3f}")
time.sleep(0.5)
df = pd.DataFrame(results)
df.to_csv("variant_conservation.csv", index=False)
print(f"\nSaved → variant_conservation.csv\n{df.to_string(index=False)}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
genome |
All endpoints | — | hg38, mm39, dm6, any UCSC assembly |
Selects the genome assembly |
chrom |
Sequence, Track | — | chr1–chrY, chrM |
Chromosome name (UCSC chr-prefix convention) |
start |
Sequence, Track | — | 0–chrom_size | Region start (0-based, inclusive) |
end |
Sequence, Track | — | 1–chrom_size | Region end (0-based, exclusive) |
track |
getData/track |
— | Any track name from list/tracks |
Annotation track to retrieve |
hubUrl |
Hub endpoints | — | URL to hub.txt | Access a public or private track hub |
Best Practices
-
Use 0-based coordinates throughout: The API is BED-format; always subtract 1 from 1-based browser positions. Mixing conventions causes silent off-by-one errors.
-
Add delays for batch queries: There is no enforced rate limit, but UCSC's servers are shared resources. Insert
time.sleep(0.5)between requests when processing >50 regions.import time for region in regions: seq = get_sequence("hg38", region["chrom"], region["start"], region["end"]) time.sleep(0.5) -
Discover track names before querying: Track names (e.g.,
refGene,cpgIslandExt) are not always obvious. Calllist/tracksfirst to find the correct internal name, then query/getData/track. -
Handle missing track keys in response: The JSON key holding track records matches the
trackparameter name. Always use.get(track, [])to avoidKeyErrorwhen a track returns no data in a region. -
Download chromosome sizes once and cache: For pipelines that need sizes across many regions, call
list/chromosomesonce and store the result in a dict rather than re-requesting for each query.
Common Recipes
Recipe: Fetch Assembly List and Filter by Organism
When to use: Discover available genome assemblies for a specific species.
import requests
r = requests.get("https://api.genome.ucsc.edu/list/ucscGenomes")
r.raise_for_status()
genomes = r.json()["ucscGenomes"]
# All mouse assemblies
mouse = {k: v for k, v in genomes.items()
if "Mus musculus" in v.get("scientificName", "")}
for name, meta in sorted(mouse.items()):
print(f" {name}: {meta.get('description', '')}")
Recipe: Write BED File from Track Query
When to use: Save UCSC track annotations as a BED file for downstream bedtools or IGV analysis.
import requests
BASE = "https://api.genome.ucsc.edu"
r = requests.get(f"{BASE}/getData/track",
params={"genome": "hg38", "track": "refGene",
"chrom": "chr7", "start": 55_019_017, "end": 55_211_628})
r.raise_for_status()
records = r.json().get("refGene", [])
with open("egfr_refgene.bed", "w") as fh:
for rec in records:
fh.write(f"{rec['chrom']}\t{rec['txStart']}\t{rec['txEnd']}\t"
f"{rec.get('name2', rec['name'])}\t0\t{rec['strand']}\n")
print(f"Wrote {len(records)} gene records → egfr_refgene.bed")
Recipe: GC Content for a Sequence
When to use: Compute GC content of a promoter or exon after fetching its sequence.
import requests
seq = requests.get(
"https://api.genome.ucsc.edu/getData/sequence",
params={"genome": "hg38", "chrom": "chr17",
"start": 43_044_294, "end": 43_046_294}
).json()["dna"].upper()
gc = (seq.count("G") + seq.count("C")) / len(seq) * 100
print(f"Region length: {len(seq)} bp | GC content: {gc:.1f}%")
Recipe: Quick Coordinate Validation
When to use: Confirm coordinates are within chromosome bounds before submitting a batch.
import requests
sizes = requests.get("https://api.genome.ucsc.edu/list/chromosomes",
params={"genome": "hg38"}).json()["chromosomeSizes"]
def validate(chrom, start, end):
if chrom not in sizes:
return f"ERROR: {chrom} not in hg38"
if start < 0 or end > sizes[chrom] or start >= end:
return f"ERROR: {chrom}:{start}-{end} out of bounds (chrom size={sizes[chrom]})"
return "OK"
print(validate("chr17", 43_044_294, 43_125_482)) # OK
print(validate("chr17", -1, 100)) # ERROR
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 400 on sequence endpoint |
Coordinates out of chromosome bounds or start >= end |
Check chromosome size with list/chromosomes; swap start/end if reversed |
| Track query returns empty list | No features in the region for that track | Confirm track exists with list/tracks; widen the query window |
KeyError on track response |
Response key differs from track parameter | Use .get(track, data.get("data", [])) to handle variant key names |
ConnectionError or timeout |
Network issue or server load | Retry with requests.Session() and set timeout=30; add time.sleep(1) |
| Sequence is all lowercase | Softmasked regions (RepeatMasker) | Call .upper() on returned sequence if case is irrelevant to your use |
| Conservation track returns no data | Track not available for that assembly | Check list/tracks for the assembly; phyloP100way is hg38-only; use phyloP60way for mm10 |
| Wrong gene retrieved | Multiple transcripts at locus | Filter by name2 (gene symbol) and select the longest transcript |
Related Skills
ensembl-database— Ensembl REST API for gene/transcript annotations with stable Ensembl IDs, VEP variant effects, and cross-species homologs; preferred for Ensembl-centric workflowsencode-database— ENCODE portal for regulatory element datasets (ChIP-seq peaks, ATAC-seq) that feed into UCSC track hubsbedtools-genomic-intervals— Perform intersection, coverage, and arithmetic on BED files downloaded from UCSCregulomedb-database— RegulomeDB for regulatory variant scoring, which overlaps UCSC regulatory tracks
References
- UCSC REST API documentation — full endpoint reference, parameters, and response formats
- UCSC API base URL — interactive endpoint explorer
- Kent WJ et al. (2002) Genome Res 12:996–1006 — original UCSC Genome Browser paper
- UCSC Track Database — Table Browser to explore track names and schemas
skills/genomics-bioinformatics/etetoolkit/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill etetoolkit -g -y
SKILL.md
Frontmatter
{
"name": "etetoolkit",
"license": "GPL-3.0",
"description": "ETE Toolkit (ETE3): Python phylogenetic tree analysis and visualization. Parse Newick\/NHX\/PhyloXML, traverse\/annotate nodes, render figures with TreeStyle\/NodeStyle, integrate NCBI taxonomy, run PhyloTree comparative genomics. Use for species trees, gene family evolution, annotated tree figures."
}
ETE Toolkit: Phylogenetic Tree Analysis and Visualization
Overview
ETE Toolkit (ETE3) is a Python framework for phylogenetic tree exploration, manipulation, and publication-quality visualization. It supports reading and writing Newick, NHX, PhyloXML, and NeXML formats, rich node annotation, programmatic tree traversal, NCBI taxonomy integration, and a flexible rendering engine for customizable tree figures. ETE3 is widely used in comparative genomics, phylogenomics, and evolutionary biology workflows.
When to Use
- Parse phylogenetic trees from Newick, NHX, PhyloXML, or NeXML files and programmatically traverse or modify topology
- Annotate tree nodes with metadata (bootstrap values, gene names, taxonomic ranks, expression data) for visualization or downstream analysis
- Render publication-quality tree figures with custom node shapes, colors, branch widths, and face decorations using TreeStyle and NodeStyle
- Map NCBI taxonomy IDs to lineage information, validate species names, or build taxonomy-aware trees
- Compute evolutionary statistics: branch lengths, tree distances (Robinson-Foulds), LCA queries, monophyly tests
- Build PhyloTree objects for comparative genomics — gene duplication/speciation event annotation, orthologs/paralogs inference
- Prune, reroot, or ultrametricize trees programmatically before passing to downstream tools (BEAST, IQ-TREE, etc.)
- For sequence alignment prior to tree building, use
biopython-molecular-biologyinstead
Prerequisites
- Python packages:
ete3,numpy,PyQt5(for interactive rendering),lxml(for PhyloXML) - Data requirements: Newick string or tree file; NCBI taxonomy database (downloaded on first use for NCBI module)
- Environment: Python 3.6+; PyQt5 required for
TreeStylerendering and interactive GUI; headless rendering requiresxvfb
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v pythonfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run pythonrather than barepython.
pip install ete3 numpy lxml PyQt5
# For headless rendering on Linux servers:
# apt-get install xvfb python3-pyqt5
Quick Start
from ete3 import Tree
# Load a Newick tree and inspect basic properties
t = Tree("((A:0.1,B:0.2)AB:0.3,(C:0.4,D:0.1)CD:0.2)root;")
print(f"Number of leaves: {len(t.get_leaves())}")
print(f"Leaf names: {t.get_leaf_names()}")
print(f"Tree depth: {t.get_farthest_leaf()[1]:.3f}")
# Number of leaves: 4
# Leaf names: ['A', 'B', 'C', 'D']
# Tree depth: 0.700
t.show() # Opens interactive viewer (requires PyQt5)
Core API
Module 1: Tree I/O (Tree parsing and serialization)
Load trees from strings or files; write in various formats.
from ete3 import Tree, PhyloTree
# Parse Newick string (format 1 = standard Newick with support values)
t = Tree("((A:0.1,B:0.2)90:0.3,(C:0.4,D:0.1)85:0.2)root;", format=1)
print(f"Root children: {[n.name for n in t.children]}")
# Load from file
t_file = Tree("my_tree.nwk")
# Write Newick with internal names and supports
nwk_str = t.write(format=1)
print(f"Newick: {nwk_str}")
# Write to file
t.write(outfile="output_tree.nwk", format=0)
print("Saved output_tree.nwk")
from ete3 import PhyloTree
# Load PhyloXML tree (retains sequence annotations)
# pt = PhyloTree("my_phylo.xml", parser="phyloxml")
# Load NHX format (extended Newick with key=value annotations)
nhx = Tree("((A[&&NHX:S=human:D=Y],B[&&NHX:S=mouse:D=N]))")
for leaf in nhx.get_leaves():
print(f"{leaf.name}: species={leaf.S}, duplication={leaf.D}")
# A: species=human, duplication=Y
# B: species=mouse, duplication=N
Module 2: Tree Traversal and Search
Navigate nodes using pre-order, post-order, or breadth-first traversal; search by name or attribute.
from ete3 import Tree
t = Tree("((Homo_sapiens:0.1,Pan_troglodytes:0.05)Hominidae:0.2,(Mus_musculus:0.3,Rattus_norvegicus:0.25)Muridae:0.4)Euarchontoglires;")
# Iterate all nodes (preorder by default)
for node in t.traverse("preorder"):
depth = node.get_distance(t)
print(f"{'leaf' if node.is_leaf() else 'internal'}: {node.name or 'unnamed'} depth={depth:.3f}")
# Search by name
human = t.search_nodes(name="Homo_sapiens")[0]
print(f"Human branch length: {human.dist:.3f}")
print(f"Ancestors: {[a.name for a in human.get_ancestors()]}")
from ete3 import Tree
t = Tree("((Homo_sapiens:0.1,Pan_troglodytes:0.05)Hominidae:0.2,(Mus_musculus:0.3,Rattus_norvegicus:0.25)Muridae:0.4)Euarchontoglires;")
# Lowest common ancestor (LCA) query
human = t & "Homo_sapiens" # shorthand for search_nodes(name=...)[0]
mouse = t & "Mus_musculus"
lca = t.get_common_ancestor(human, mouse)
print(f"LCA of human and mouse: {lca.name}")
# LCA of human and mouse: Euarchontoglires
# Check monophyly
is_mono, mono_type, broken = t.check_monophyly(
values=["Homo_sapiens", "Pan_troglodytes"], target_attr="name"
)
print(f"Hominids monophyletic: {is_mono}, type: {mono_type}")
# Hominids monophyletic: True, type: monophyletic
Module 3: Node Annotation
Add custom attributes to nodes for metadata-driven visualization and analysis.
from ete3 import Tree
t = Tree("((Homo_sapiens,Pan_troglodytes)Hominidae,(Mus_musculus,Rattus_norvegicus)Muridae)Euarchontoglires;")
# Annotate leaves with arbitrary metadata
metadata = {
"Homo_sapiens": {"genome_size_gb": 3.2, "ploidy": 2, "color": "blue"},
"Pan_troglodytes": {"genome_size_gb": 3.1, "ploidy": 2, "color": "green"},
"Mus_musculus": {"genome_size_gb": 2.7, "ploidy": 2, "color": "orange"},
"Rattus_norvegicus": {"genome_size_gb": 2.9, "ploidy": 2, "color": "red"},
}
for leaf in t.get_leaves():
for attr, val in metadata[leaf.name].items():
setattr(leaf, attr, val)
# Access annotations
for leaf in t.get_leaves():
print(f"{leaf.name}: {leaf.genome_size_gb} Gb, {leaf.color}")
from ete3 import Tree
import pandas as pd
t = Tree("((A,B)AB,(C,D)CD)root;")
# Load annotations from a DataFrame and apply to tree
df = pd.DataFrame({
"name": ["A", "B", "C", "D"],
"value": [1.2, 3.4, 0.8, 2.1],
"group": ["x", "x", "y", "y"],
})
name_to_row = df.set_index("name").to_dict(orient="index")
for leaf in t.get_leaves():
if leaf.name in name_to_row:
leaf.add_features(**name_to_row[leaf.name])
print(f"{leaf.name}: value={leaf.value}, group={leaf.group}")
Module 4: Tree Manipulation
Prune, reroot, ultrametricize, and compute distances.
from ete3 import Tree
t = Tree("((A:0.1,B:0.2)AB:0.3,(C:0.4,D:0.1,(E:0.2,F:0.3)EF:0.1)CD:0.2)root;")
print(f"Original leaves: {t.get_leaf_names()}")
# Prune to a subset of taxa
t.prune(["A", "C", "E"], preserve_branch_length=True)
print(f"Pruned leaves: {t.get_leaf_names()}")
# Reroot on midpoint
t2 = Tree("((A:0.5,B:0.1):0.2,(C:0.3,D:0.4):0.1);")
midpoint_node, midpoint_dist = t2.get_midpoint_outgroup()
t2.set_outgroup(midpoint_node)
print(f"Rerooted at midpoint; root children: {[n.name for n in t2.children]}")
# Robinson-Foulds distance between two topologies
t_ref = Tree("((A,B),(C,D));")
t_alt = Tree("((A,C),(B,D));")
rf, rf_max, common_attrs, discard_t1, discard_t2, parts1, parts2 = t_ref.robinson_foulds(t_alt)
print(f"RF distance: {rf}, normalized: {rf/rf_max:.3f}")
Module 5: Tree Visualization (TreeStyle / NodeStyle)
Render publication-quality tree figures with custom styles.
from ete3 import Tree, TreeStyle, NodeStyle, faces, AttrFace, CircleFace
t = Tree("((Homo_sapiens,Pan_troglodytes)Hominidae,(Mus_musculus,Rattus_norvegicus)Muridae)Euarchontoglires;")
# Define node styles
for node in t.traverse():
nstyle = NodeStyle()
if node.is_leaf():
nstyle["shape"] = "circle"
nstyle["size"] = 8
nstyle["fgcolor"] = "darkblue"
else:
nstyle["shape"] = "sphere"
nstyle["size"] = 6
nstyle["fgcolor"] = "gray"
node.set_style(nstyle)
# Add text face to leaves
for leaf in t.get_leaves():
name_face = AttrFace("name", fsize=12, fgcolor="black")
leaf.add_face(name_face, column=0, position="branch-right")
# Configure TreeStyle
ts = TreeStyle()
ts.mode = "r" # rectangular (use "c" for circular)
ts.show_leaf_name = False
ts.branch_vertical_margin = 15
ts.title.add_face(faces.TextFace("Phylogenetic Tree", fsize=16), column=0)
# Render to file (no display needed)
t.render("tree_figure.png", tree_style=ts, w=800, units="px")
print("Saved tree_figure.png")
from ete3 import Tree, TreeStyle, NodeStyle, faces, RectFace
t = Tree("((A,B)AB,(C,D)CD)root;")
# Circular cladogram with colored rectangles
metadata = {"A": "red", "B": "red", "C": "blue", "D": "blue"}
for leaf in t.get_leaves():
leaf.color = metadata[leaf.name]
leaf.add_face(RectFace(width=20, height=20, fgcolor=leaf.color, bgcolor=leaf.color),
column=0, position="aligned")
ts = TreeStyle()
ts.mode = "c" # circular layout
ts.arc_start = -180
ts.arc_span = 359
ts.show_leaf_name = True
t.render("circular_tree.png", tree_style=ts, w=600, units="px")
print("Saved circular_tree.png")
Module 6: NCBI Taxonomy Integration
Map species to NCBI taxonomy, retrieve lineages, and build taxonomy trees.
from ete3 import NCBITaxa
# Initialize (downloads ~50 MB taxonomy DB on first call)
ncbi = NCBITaxa()
# ncbi.update_taxonomy_database() # Refresh to latest NCBI taxonomy
# Name → taxid
taxid_map = ncbi.get_name_translator(["Homo sapiens", "Mus musculus", "Danio rerio"])
print(f"Taxid map: {taxid_map}")
# Taxid map: {'Homo sapiens': [9606], 'Mus musculus': [10090], 'Danio rerio': [7955]}
# Taxid → lineage
lineage = ncbi.get_lineage(9606)
names = ncbi.get_taxid_translator(lineage)
ranks = ncbi.get_rank(lineage)
for taxid in lineage[-6:]:
print(f" {ranks[taxid]:15s}: {names[taxid]}")
from ete3 import NCBITaxa, Tree, TreeStyle
ncbi = NCBITaxa()
# Build a taxonomy tree for a set of taxids
taxids = [9606, 10090, 7955, 6239, 7227] # human, mouse, zebrafish, C. elegans, fruit fly
tree = ncbi.get_topology(taxids, intermediate_nodes=True)
# Annotate with common names
translator = ncbi.get_taxid_translator([int(n.name) for n in tree.get_leaves()])
for leaf in tree.get_leaves():
leaf.sci_name = translator.get(int(leaf.name), leaf.name)
print(f"Taxid {leaf.name}: {leaf.sci_name}")
# Render taxonomy tree
ts = TreeStyle()
ts.show_leaf_name = True
tree.render("taxonomy_tree.png", tree_style=ts, w=600, units="px")
print("Saved taxonomy_tree.png")
Module 7: PhyloTree for Comparative Genomics
Annotate gene trees with duplication/speciation events and query ortholog relationships.
from ete3 import PhyloTree
# Build a gene tree with species mapping
# Format: leaf names must follow "gene_SPECIES" or use sp_naming_function
nwk = "((Hsap_BRCA1:0.1,Ptro_BRCA1:0.05)0.99:0.2,(Mmus_Brca1:0.3,Rnor_Brca1:0.25)0.95:0.1);"
t = PhyloTree(nwk, sp_naming_function=lambda name: name.split("_")[0])
# Annotate events (duplication vs speciation)
t.get_descendant_evol_events()
# Report events per node
for node in t.traverse():
if not node.is_leaf() and hasattr(node, "evoltype"):
print(f"Node evoltype: {node.evoltype} "
f"(D=duplication, S=speciation)")
# Get orthologs for a given leaf
leaf = t & "Hsap_BRCA1"
orthologs = leaf.get_sisters()
print(f"Orthologs of Hsap_BRCA1: {[n.name for n in t.get_leaves() if n != leaf]}")
Key Concepts
Newick Format Variants
ETE3's format parameter controls which Newick flavor to parse:
| Format | Internal node labels | Branch lengths | Typical use |
|---|---|---|---|
| 0 | Flexible | Yes | IQ-TREE, RAxML output |
| 1 | Named + support | Yes | Standard annotated trees |
| 5 | Internal names only | No | Topology-only trees |
| 9 | Leaf names only | Yes | Simple labeled trees |
| 100 | No names | No | Pure topology |
from ete3 import Tree
# RAxML output (bootstrap values as internal labels)
t = Tree("((A:0.1,B:0.2)90:0.3,(C:0.4,D:0.1)85:0.2);", format=1)
for node in t.traverse():
if not node.is_leaf():
print(f"Support: {node.support}, dist: {node.dist}")
Common Workflows
Workflow 1: Annotated Tree Figure from Alignment Output
Goal: Load an IQ-TREE result, annotate leaves with metadata, and render a publication figure.
from ete3 import Tree, TreeStyle, NodeStyle, AttrFace, faces
import pandas as pd
# Load IQ-TREE Newick output (format=1 handles support values)
t = Tree("iqtree_output.treefile", format=1)
# Midpoint rooting
outgroup, _ = t.get_midpoint_outgroup()
t.set_outgroup(outgroup)
# Load metadata table
meta = pd.read_csv("sample_metadata.csv") # columns: name, clade, color
meta_dict = meta.set_index("name").to_dict(orient="index")
# Annotate leaves
for leaf in t.get_leaves():
info = meta_dict.get(leaf.name, {})
leaf.clade = info.get("clade", "unknown")
leaf.face_color = info.get("color", "gray")
# Apply per-node styles
for node in t.traverse():
ns = NodeStyle()
if node.is_leaf():
ns["size"] = 8
ns["fgcolor"] = node.face_color if hasattr(node, "face_color") else "black"
else:
ns["size"] = 0 # hide internal nodes
node.set_style(ns)
# Add name labels
for leaf in t.get_leaves():
leaf.add_face(AttrFace("name", fsize=10), column=0, position="branch-right")
leaf.add_face(AttrFace("clade", fsize=9, fgcolor="gray"), column=1, position="branch-right")
ts = TreeStyle()
ts.show_leaf_name = False
ts.branch_vertical_margin = 12
ts.scale = 200 # pixels per branch length unit
t.render("annotated_tree.pdf", tree_style=ts)
print("Saved annotated_tree.pdf")
Workflow 2: Taxonomy-Aware Species Tree Construction
Goal: Build a topology-correct species tree from a list of NCBI taxids and export as Newick.
from ete3 import NCBITaxa, TreeStyle
ncbi = NCBITaxa()
# Input: list of NCBI taxids
taxids = [9606, 10090, 7955, 6239, 7227, 3702] # human, mouse, zebrafish, worm, fly, Arabidopsis
# Build topology tree
species_tree = ncbi.get_topology(taxids, intermediate_nodes=False)
# Translate taxid leaf names to scientific names
translator = ncbi.get_taxid_translator([int(n.name) for n in species_tree.get_leaves()])
for leaf in species_tree.get_leaves():
leaf.name = translator.get(int(leaf.name), leaf.name)
# Export Newick
nwk = species_tree.write(format=9)
print(f"Species tree Newick:\n{nwk}")
with open("species_tree.nwk", "w") as f:
f.write(nwk)
# Render figure
ts = TreeStyle()
ts.show_leaf_name = True
ts.mode = "r"
species_tree.render("species_tree.png", tree_style=ts, w=800, units="px")
print("Saved species_tree.nwk and species_tree.png")
Workflow 3: Batch Robinson-Foulds Comparison
Goal: Compare a set of bootstrap replicate trees against a reference topology.
from ete3 import Tree
import pandas as pd
# Reference tree
ref_tree = Tree("reference.nwk", format=1)
ref_leaves = set(ref_tree.get_leaf_names())
results = []
for i, line in enumerate(open("bootstrap_trees.nwk")):
bt = Tree(line.strip(), format=1)
# Prune to common taxa
common = ref_leaves & set(bt.get_leaf_names())
ref_pruned = ref_tree.copy()
bt_pruned = bt.copy()
ref_pruned.prune(list(common))
bt_pruned.prune(list(common))
rf, rf_max, *_ = ref_pruned.robinson_foulds(bt_pruned, unrooted_trees=True)
norm_rf = rf / rf_max if rf_max > 0 else 0
results.append({"replicate": i + 1, "rf": rf, "rf_max": rf_max, "norm_rf": norm_rf})
df = pd.DataFrame(results)
print(df.describe())
df.to_csv("rf_distances.csv", index=False)
print(f"Mean normalized RF: {df['norm_rf'].mean():.4f}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
format |
Tree I/O | 0 |
0–9, 100 |
Newick flavor; controls parsing of support values vs internal names |
quoted_node_names |
Tree I/O | False |
True, False |
Allow spaces and special chars in node names |
preserve_branch_length |
Pruning | False |
True, False |
Maintain patristic distances when pruning subtrees |
unrooted_trees |
Robinson-Foulds | False |
True, False |
Compare unrooted topologies (required for most gene trees) |
ts.mode |
TreeStyle | "r" |
"r", "c" |
Rectangular or circular layout |
ts.scale |
TreeStyle | None |
positive int | Pixels per branch length unit; controls horizontal spread |
ts.branch_vertical_margin |
TreeStyle | 5 |
int (px) | Vertical spacing between leaf branches |
intermediate_nodes |
NCBITaxa | False |
True, False |
Include ancestral NCBI taxids in topology tree |
Common Recipes
Recipe: Extract All Subtrees Matching a Clade Name
When to use: Identify all subtrees rooted at nodes whose name matches a pattern.
from ete3 import Tree
t = Tree("((Homo_sapiens,Pan_troglodytes)Hominidae,(Mus_musculus,Rattus_norvegicus)Muridae)Euarchontoglires;")
target_clade = "Hominidae"
nodes = t.search_nodes(name=target_clade)
for node in nodes:
print(f"Clade {node.name}: {node.get_leaf_names()}")
subtree_nwk = node.write(format=1)
print(f" Newick: {subtree_nwk}")
Recipe: Ultrametricize a Non-Ultrametric Tree
When to use: Prepare a tree for time-calibrated analyses requiring ultrametric input.
from ete3 import Tree
t = Tree("((A:0.3,B:0.1):0.4,(C:0.7,D:0.2):0.1);")
print(f"Before: max leaf distance = {max(t.get_distance(l) for l in t.get_leaves()):.4f}")
# Convert to ultrametric using ETE's method
t.convert_to_ultrametric()
dists = [t.get_distance(l) for l in t.get_leaves()]
print(f"After: all leaf distances = {set(round(d, 6) for d in dists)}")
Recipe: Color Leaves by Group and Export SVG
When to use: Quickly produce a colored tree for presentations without manual styling.
from ete3 import Tree, TreeStyle, NodeStyle
t = Tree("((A,B,C)GroupX,(D,E)GroupY,(F,G,H)GroupZ);")
group_colors = {"GroupX": "steelblue", "GroupY": "tomato", "GroupZ": "seagreen"}
for node in t.traverse():
ns = NodeStyle()
if node.is_leaf():
parent_name = node.up.name if node.up else ""
ns["fgcolor"] = group_colors.get(parent_name, "black")
ns["size"] = 10
node.set_style(ns)
ts = TreeStyle()
ts.show_leaf_name = True
ts.branch_vertical_margin = 14
t.render("colored_tree.svg", tree_style=ts, w=600, units="px")
print("Saved colored_tree.svg")
Expected Outputs
- Tree objects:
Tree/PhyloTreeinstances — navigable node hierarchies with.children,.up,.name,.dist,.support - Newick strings: produced by
t.write(format=N)— standard text for downstream tools - Image files:
.png,.pdf,.svgviat.render()— resolution controlled bywparameter anddpi - Taxonomy trees:
NCBITaxa.get_topology()returns aTreewith taxid node names - RF distances: integer raw RF + integer max RF from
robinson_foulds(); normalize asrf / rf_max
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
NewickError: Unexepected char |
Newick format mismatch (support vs name field) | Try format=0, 1, or 5; check if internal nodes have support values or names |
RuntimeError: cannot connect to display |
PyQt5 requires a display for rendering | Use t.render("out.png", ...) instead of t.show(); on headless servers, run under xvfb-run |
AttributeError: 'Tree' object has no attribute |
Node feature not annotated | Check dir(node) or use hasattr(node, "attr") before access |
| NCBI taxonomy DB not found | First-time use; DB not downloaded | Call NCBITaxa().update_taxonomy_database() once |
| Tree figure all leaves collapsed | Branch lengths all zero with scale set too low |
Set ts.scale = None to auto-scale, or increase ts.scale value |
ValueError on robinson_foulds |
Trees have no common leaves | Prune both trees to shared taxon set before comparison |
| Slow rendering for large trees (500+ leaves) | Per-node Python rendering loop | Use ts.show_leaf_name = False and minimal faces; consider exporting Newick and rendering with FigTree |
References
- ETE3 documentation — official tutorial covering all modules
- ETE3 API reference — complete class and method reference
- ETE3 GitHub repository — source, issue tracker, and example notebooks
- Huerta-Cepas J, Serra F, Bork P (2016) ETE 3: Reconstruction, Analysis, and Visualization of Phylogenomic Data. Mol Biol Evol 33:1635-1638 — primary citation for ETE3
skills/genomics-bioinformatics/homer-motif-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill homer-motif-analysis -g -y
SKILL.md
Frontmatter
{
"name": "homer-motif-analysis",
"license": "GPL-3.0",
"description": "De novo and known TF motif enrichment in ChIP-seq\/ATAC-seq peaks via HOMER. findMotifsGenome.pl finds over-represented patterns vs background; annotatePeaks.pl assigns context (TSS distance, gene, repeat). Use after MACS3 to identify enriched TFs, annotate peaks with nearest genes, and validate ChIP-seq via the target motif."
}
HOMER — Motif Analysis and Peak Annotation
Overview
HOMER (Hypergeometric Optimization of Motif EnRichment) is a suite of Perl/C++ tools for analyzing genomic regulatory elements. Its two primary commands are findMotifsGenome.pl, which performs de novo motif discovery and known motif enrichment against JASPAR/HOMER databases, and annotatePeaks.pl, which maps each peak to the nearest gene, distance to TSS, and genomic feature class (promoter, intron, intergenic, repeat). HOMER takes BED-format peak files from MACS3 or similar peak callers and a reference genome assembly as input, and outputs HTML/text reports ranking enriched motifs by p-value and fold enrichment over a matched background.
When to Use
- Identifying which transcription factors are bound in a ChIP-seq peak set by enriching their known motifs from JASPAR or the HOMER motif library
- Discovering novel sequence motifs de novo in open chromatin regions from ATAC-seq without prior knowledge of the binding TF
- Comparing motif landscapes between two conditions (e.g., treated vs. untreated peak sets) by running HOMER with one set as target and the other as background
- Annotating genomic peaks with nearest genes and distance to TSS for downstream functional analysis or integration with DESeq2 results
- Validating ChIP-seq experiment quality: a successful pull-down should show the target TF's canonical motif as the top hit
- Use
macs3-peak-callingfirst to generate the peak BED files that serve as input to HOMER - Use
jaspar-databaseto cross-reference HOMER-discovered motifs with JASPAR IDs and additional TF metadata - Use
MEME-CHIP(web or local) when you need a more probabilistic ZOOPS/TCM model or the MEME Suite ecosystem - Use
AME(part of MEME Suite) as a faster alternative for known motif scanning without de novo discovery
Prerequisites
- Software: HOMER (Perl + compiled binaries), conda or manual install
- Genomes: must download genome sequence via
installGenome.plafter HOMER install - Input: BED file of peaks (at minimum: chr, start, end columns); ideally summit-centered peaks from MACS3
- Python packages (for parsing/visualization):
pandas,matplotlib,seaborn
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v findMotifsGenome.plfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run findMotifsGenome.plrather than barefindMotifsGenome.pl.
# Install HOMER via conda (recommended — handles Perl dependencies)
conda install -c bioconda homer
# Verify installation
findMotifsGenome.pl 2>&1 | head -3
# Usage: findMotifsGenome.pl <peak/BED file> <genome> <output directory> [options]
annotatePeaks.pl 2>&1 | head -3
# Usage: annotatePeaks.pl <peak/BED file> <genome> [options]
# Install reference genomes (downloads 2-way masker + sequence; ~3–10 GB each)
installGenome.pl hg38
installGenome.pl mm10
# Install Python parsing dependencies
pip install pandas matplotlib seaborn
Quick Start
# Run de novo + known motif enrichment on TF ChIP-seq peaks (hg38, 200 bp window)
findMotifsGenome.pl peaks/tf_chip_summits.bed hg38 motif_output/ \
-size 200 -mask -p 4
# Annotate peaks with nearest genes and genomic features
annotatePeaks.pl peaks/tf_chip_peaks.narrowPeak hg38 > annotated_peaks.txt
echo "Top known motif:"
head -2 motif_output/knownResults.txt | tail -1 | cut -f1-4
echo "Annotated peaks: $(wc -l < annotated_peaks.txt) lines"
Workflow
Step 1: Installation and Genome Setup
Install HOMER and download the reference genome sequence required for motif analysis.
# Activate conda environment (or use existing env)
conda create -n homer_env -c bioconda homer python=3.10 -y
conda activate homer_env
# List available genomes
installGenome.pl list
# Install human (hg38) and mouse (mm10) genomes
# Downloads masked genome sequence and annotation files
installGenome.pl hg38
# Output: Installing hg38... Done. (3-5 min, ~3 GB)
installGenome.pl mm10
# Output: Installing mm10... Done. (3-5 min, ~2.5 GB)
# Verify genome is installed
ls ~/.homer/data/genomes/hg38/
# genome.fa chrom.sizes ...
# Check HOMER motif database
ls ~/.homer/data/knownTFs/
# vertebrates.motifs jaspar.motifs ...
Step 2: Prepare Input Peak File
Prepare a summit-centered BED file from MACS3 output for optimal motif resolution.
# Option A: Use MACS3 summit file directly (already 1 bp summit positions)
# Expand summits to ±100 bp (200 bp total) centered on summit
awk 'BEGIN{OFS="\t"} {
start = ($2 - 100 < 0) ? 0 : $2 - 100;
print $1, start, $2 + 100, $4, $5
}' peaks/tf_chip_summits.bed > peaks/tf_chip_200bp.bed
echo "Summit-centered peaks: $(wc -l < peaks/tf_chip_200bp.bed)"
# Summit-centered peaks: 12453
# Option B: Use narrowPeak file directly (HOMER accepts multi-column BED)
# HOMER uses columns 1-3 (chr, start, end) and centers internally with -size
cp peaks/tf_chip_peaks.narrowPeak peaks/input_peaks.bed
# Option C: Prepare a custom background region file (matched GC content)
# HOMER auto-generates background if not provided, but explicit background
# is recommended when comparing two peak sets
# Use the control peak set or random genomic regions as background:
bedtools shuffle -i peaks/tf_chip_peaks.narrowPeak \
-g ~/.homer/data/genomes/hg38/chrom.sizes \
-excl peaks/tf_chip_peaks.narrowPeak > peaks/background_regions.bed
echo "Background regions: $(wc -l < peaks/background_regions.bed)"
# Background regions: 12453
Step 3: De Novo Motif Discovery
Run findMotifsGenome.pl for de novo motif discovery and known motif enrichment simultaneously.
mkdir -p motif_output/
# Full run: de novo + known motif enrichment
# -size 200: use 200 bp window centered on peak midpoint
# -mask: mask repetitive elements (recommended for clean motifs)
# -p 4: use 4 CPU threads
# -S 25: find top 25 de novo motifs (default)
findMotifsGenome.pl peaks/tf_chip_200bp.bed hg38 motif_output/ \
-size 200 \
-mask \
-p 4 \
-S 25
# Check progress output:
# Reading genome sizes for hg38 ...
# Scanning for motifs...
# Optimizing 25 motifs...
# Done! Output in motif_output/
echo "Known results: $(wc -l < motif_output/knownResults.txt) motifs"
echo "De novo motifs: $(ls motif_output/homerResults/*.motif 2>/dev/null | wc -l) motifs"
# Known results: 392 motifs
# De novo motifs: 25 motifs
# For mouse peaks (mm10)
# findMotifsGenome.pl peaks/atac_peaks.bed mm10 motif_output_mm10/ \
# -size 200 -mask -p 4
Step 4: Known Motif Enrichment Only
Scan peaks for occurrences of a specific known motif or skip de novo discovery for speed.
# Skip de novo discovery (faster when you only need known motifs)
findMotifsGenome.pl peaks/tf_chip_200bp.bed hg38 motif_output_known/ \
-size 200 \
-mask \
-p 4 \
-nomotif
echo "Known motif results: $(wc -l < motif_output_known/knownResults.txt)"
# Known motif results: 392
# Find occurrences of a specific motif across peaks (outputs peak-level annotation)
# Extract the motif matrix file for the TF of interest from homerResults/
findMotifsGenome.pl peaks/tf_chip_200bp.bed hg38 motif_scan_out/ \
-size 200 \
-mask \
-find motif_output/homerResults/motif1.motif \
> peaks_with_motif1.txt
echo "Peaks containing motif1: $(wc -l < peaks_with_motif1.txt)"
# Peaks containing motif1: 8941
# Custom background: compare treated vs. control peak sets
findMotifsGenome.pl peaks/treated_peaks.bed hg38 motif_treated_vs_ctrl/ \
-size 200 \
-mask \
-p 4 \
-bg peaks/control_peaks.bed
Step 5: Peak Annotation
Use annotatePeaks.pl to assign each peak to a genomic feature and nearest gene.
# Annotate peaks with nearest gene and TSS distance
# Outputs a tab-delimited file with genomic context for each peak
annotatePeaks.pl peaks/tf_chip_peaks.narrowPeak hg38 \
> annotated_peaks.txt
echo "Annotated peaks: $(($(wc -l < annotated_peaks.txt) - 1)) peaks"
# Annotated peaks: 12453 peaks
# Preview column headers and first peak
head -2 annotated_peaks.txt | cut -f1-10
# Annotate ATAC-seq peaks (same command, different input)
annotatePeaks.pl peaks/atac_sample_peaks.narrowPeak hg38 \
> annotated_atac.txt
# Generate TSS-distance histogram (for tag density plots)
# annotatePeaks.pl can compute read density around peaks with -d flag
# annotatePeaks.pl tss hg38 -size 4000 -hist 10 \
# -d chip_tagdir/ > tss_histogram.txt
Step 6: Parse HOMER Results with Python
Read knownResults.txt and de novo motif files into pandas for downstream analysis.
import pandas as pd
import subprocess
import io
# --- Parse known motif enrichment results ---
# knownResults.txt columns:
# Motif Name | Consensus | P-value | Log P-value | q-value | # Target Seqs w/ motif | % Target | # Bg Seqs w/ motif | % Bg
known_cols = [
"motif_name", "consensus", "pvalue", "log_pvalue",
"qvalue", "n_target_seqs", "pct_target",
"n_bg_seqs", "pct_bg"
]
known = pd.read_csv(
"motif_output/knownResults.txt",
sep="\t", header=0, names=known_cols
)
# Convert string percentages to floats
known["pct_target"] = known["pct_target"].str.rstrip("%").astype(float)
known["pct_bg"] = known["pct_bg"].str.rstrip("%").astype(float)
known["fold_enrichment"] = known["pct_target"] / known["pct_bg"].replace(0, 0.001)
known["-log10_pvalue"] = -known["log_pvalue"] / 2.303 # log10 from natural log
print(f"Total known motifs tested: {len(known)}")
print(f"Significant (p < 1e-5): {(known['pvalue'].astype(float) < 1e-5).sum()}")
print("\nTop 5 enriched motifs:")
print(
known[["motif_name", "pct_target", "pct_bg", "fold_enrichment", "-log10_pvalue"]]
.head(5)
.to_string(index=False)
)
# Total known motifs tested: 391
# Significant (p < 1e-5): 47
# Top 5 enriched motifs:
# motif_name pct_target pct_bg fold_enrichment -log10_pvalue
# CTCF(Zf)/GM12878-CTCF-ChIP-Seq... 78.21 8.32 9.40 312.4
# ZNF143(Zf)/... 42.11 5.10 8.26 188.7
# ...
Step 7: Parse Peak Annotations and Visualize
Read annotated peak output, summarize genomic feature distribution, and plot motif enrichments.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
# --- Parse annotated peaks ---
# annotatePeaks.pl output header: PeakID chr start end strand score ...
# Key columns: "Annotation", "Distance to TSS", "Nearest RefSeq", "Gene Name"
annot = pd.read_csv("annotated_peaks.txt", sep="\t", header=0, low_memory=False)
annot.columns = annot.columns.str.strip()
# Simplify annotation categories
def simplify_annotation(ann):
if pd.isna(ann):
return "Other"
ann = str(ann)
if "promoter" in ann.lower():
return "Promoter (<2 kb)"
elif "exon" in ann.lower():
return "Exon"
elif "intron" in ann.lower():
return "Intron"
elif "tts" in ann.lower() or "downstream" in ann.lower():
return "Downstream"
elif "intergenic" in ann.lower():
return "Intergenic"
else:
return "Other"
annot["simple_ann"] = annot["Annotation"].apply(simplify_annotation)
ann_counts = annot["simple_ann"].value_counts()
print("Peak annotation summary:")
print(ann_counts.to_string())
# Peak annotation summary:
# Intron 5832
# Intergenic 3102
# Promoter (<2 kb) 2218
# Exon 891
# Downstream 287
# Other 123
# --- Plot 1: Annotation pie chart ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
colors = ["#E41A1C", "#377EB8", "#4DAF4A", "#984EA3", "#FF7F00", "#A65628"]
axes[0].pie(
ann_counts.values,
labels=ann_counts.index,
colors=colors[:len(ann_counts)],
autopct="%1.1f%%",
startangle=140,
textprops={"fontsize": 9}
)
axes[0].set_title("Peak Genomic Annotation\n(n=12,453 peaks)", fontsize=11)
# --- Plot 2: Top known motif enrichments ---
known = pd.read_csv("motif_output/knownResults.txt", sep="\t", header=0)
known.columns = [
"motif_name", "consensus", "pvalue", "log_pvalue",
"qvalue", "n_target_seqs", "pct_target", "n_bg_seqs", "pct_bg"
]
known["pct_target"] = known["pct_target"].str.rstrip("%").astype(float)
known["pct_bg"] = known["pct_bg"].str.rstrip("%").astype(float)
known["-log10_p"] = (-known["log_pvalue"].astype(float)) / 2.303
known["short_name"] = known["motif_name"].str.split("/").str[0]
top_motifs = known.head(15).copy()
sns.barplot(
data=top_motifs,
x="-log10_p",
y="short_name",
palette="Blues_r",
ax=axes[1]
)
axes[1].set_xlabel("-log10(p-value)", fontsize=10)
axes[1].set_ylabel("Motif", fontsize=10)
axes[1].set_title("Top 15 Enriched Known Motifs\n(HOMER/JASPAR database)", fontsize=11)
axes[1].axvline(x=5, color="red", linestyle="--", linewidth=0.8, label="p=1e-5")
axes[1].legend(fontsize=8)
plt.tight_layout()
plt.savefig("homer_summary.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved: homer_summary.png")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
-size |
200 |
50–1000 | Window size (bp) centered on peak midpoint for motif search; 200 for TF ChIP-seq, 150 for ATAC-seq |
-mask |
off | flag | Mask repetitive elements (N-mask); strongly recommended to reduce false positives |
-p |
1 |
1–64 | Number of parallel CPU threads; use 4–8 for typical datasets |
-S |
25 |
1–100 | Number of de novo motifs to find; 25 is usually sufficient |
-nomotif |
off | flag | Skip de novo motif discovery; only perform known motif enrichment (10× faster) |
-bg |
auto-generated | BED file path | Custom background region file; auto-background is GC-matched if omitted |
-find |
— | .motif file path |
Scan peaks for occurrences of a specific motif matrix; outputs per-peak annotation |
-mis |
2 |
0–4 | Maximum mismatches allowed when matching known motifs |
-len |
8,10,12 |
comma-separated integers | Motif lengths to try for de novo search; adding 6 finds shorter motifs |
genome |
required | hg38, mm10, hg19, dm6, ce11, etc. |
Reference genome assembly; must be installed with installGenome.pl |
Common Recipes
Recipe 1: Python subprocess Wrapper for findMotifsGenome.pl
Run HOMER from a Python script and capture completion status.
import subprocess
import sys
from pathlib import Path
def run_homer_motifs(
peaks_bed: str,
genome: str,
output_dir: str,
size: int = 200,
mask: bool = True,
cpus: int = 4,
n_motifs: int = 25,
denovo: bool = True,
bg_bed: str = None
) -> int:
"""Run findMotifsGenome.pl and return exit code."""
Path(output_dir).mkdir(parents=True, exist_ok=True)
cmd = [
"findMotifsGenome.pl", peaks_bed, genome, output_dir,
"-size", str(size),
"-p", str(cpus),
"-S", str(n_motifs),
]
if mask:
cmd.append("-mask")
if not denovo:
cmd.append("-nomotif")
if bg_bed:
cmd.extend(["-bg", bg_bed])
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"HOMER stderr:\n{result.stderr[-2000:]}", file=sys.stderr)
return result.returncode
print(f"Done. Output in {output_dir}/")
return 0
# Usage
exit_code = run_homer_motifs(
peaks_bed="peaks/tf_chip_200bp.bed",
genome="hg38",
output_dir="motif_output/",
size=200, mask=True, cpus=4, n_motifs=25
)
print(f"Exit code: {exit_code}")
# Running: findMotifsGenome.pl peaks/tf_chip_200bp.bed hg38 motif_output/ -size 200 -p 4 -S 25 -mask
# Done. Output in motif_output/
# Exit code: 0
Recipe 2: Batch Motif Analysis Across Multiple Peak Sets
Run HOMER on multiple ChIP-seq samples in a loop.
#!/bin/bash
# Batch motif analysis for several ChIP-seq experiments (same genome)
GENOME="hg38"
PEAK_DIR="peaks"
MOTIF_DIR="motif_results"
mkdir -p "$MOTIF_DIR"
SAMPLES=(CTCF H3K4me3 FOXA2 RUNX1)
for sample in "${SAMPLES[@]}"; do
echo "=== Processing $sample ==="
peak_file="${PEAK_DIR}/${sample}_summits.bed"
if [ ! -f "$peak_file" ]; then
echo " Skipping $sample: $peak_file not found"
continue
fi
# Center on summit ±100 bp
awk 'BEGIN{OFS="\t"} {s=$2-100<0?0:$2-100; print $1,s,$2+100,$4,$5}' \
"$peak_file" > "${PEAK_DIR}/${sample}_200bp.bed"
findMotifsGenome.pl "${PEAK_DIR}/${sample}_200bp.bed" "$GENOME" \
"${MOTIF_DIR}/${sample}/" \
-size 200 -mask -p 4 -nomotif \
2> "${MOTIF_DIR}/${sample}.log"
n=$(wc -l < "${MOTIF_DIR}/${sample}/knownResults.txt")
echo " $sample: $n motifs tested. Top hit: $(sed -n '2p' "${MOTIF_DIR}/${sample}/knownResults.txt" | cut -f1)"
done
# === Processing CTCF ===
# CTCF: 392 motifs tested. Top hit: CTCF(Zf)/GM12878-CTCF-ChIP-Seq(GSE32465)/Homer
# === Processing FOXA2 ===
# FOXA2: 392 motifs tested. Top hit: Foxa2(Forkhead)/Liver-Foxa2-ChIP-Seq(GSE25694)/Homer
Recipe 3: Parse De Novo Motif Matrices from homerResults/
Load de novo motif PWM matrices for downstream comparison or plotting.
import os
import re
import pandas as pd
def parse_homer_motif(motif_file: str) -> dict:
"""Parse a HOMER .motif file into name, log_odds_threshold, and PWM."""
with open(motif_file) as f:
header = f.readline().strip() # >motif_name\tlog_odds\tlog_p-value\t0\tnucs
rows = []
for line in f:
line = line.strip()
if line:
rows.append([float(x) for x in line.split("\t")])
parts = header.lstrip(">").split("\t")
name = parts[0]
log_odds = float(parts[1]) if len(parts) > 1 else 0.0
log_p = float(parts[2]) if len(parts) > 2 else 0.0
pwm = pd.DataFrame(rows, columns=["A", "C", "G", "T"])
return {"name": name, "log_odds": log_odds, "log_p": log_p, "pwm": pwm}
# Load all de novo motifs
motif_dir = "motif_output/homerResults/"
motifs = []
for fn in sorted(os.listdir(motif_dir)):
if fn.endswith(".motif"):
motif = parse_homer_motif(os.path.join(motif_dir, fn))
motifs.append(motif)
print(f"{fn}: {motif['name']} ({len(motif['pwm'])} positions, log_p={motif['log_p']:.1f})")
print(f"\nLoaded {len(motifs)} de novo motifs")
# motif1.motif: CTCF-motif (19 positions, log_p=-8234.1)
# motif2.motif: CTCFL-motif (17 positions, log_p=-3421.7)
# ...
# Loaded 25 de novo motifs
Recipe 4: Annotate Peaks and Join with Differential Expression Results
Combine peak annotations with RNA-seq DE results to find regulated genes near peaks.
import pandas as pd
# Load annotated peaks
annot = pd.read_csv("annotated_peaks.txt", sep="\t", header=0, low_memory=False)
annot.columns = annot.columns.str.strip()
# Key columns from HOMER annotation
peak_genes = annot[["PeakID (cmd=annotatePeaks.pl peaks.bed hg38)",
"Chr", "Start", "End",
"Annotation", "Distance to TSS",
"Nearest RefSeq", "Gene Name"]].copy()
peak_genes.columns = ["peak_id", "chr", "start", "end",
"annotation", "tss_dist", "refseq", "gene_name"]
# Filter promoter-proximal peaks (within 2 kb of TSS)
promoter_peaks = peak_genes[peak_genes["tss_dist"].abs() < 2000].copy()
print(f"Promoter-proximal peaks (|TSS| < 2kb): {len(promoter_peaks)}")
# Promoter-proximal peaks (|TSS| < 2kb): 2218
# Load DESeq2 results (gene_name, log2FC, padj)
de_results = pd.read_csv("deseq2_results.csv")
de_sig = de_results[de_results["padj"] < 0.05].copy()
# Merge: find DE genes with a nearby peak
merged = promoter_peaks.merge(de_sig, on="gene_name", how="inner")
print(f"DE genes with promoter-proximal peak: {len(merged)}")
print(merged[["gene_name", "tss_dist", "annotation", "log2FC", "padj"]].head())
# DE genes with promoter-proximal peak: 347
Expected Outputs
| Output | Format | Description |
|---|---|---|
motif_output/knownResults.txt |
TSV | All known motifs tested: name, p-value, q-value, % target, % background; primary result file |
motif_output/knownResults.html |
HTML | Interactive HTML report with motif logos, statistics, and links |
motif_output/homerResults/ |
Directory | De novo motifs: motifN.motif (PWM matrix), motifN.logo.png, similar.motifs.txt |
motif_output/homerResults.html |
HTML | Interactive HTML report for de novo motifs |
annotated_peaks.txt |
TSV | One row per peak: chr, start, end, annotation, TSS distance, nearest gene, RefSeq ID |
peaks_with_motif1.txt |
TSV | Per-peak motif occurrence scores (from -find mode) |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ERROR: Genome not found |
Genome not installed via installGenome.pl |
Run installGenome.pl hg38; confirm ~/.homer/data/genomes/hg38/ exists |
command not found: findMotifsGenome.pl |
HOMER binaries not in $PATH |
Add HOMER bin directory to PATH: export PATH=~/.homer/bin:$PATH (or activate conda env) |
| Poor de novo motif quality (GC-rich artifacts) | Unmasked repetitive elements | Always use -mask; also try increasing -size to 300 or reducing to 150 |
| No significant known motifs (all p > 0.01) | Too few peaks or wrong peak size | Ensure ≥500 peaks; check -size matches expected binding footprint (150–200 for TFs) |
| Conda HOMER conflicts (Perl module errors) | HOMER's Perl scripts conflict with conda environment Perl | Install HOMER in a dedicated conda env: conda create -n homer_only -c bioconda homer |
annotatePeaks.pl gives all "Intergenic" |
Genome annotation not installed | HOMER annotation requires installGenome.pl which downloads GTF; check ~/.homer/data/genomes/hg38/ contains *.ann files |
| Very slow run time (>2 hr) | Single-threaded or large peak set | Add -p 8; reduce -S to 10 for speed; use -nomotif for known-only runs |
-bg custom background gives poor motifs |
Background regions have very different GC content | Use a GC-matched background; run bedtools shuffle with -noOverlapping to generate random same-size regions |
References
- HOMER official documentation — comprehensive guide to all HOMER commands, parameters, and output formats
- Heinz S et al. (2010) "Simple Combinations of Lineage-Determining Transcription Factors Prime cis-Regulatory Elements Required for Macrophage and B Cell Identities." Molecular Cell 38(4):576–589. DOI:10.1016/j.molcel.2010.05.004
- HOMER GitHub: samtools/homer — source code and issue tracker
- JASPAR 2024 database — curated TF binding motif database used by HOMER for known motif enrichment
skills/genomics-bioinformatics/interval-ops/bedtools-genomic-intervals/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill bedtools-genomic-intervals -g -y
SKILL.md
Frontmatter
{
"name": "bedtools-genomic-intervals",
"license": "GPL-2.0",
"description": "Genomic interval ops on BED\/BAM\/GFF\/VCF. Find overlaps, merge intervals, compute coverage, extract FASTA, find nearest features. Core for ChIP-seq peak annotation, region filtering, genome arithmetic. Use tabix for indexed single-region queries; use deeptools for normalized bigWig coverage."
}
bedtools — Genomic Interval Analysis Toolkit
Overview
bedtools is the standard toolkit for operating on genomic intervals in BED, BAM, GFF, and VCF formats. It solves the core problem of genome arithmetic: finding overlaps between feature sets, computing coverage, extracting sequences, merging adjacent regions, and annotating features with nearest neighbors. bedtools operates on sorted coordinate lists and runs at C speed, making it practical for whole-genome analyses.
When to Use
- Intersecting ChIP-seq peaks with gene annotations to find promoter-overlapping peaks
- Merging overlapping ATAC-seq peaks or called regions across replicates
- Computing read coverage depth over target capture regions
- Extracting FASTA sequences for motif discovery or primer design
- Finding the nearest gene to each regulatory element or variant
- Subtracting blacklist or repeat regions from peak calls
- Expanding genomic intervals by fixed distance (promoter regions)
- Use
tabixinstead for fast indexed queries of a single genomic region - For normalized coverage bigWig tracks, use
deeptools bamCoverageinstead - Use
mosdepthinstead for whole-genome per-base depth (10× faster)
Prerequisites
- Python packages: None required (command-line only)
- Input requirements: BED/BAM/GFF/VCF files; FASTA reference for
getfasta; genome file (chromosome sizes) forslop/flank/genomecov - Sorting: Most operations require coordinate-sorted input
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v bedtoolsfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run bedtoolsrather than barebedtools.
# Bioconda (recommended)
conda install -c bioconda bedtools
# Homebrew (macOS)
brew install bedtools
# Verify
bedtools --version
# bedtools v2.31.0
# Create genome file from FASTA index
samtools faidx reference.fa
cut -f1,2 reference.fa.fai > genome.txt # chr → size table
Quick Start
# Find peaks overlapping genes, then merge overlapping peaks
bedtools intersect -a peaks.bed -b genes.bed -wa -wb > peaks_with_genes.bed
bedtools merge -i peaks.bed > merged_peaks.bed
bedtools coverage -a genes.bed -b reads.bam > gene_coverage.bed
Core API
Module 1: Interval Intersection and Overlap Analysis
Find regions that overlap between two feature sets.
# Basic intersection: output overlapping regions
bedtools intersect -a peaks.bed -b genes.bed
# Report original A and B features for each overlap
bedtools intersect -a peaks.bed -b genes.bed -wa -wb
# Count B overlaps per A feature (adds column)
bedtools intersect -a peaks.bed -b genes.bed -c
# Output: chr1 1000 2000 peak1 gene_count
# Peaks with ANY overlap (report each peak once)
bedtools intersect -a peaks.bed -b genes.bed -u
# Peaks with NO overlap in B (invert filter)
bedtools intersect -a peaks.bed -b blacklist.bed -v
# Require reciprocal 50% overlap both ways
bedtools intersect -a exp1.bed -b exp2.bed -f 0.5 -F 0.5 -r
# Same-strand intersections only
bedtools intersect -a peaks.bed -b genes.bed -s
# Multiple database files with overlap counts per file
bedtools intersect -a query.bed -b enhancers.bed promoters.bed \
-names enh prom -C
# Memory-efficient mode for pre-sorted large files
bedtools intersect -a sorted_peaks.bed -b sorted_genes.bed -sorted
Module 2: Interval Merging and Arithmetic
Combine overlapping intervals and perform set operations.
# Merge overlapping and adjacent intervals
sort -k1,1 -k2,2n peaks.bed | bedtools merge -i stdin
# Merge intervals within 500 bp of each other
bedtools merge -i peaks.bed -d 500
# Merge and count original features
bedtools merge -i peaks.bed -c 1 -o count
# Output: chr1 1000 5000 3 (3 original peaks merged)
# Merge and collapse feature names
bedtools merge -i peaks.bed -c 4 -o collapse -delim ";"
# Output: chr1 1000 5000 peak1;peak2;peak3
# Subtract B from A (remove covered bases)
bedtools subtract -a peaks.bed -b blacklist.bed
# Remove entire A feature if ANY B overlap
bedtools subtract -a peaks.bed -b exclusion.bed -A
# Find genomic gaps (complement of covered regions)
bedtools complement -i merged.bed -g genome.txt
Module 3: Coverage Analysis
Calculate depth and breadth of read coverage over features.
# Coverage stats per feature (count, bases covered, % covered)
bedtools coverage -a target_genes.bed -b aligned.bam
# Output: chr start end gene n_overlapping_reads bases_covered feature_len fraction_covered
# Per-base depth within each feature
bedtools coverage -a targets.bed -b aligned.bam -d
# Output: chr start end name position depth
# Coverage histogram per feature
bedtools coverage -a features.bed -b aligned.bam -hist
# Genome-wide BEDGRAPH (coverage per bin)
bedtools genomecov -ibam aligned.bam -bg -o coverage.bedgraph
# Include zero-coverage regions (for whole-genome coverage)
bedtools genomecov -ibam aligned.bam -bga > full_coverage.bedgraph
# Per-base depth for whole genome
bedtools genomecov -ibam aligned.bam -d > depth.txt
# Scaled BEDGRAPH (RPM normalization: total=50M reads → scale=1/50)
bedtools genomecov -ibam aligned.bam -bg -scale 0.00000002 > rpm.bedgraph
# Strand-specific coverage tracks
bedtools genomecov -ibam rnaseq.bam -bg -strand + > forward.bedgraph
bedtools genomecov -ibam rnaseq.bam -bg -strand - > reverse.bedgraph
Module 4: Sequence Extraction and Nearest Feature
Extract genomic sequences and annotate features with neighbors.
# Extract FASTA sequences for each BED region
bedtools getfasta -fi genome.fa -bed regions.bed -fo sequences.fasta
# Strand-aware extraction (reverse complement - strand)
bedtools getfasta -fi genome.fa -bed regions.bed -s -fo stranded.fasta
# Custom FASTA headers (name + coords)
bedtools getfasta -fi genome.fa -bed peaks.bed -name -fo named.fasta
# Extract and concatenate exons (BED12 spliced transcripts)
bedtools getfasta -fi genome.fa -bed transcripts.bed12 -split -fo exons.fasta
# Find nearest gene to each peak (with distance)
bedtools closest -a peaks.bed -b genes.bed -d
# Output: peak fields... | gene fields... | distance_bp
# Nearest feature on same strand only
bedtools closest -a peaks.bed -b genes.bed -s -d
# Ignore overlapping features (find nearest non-overlapping)
bedtools closest -a peaks.bed -b genes.bed -io -d
# Multiple annotation databases
bedtools closest -a query.bed -b genes.bed enhancers.bed \
-names genes enhancers -d
Module 5: Interval Manipulation
Expand, contract, and shift genomic intervals.
# Expand regions by 500 bp on each side
bedtools slop -i peaks.bed -g genome.txt -b 500
# Asymmetric: 2000 bp upstream, 500 bp downstream of TSS
bedtools slop -i tss.bed -g genome.txt -l 2000 -r 500
# Strand-aware expansion (upstream = 5' side)
bedtools slop -i genes.bed -g genome.txt -l 1000 -r 200 -s
# Create flanking regions (not overlapping the feature)
bedtools flank -i genes.bed -g genome.txt -b 1000
bedtools flank -i genes.bed -g genome.txt -l 2000 -r 0 -s # upstream only
Key Concepts
Coordinate Systems
BED files use 0-based half-open intervals: start is 0-indexed (like Python), end is exclusive. A region chr1:1000-2000 in BED covers bases 1000–1999 (1000 bases).
chr1 1000 2000 peak1 ← covers positions 1000,1001,...,1999
# BED: 0-based start, exclusive end
# VCF: 1-based position (POS)
# GFF: 1-based start and end (both inclusive)
bedtools converts internally — input format is auto-detected. Problems arise when mixing tools with different conventions.
Sorting Requirements
Most bedtools operations require coordinate-sorted input. Pre-sort with:
sort -k1,1 -k2,2n input.bed > sorted.bed
# For large files, use -S 4G for 4 GB sort buffer
sort -k1,1 -k2,2n -S 4G --parallel=8 input.bed > sorted.bed
The -sorted flag in bedtools intersect uses a sweep algorithm that requires sorted input but uses O(1) memory instead of O(N).
Common Workflows
Workflow 1: ChIP-seq Peak Annotation
Goal: Annotate peaks with overlapping genes, distances to TSS, and filter blacklisted regions.
#!/bin/bash
PEAKS="peaks.bed"
GENES="refseq_genes.bed"
TSS="refseq_tss.bed" # BED with TSS positions
BLACKLIST="encode_blacklist_hg38.bed"
GENOME="hg38.genome"
# 1. Remove blacklisted regions
bedtools subtract -a $PEAKS -b $BLACKLIST -A > peaks_clean.bed
echo "After blacklist filter: $(wc -l < peaks_clean.bed) peaks"
# 2. Annotate with overlapping gene (allow 2 kb from gene body)
bedtools slop -i $GENES -g $GENOME -b 2000 > genes_padded.bed
bedtools intersect -a peaks_clean.bed -b genes_padded.bed -wa -wb \
> peaks_gene_overlap.bed
# 3. For non-overlapping peaks: find nearest gene
bedtools intersect -a peaks_clean.bed -b genes_padded.bed -v > peaks_distal.bed
bedtools closest -a peaks_distal.bed -b $TSS -d > peaks_distal_nearest.bed
echo "Promoter peaks: $(wc -l < peaks_gene_overlap.bed)"
echo "Distal peaks: $(wc -l < peaks_distal.bed)"
Workflow 2: Coverage Analysis for WES Target Regions
Goal: Calculate on-target read depth and coverage breadth for exome sequencing QC.
#!/bin/bash
BAM="sample.deduped.bam"
TARGETS="capture_targets.bed"
# Per-target coverage statistics
bedtools coverage -a $TARGETS -b $BAM > per_target_coverage.bed
# Summary: total targets, mean depth, % at ≥20×
awk 'BEGIN{n=0; depth=0; covered=0}
{n++; depth+=$7; if($8>=20) covered++}
END{printf "Targets: %d\nMean depth: %.1f×\n%% at 20×: %.1f%%\n",
n, depth/n, covered/n*100}' per_target_coverage.bed
# Per-base depth for IGV visualization
bedtools coverage -a $TARGETS -b $BAM -d > per_base_depth.txt
echo "Per-base depth written to per_base_depth.txt"
Key Parameters
| Parameter | Command | Default | Range/Options | Effect |
|---|---|---|---|---|
-f |
intersect, coverage | 1e-9 | 0.0–1.0 | Min fraction of A that must overlap |
-F |
intersect, coverage | 1e-9 | 0.0–1.0 | Min fraction of B that must overlap |
-r |
intersect | — | flag | Require reciprocal overlap (-f AND -F) |
-s |
Most | — | flag | Strand-aware (same strand only) |
-v |
intersect | — | flag | Report features with NO overlap (invert) |
-c |
intersect | — | flag | Append overlap count per A feature |
-d |
merge | 0 | integer | Max gap to merge (bp) |
-bg |
genomecov | — | flag | BEDGRAPH output format |
-scale |
genomecov | 1.0 | float | Multiply coverage by constant (for RPM) |
-sorted |
intersect, closest | — | flag | Use sweep algorithm (sorted input required) |
-b |
slop | — | integer | Expand interval by N bp on both sides |
-D |
closest | — | ref/a/b | Report signed distance (upstream negative) |
Best Practices
-
Always sort before bedtools: Most bedtools commands fail silently on unsorted input. Sort with
sort -k1,1 -k2,2n input.bedbefore any bedtools operation. -
Use
-sortedfor large files: For pre-sorted files,-sortedreduces memory from O(N) to O(1). Required when intersecting multi-gigabyte BED files. -
Check chromosome naming consistency: The single most common failure — some tools use
chr1, others use1. Verify withcut -f1 file.bed | sort -ubefore running intersections. -
Apply blacklist early: Run
bedtools subtract -b blacklist.bed -Abefore any peak analysis. ENCODE blacklists remove artifactual signal in repetitive/high-copy regions. -
Use
-f 0.5 -rfor peak reproducibility: When intersecting peaks across replicates, require 50% reciprocal overlap to avoid spurious short overlaps at interval boundaries. -
Validate BED format: Malformed BED (wrong column count, text in numeric columns) causes silent failures. Test with
bedtools merge -i file.bed 2>&1 | head -5.
Common Recipes
Recipe: Count Feature Overlaps Across Annotation Categories
# Report how many peaks overlap each category (genes, promoters, enhancers)
for category in genes.bed promoters.bed enhancers.bed repeats.bed; do
label=$(basename $category .bed)
count=$(bedtools intersect -a peaks.bed -b $category -u | wc -l)
total=$(wc -l < peaks.bed)
echo "$label: $count/$total ($(echo "scale=1; $count*100/$total" | bc)%)"
done
Recipe: Create Promoter Regions from Gene Annotations
# Extract 2kb upstream of TSS for ChIP annotation
# For genes on + strand: TSS = start; on - strand: TSS = end
awk 'BEGIN{OFS="\t"} $6=="+" {print $1,$2,$2+1,$4,$5,$6}
$6=="-" {print $1,$3-1,$3,$4,$5,$6}' genes.bed > tss.bed
bedtools slop -i tss.bed -g genome.txt -l 2000 -r 500 -s > promoters.bed
echo "Created $(wc -l < promoters.bed) promoter regions"
Recipe: Calculate Intersection Statistics
# Jaccard similarity between two peak sets (0=no overlap, 1=identical)
bedtools sort -i set1.bed > s1.bed
bedtools sort -i set2.bed > s2.bed
bedtools jaccard -a s1.bed -b s2.bed
# Output: intersection union jaccard n_intersections
# 423456 2345678 0.1804 892
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Empty intersect output | Chromosome name mismatch (chr1 vs 1) | Check: cut -f1 a.bed | sort -u vs cut -f1 b.bed | sort -u |
| Memory error on large files | Not using -sorted flag |
Pre-sort inputs and add -sorted to intersect/closest |
getfasta: sequence not found |
FASTA headers differ from BED chr names | Index FASTA: samtools faidx genome.fa; match names exactly |
| Zero coverage everywhere | BAM not indexed or BED/BAM chr mismatch | Run samtools index file.bam; verify chr naming |
| Merge doesn't merge expected features | Input not sorted by coordinate | Sort: sort -k1,1 -k2,2n file.bed | bedtools merge -i stdin |
getfasta produces wrong-strand sequence |
Using -s without strand column in BED |
Ensure BED col 6 has +/-; add strand: awk '{$6="+"; print}' OFS="\t" |
| Off-by-one in coordinates | Mixing 0-based BED and 1-based VCF/GFF | Convert GFF to BED: subtract 1 from start |
| Slow on large genomes | Processing unsorted files | Sort both files; use -sorted; pipe through sort without writing temp files |
Related Skills
- samtools-bam-processing — BAM sorting, filtering, and QC before passing to bedtools
- deeptools-ngs-analysis — normalized coverage bigWig tracks and heatmaps from the BAM files bedtools processes
- pysam-genomic-files — Python-native BAM/BED manipulation for custom logic beyond bedtools
References
- bedtools documentation — command reference and examples
- GitHub: arq5x/bedtools2 — source, releases, issue tracker
- Quinlan & Hall (2010) "BEDTools: a flexible suite of utilities for comparing genomic features" — Bioinformatics 26(6)
- ENCODE blacklist regions — curated problematic genomic regions
skills/genomics-bioinformatics/interval-ops/deeptools-ngs-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill deeptools-ngs-analysis -g -y
SKILL.md
Frontmatter
{
"name": "deeptools-ngs-analysis",
"license": "BSD-3-Clause",
"description": "NGS CLI for ChIP\/RNA\/ATAC-seq. BAM→bigWig with RPGC\/CPM\/RPKM, sample correlation\/PCA, heatmaps\/profiles around features, fingerprints. For alignment use STAR\/BWA; for peak calling use MACS2."
}
deepTools — NGS Data Analysis Toolkit
Overview
deepTools is a command-line toolkit for processing and visualizing high-throughput sequencing data. It converts BAM alignments to normalized coverage tracks (bigWig), performs quality control (correlation, PCA, fingerprint), and generates publication-quality heatmaps and profile plots around genomic features. Supports ChIP-seq, RNA-seq, ATAC-seq, and MNase-seq.
When to Use
- Converting BAM files to normalized bigWig coverage tracks
- Comparing ChIP-seq treatment vs input control (log2 ratio tracks)
- Assessing sample quality: replicate correlation, PCA, coverage depth
- Evaluating ChIP enrichment strength (fingerprint plots)
- Creating heatmaps and profile plots around TSS, peaks, or other genomic regions
- Analyzing ATAC-seq data with Tn5 offset correction
- Generating strand-specific RNA-seq coverage tracks
- For read alignment, use STAR, BWA, or bowtie2 instead
- For peak calling, use MACS2 or HOMER instead
- For BAM/VCF file manipulation, use pysam instead
Prerequisites
pip install deeptools
# Verify installation
bamCoverage --version
Input requirements: BAM files must be sorted and indexed (.bai file present). Generate index with samtools index input.bam. BED files for genomic regions (genes, peaks) in standard 3+ column format.
Quick Start
# Convert BAM to normalized bigWig
bamCoverage --bam sample.bam --outFileName sample.bw \
--normalizeUsing RPGC --effectiveGenomeSize 2913022398 \
--binSize 10 --numberOfProcessors 8
# Create heatmap around TSS
computeMatrix reference-point -S sample.bw -R genes.bed \
-b 3000 -a 3000 --referencePoint TSS -o matrix.gz
plotHeatmap -m matrix.gz -o heatmap.png --colorMap RdBu
Core API
1. BAM to Coverage Conversion
Convert BAM alignments to normalized coverage tracks (bigWig or bedGraph).
# Basic conversion with RPGC normalization
bamCoverage --bam input.bam --outFileName output.bw \
--normalizeUsing RPGC --effectiveGenomeSize 2913022398 \
--binSize 10 --numberOfProcessors 8 \
--extendReads 200 --ignoreDuplicates
# CPM normalization (simpler, no genome size needed)
bamCoverage --bam input.bam --outFileName output.bw \
--normalizeUsing CPM --binSize 10 -p 8
# RNA-seq: strand-specific coverage
bamCoverage --bam rnaseq.bam --outFileName forward.bw \
--filterRNAstrand forward --normalizeUsing CPM -p 8
# IMPORTANT: Never use --extendReads for RNA-seq (spans splice junctions)
2. Sample Comparison
Compare treatment vs control or generate ratio tracks.
# Log2 ratio: treatment / control
bamCompare -b1 treatment.bam -b2 control.bam -o log2ratio.bw \
--operation log2 --scaleFactorsMethod readCount \
--extendReads 200 -p 8
# Subtract control from treatment
bamCompare -b1 treatment.bam -b2 control.bam -o subtract.bw \
--operation subtract --scaleFactorsMethod readCount
3. Quality Control
Assess sample quality, replicate concordance, and enrichment strength.
# Sample correlation heatmap
multiBamSummary bins --bamfiles rep1.bam rep2.bam rep3.bam \
-o counts.npz --binSize 10000 -p 8
plotCorrelation -in counts.npz --corMethod pearson \
--whatToShow heatmap -o correlation.png
# Good: replicates cluster with r > 0.9
# PCA of samples
plotPCA -in counts.npz -o pca.png --plotTitle "Sample PCA"
# ChIP enrichment fingerprint
plotFingerprint -b input.bam chip.bam -o fingerprint.png \
--extendReads 200 --ignoreDuplicates
# Good ChIP: steep rise curve; flat diagonal = poor enrichment
# Coverage depth assessment
plotCoverage -b sample.bam -o coverage.png --ignoreDuplicates -p 8
# Fragment size distribution (paired-end)
bamPEFragmentSize -b sample.bam -o fragsize.png
4. Heatmaps and Profile Plots
Visualize signal around genomic features (TSS, peaks, gene bodies).
# Reference-point mode: signal around TSS
computeMatrix reference-point -S chip.bw -R genes.bed \
-b 3000 -a 3000 --referencePoint TSS -o matrix.gz -p 8
# Scale-regions mode: signal across gene bodies
computeMatrix scale-regions -S chip.bw -R genes.bed \
-b 1000 -a 1000 --regionBodyLength 5000 -o matrix.gz -p 8
# Generate heatmap
plotHeatmap -m matrix.gz -o heatmap.png \
--colorMap RdBu --kmeans 3 --sortUsing mean
# Generate profile plot
plotProfile -m matrix.gz -o profile.png \
--plotType lines --colors blue red
# Multiple signal files: compare marks
computeMatrix reference-point -S h3k4me3.bw h3k27me3.bw -R genes.bed \
-b 3000 -a 3000 --referencePoint TSS -o multi_matrix.gz
plotHeatmap -m multi_matrix.gz -o multi_heatmap.png
5. Read Filtering and Processing
Filter reads before analysis or correct for assay-specific biases.
# Filter by mapping quality and fragment size
alignmentSieve --bam input.bam --outFile filtered.bam \
--minMappingQuality 10 --minFragmentLength 150 \
--maxFragmentLength 700
# ATAC-seq: apply Tn5 offset correction (+4/-5 bp shift)
alignmentSieve --bam atac.bam --outFile shifted.bam --ATACshift
# Then index: samtools index shifted.bam
# GC bias correction (only if significant bias detected)
computeGCBias -b input.bam --effectiveGenomeSize 2913022398 \
-g genome.2bit --GCbiasFrequenciesFile gc_freq.txt -p 8
correctGCBias -b input.bam --effectiveGenomeSize 2913022398 \
--GCbiasFrequenciesFile gc_freq.txt -o corrected.bam
6. Enrichment Analysis
Quantify signal enrichment at specific regions.
# Signal enrichment at peak regions
plotEnrichment -b chip.bam input.bam --BED peaks.bed \
-o enrichment.png --ignoreDuplicates -p 8
Key Concepts
Normalization Methods
| Method | Formula | When to Use | Requires |
|---|---|---|---|
| RPGC | 1× genome coverage | ChIP-seq, ATAC-seq | --effectiveGenomeSize |
| CPM | Counts per million | Any assay, quick comparison | Nothing |
| RPKM | Per kb per million | RNA-seq gene-level | Nothing |
| BPM | Bins per million | Similar to CPM | Nothing |
| None | Raw counts | Not recommended for comparison | Nothing |
Rule: Use RPGC for ChIP-seq/ATAC-seq (accounts for genome size). Use CPM for quick comparisons. Use RPKM for RNA-seq gene-level analysis.
Effective Genome Sizes
| Organism | Assembly | Effective Size |
|---|---|---|
| Human | GRCh38/hg38 | 2,913,022,398 |
| Mouse | GRCm38/mm10 | 2,652,783,500 |
| Zebrafish | GRCz11 | 1,368,780,147 |
| Drosophila | dm6 | 142,573,017 |
| C. elegans | ce10/ce11 | 100,286,401 |
computeMatrix Modes
| Mode | Use When | Key Params |
|---|---|---|
reference-point |
Signal around a fixed point (TSS, peak summit) | -b, -a, --referencePoint |
scale-regions |
Signal across variable-length features (gene bodies) | -b, -a, --regionBodyLength |
Common Workflows
Workflow: ChIP-seq QC and Visualization
#!/bin/bash
# Complete ChIP-seq QC + visualization pipeline
CHIP="chip.bam"
INPUT="input.bam"
GENES="genes.bed"
PEAKS="peaks.bed"
GSIZE=2913022398
THREADS=8
# 1. QC: sample correlation
multiBamSummary bins --bamfiles $INPUT $CHIP -o summary.npz -p $THREADS
plotCorrelation -in summary.npz --corMethod pearson --whatToShow heatmap -o correlation.png
# 2. QC: enrichment fingerprint
plotFingerprint -b $INPUT $CHIP -o fingerprint.png --extendReads 200 --ignoreDuplicates
# 3. Convert to normalized bigWig
bamCoverage --bam $CHIP --outFileName chip.bw --normalizeUsing RPGC \
--effectiveGenomeSize $GSIZE --extendReads 200 --ignoreDuplicates -p $THREADS
# 4. Log2 ratio track
bamCompare -b1 $CHIP -b2 $INPUT -o log2ratio.bw --operation log2 \
--scaleFactorsMethod readCount --extendReads 200 -p $THREADS
# 5. Heatmap at TSS
computeMatrix reference-point -S chip.bw log2ratio.bw -R $GENES \
-b 3000 -a 3000 --referencePoint TSS -o tss_matrix.gz -p $THREADS
plotHeatmap -m tss_matrix.gz -o tss_heatmap.png --colorMap RdBu --kmeans 3
# 6. Profile at peaks
computeMatrix reference-point -S chip.bw -R $PEAKS \
-b 2000 -a 2000 -o peak_matrix.gz -p $THREADS
plotProfile -m peak_matrix.gz -o peak_profile.png
Workflow: ATAC-seq Analysis
#!/bin/bash
ATAC="atac.bam"
PEAKS="atac_peaks.bed"
GSIZE=2913022398
THREADS=8
# 1. Apply Tn5 offset correction (+4/-5 bp)
alignmentSieve --bam $ATAC --outFile shifted.bam --ATACshift -p $THREADS
samtools index shifted.bam
# 2. Generate RPGC-normalized coverage
bamCoverage --bam shifted.bam --outFileName atac.bw \
--normalizeUsing RPGC --effectiveGenomeSize $GSIZE \
--binSize 5 --extendReads -p $THREADS
# 3. Check nucleosome periodicity (expect 200bp/400bp peaks)
bamPEFragmentSize -b shifted.bam -o fragsize.png \
--maxFragmentLength 1000 --binSize 1
# 4. Heatmap at ATAC peaks
computeMatrix reference-point -S atac.bw -R $PEAKS \
-b 2000 -a 2000 -o atac_matrix.gz -p $THREADS
plotHeatmap -m atac_matrix.gz -o atac_heatmap.png --colorMap Blues --kmeans 2
Key Parameters
| Parameter | Tool(s) | Default | Range | Effect |
|---|---|---|---|---|
--normalizeUsing |
bamCoverage, bamCompare | None | RPGC, CPM, RPKM, BPM, None | Coverage normalization method |
--effectiveGenomeSize |
bamCoverage, bamCompare | — | See table above | Required for RPGC normalization |
--binSize |
bamCoverage, multiBamSummary | 50 | 1–10000 | Resolution in bp; smaller = larger files |
--extendReads |
bamCoverage, bamCompare | False | integer (bp) | Extend to fragment length (ChIP: YES, RNA: NO) |
--ignoreDuplicates |
Most tools | False | True/False | Remove PCR duplicates |
--numberOfProcessors |
Most tools | 1 | 1–N cores | Parallel processing |
--operation |
bamCompare | log2 | log2, ratio, subtract, add, mean, reciprocal_ratio | Sample comparison operation |
--referencePoint |
computeMatrix | TSS | TSS, TES, center | Anchor point for reference-point mode |
-b / -a |
computeMatrix | 500 | 100–10000 bp | Upstream/downstream distance from reference |
--kmeans |
plotHeatmap | None | 1–20 | Number of clusters for heatmap rows |
--minMappingQuality |
Most tools | None | 0–60 | Minimum alignment quality filter |
Best Practices
-
Always extend reads for ChIP-seq: Use
--extendReads 200(or actual fragment length) — ChIP fragments are longer than reads. -
Never extend reads for RNA-seq:
--extendReadswould span splice junctions, creating artifacts. -
Anti-pattern — comparing with different normalizations: Always use the same normalization method across all samples in a comparison.
-
Use
--regionfor parameter testing: Test on a single chromosome (--region chr1:1-10000000) before running on the full genome — saves hours. -
Always use
--numberOfProcessors: Most tools parallelize well — use all available cores. -
Anti-pattern — using RPGC without
--effectiveGenomeSize: Will silently produce wrong results. Always specify the correct genome size. -
Run QC before analysis: Check fingerprint and correlation before investing time in heatmaps/profiles. Poor enrichment means downstream visualizations will be noise.
Common Recipes
Recipe: Multi-Sample Correlation Matrix
# Compare 6 samples across the genome
multiBamSummary bins --bamfiles sample{1..6}.bam \
-o all_samples.npz --binSize 10000 -p 8 \
--labels S1 S2 S3 S4 S5 S6
# Pearson correlation heatmap
plotCorrelation -in all_samples.npz --corMethod pearson \
--whatToShow heatmap -o pearson_corr.png --plotNumbers
# Spearman correlation + PCA
plotCorrelation -in all_samples.npz --corMethod spearman \
--whatToShow heatmap -o spearman_corr.png
plotPCA -in all_samples.npz -o pca.png
Recipe: Gene Body Coverage Profile
# Scale-regions mode for gene body analysis
computeMatrix scale-regions -S sample.bw -R genes.bed \
-b 1000 -a 1000 --regionBodyLength 5000 -o gene_body.gz -p 8
plotProfile -m gene_body.gz -o gene_body_profile.png \
--plotType lines --perGroup
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
BAM index not found |
Missing .bai file |
Run samtools index input.bam |
| Out of memory | Large genome, small bin size | Increase --binSize; process with --region chr1 |
| Very slow processing | Single-threaded execution | Add -p 8 (or available cores) |
| bigWig files very large | Bin size too small | Increase --binSize 50 or larger |
| Flat ChIP fingerprint | Poor ChIP enrichment | Biological issue — consider repeating ChIP experiment |
| RNA-seq artifacts at exon boundaries | --extendReads used with RNA-seq |
Remove --extendReads for RNA-seq data |
| ATAC-seq signal offset | Missing Tn5 correction | Apply alignmentSieve --ATACshift before analysis |
| Mismatched genome assemblies | BAM and BED use different assemblies | Verify both use same genome build (hg38 vs hg19) |
Related Skills
- pysam-genomic-files — programmatic BAM/VCF manipulation for custom filtering before deepTools
- matplotlib-scientific-plotting — customize deepTools output figures beyond built-in options
References
- deepTools documentation — official user guide and tool reference
- deepTools Galaxy — web-based interface
- Ramirez et al. (2016) "deepTools2: a next generation web server for deep-sequencing data analysis" — Nucleic Acids Research
skills/genomics-bioinformatics/interval-ops/geniml/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill geniml -g -y
SKILL.md
Frontmatter
{
"name": "geniml",
"license": "BSD-2-Clause",
"description": "Python library for genomic interval ML. Train\/apply region2vec embeddings turning BED regions into vectors, index interval datasets for ML, search embedding space with BEDSpace, and evaluate embedding quality. Use for chromatin accessibility clustering, regulatory element classification, and cross-sample region comparison."
}
Geniml: Genomic Interval Machine Learning
Overview
Geniml is a Python library that bridges genomic interval biology and machine learning. It provides region2vec for learning dense vector representations of genomic regions from BED files, BEDSpace for nearest-neighbor search in embedding space, dataset classes for ML-ready genomic interval loading, and evaluation utilities for embedding quality. Geniml is designed for researchers who want to apply modern ML techniques to chromatin accessibility, histone modification, or other region-based genomic data.
When to Use
- Learn dense embeddings of genomic regions from a collection of BED files to enable ML-based analysis (region2vec)
- Cluster chromatin accessibility peaks or histone modification sites by embedding similarity
- Search for genomic regions similar to a query region using approximate nearest-neighbor search (BEDSpace)
- Build training datasets for ML models from BED-format genomic intervals with a PyTorch-compatible interface
- Compare embedding quality across training runs or datasets using quantitative metrics
- Integrate genomic region representations into custom neural network architectures
- For basic BED file parsing and set operations without ML, use
gtarsorpysam-genomic-filesinstead
Prerequisites
- Python packages:
geniml,torch,numpy,pandas,anndata - Data requirements: BED files (minimum 3 columns: chr, start, end); optionally a pre-built universe file
- Environment: Python 3.8+; GPU optional but recommended for region2vec training on large datasets
pip install geniml torch numpy pandas anndata
Quick Start
from geniml.region2vec import Region2VecExModel
from geniml.io import RegionSet
# Load a collection of BED files and train region2vec embeddings
region_sets = [RegionSet("sample1.bed"), RegionSet("sample2.bed"), RegionSet("sample3.bed")]
model = Region2VecExModel("path/to/universe.bed")
model.train(region_sets, epochs=10, batch_size=32)
# Get embedding for a specific region
embedding = model.encode("chr1", 1000000, 1500000)
print(f"Embedding shape: {embedding.shape}")
# Embedding shape: (100,)
Core API
Module 1: RegionSet — Genomic Interval I/O
Load BED files into geniml's primary data structure for downstream operations.
from geniml.io import RegionSet
# Load a BED file
rs = RegionSet("peaks.bed")
print(f"Loaded {len(rs)} regions")
print(f"First region: {rs[0]}") # Region object: chr, start, end
print(f"Chromosomes: {set(r.chr for r in rs)}")
# Convert to list of Region objects
regions = list(rs)
for r in regions[:3]:
print(f" {r.chr}:{r.start}-{r.end}")
from geniml.io import RegionSet
# Create RegionSet from a list of (chr, start, end) tuples
regions_data = [
("chr1", 100000, 101000),
("chr1", 200000, 201500),
("chr2", 50000, 51200),
]
rs = RegionSet(regions_data)
print(f"RegionSet with {len(rs)} regions from list")
# Access by index
r = rs[0]
print(f"chr={r.chr}, start={r.start}, end={r.end}, width={r.end - r.start}")
Module 2: Universe Building
A universe defines the set of consensus regions used as the vocabulary for region2vec. Build it from a collection of BED files.
from geniml.universe import UniverseBuilder
from geniml.io import RegionSet
# Collect BED files representing diverse samples
bed_files = ["sample1.bed", "sample2.bed", "sample3.bed", "sample4.bed"]
region_sets = [RegionSet(f) for f in bed_files]
# Build universe (consensus non-overlapping regions)
builder = UniverseBuilder()
universe = builder.build(region_sets)
# Save universe to BED file
universe.to_bed("universe.bed")
print(f"Universe size: {len(universe)} consensus regions")
from geniml.universe import UniverseBuilder
from geniml.io import RegionSet
# Build universe with custom parameters
builder = UniverseBuilder(
fraction=0.5, # Region must appear in >= 50% of samples to be included
merge_dist=0, # Merge adjacent regions within this distance (bp)
)
bed_files = [f"sample_{i}.bed" for i in range(1, 11)]
region_sets = [RegionSet(f) for f in bed_files]
universe = builder.build(region_sets)
print(f"Filtered universe: {len(universe)} regions (fraction >= 0.5)")
Module 3: Region2Vec — Training Embeddings
Train word2vec-style embeddings on genomic regions, treating each BED file as a "document" and each region as a "word."
from geniml.region2vec import Region2VecExModel
from geniml.io import RegionSet
# Initialize model with a pre-built universe
model = Region2VecExModel(universe="universe.bed", embedding_dim=100)
# Load training data (collection of BED files = corpus)
bed_files = [f"atac_{i}.bed" for i in range(1, 51)]
region_sets = [RegionSet(f) for f in bed_files]
# Train
model.train(
region_sets,
epochs=20,
batch_size=64,
window_size=5,
min_count=1,
)
print("Training complete")
# Save trained model
model.save("region2vec_model/")
print("Model saved to region2vec_model/")
from geniml.region2vec import Region2VecExModel
# Load a pre-trained model
model = Region2VecExModel.load("region2vec_model/")
# Encode a single genomic region
embedding = model.encode("chr1", 1_000_000, 1_500_000)
print(f"Single region embedding shape: {embedding.shape}")
# Single region embedding shape: (100,)
# Encode an entire BED file → matrix of region embeddings
from geniml.io import RegionSet
rs = RegionSet("query_peaks.bed")
embeddings = model.encode_region_set(rs)
print(f"BED file embeddings shape: {embeddings.shape}")
# BED file embeddings shape: (N_regions, 100)
Module 4: BEDSpace — Embedding Nearest-Neighbor Search
Index a corpus of BED file embeddings for fast similarity search.
from geniml.bedspace import BEDSpace
from geniml.region2vec import Region2VecExModel
from geniml.io import RegionSet
# Build a BEDSpace index from a set of BED files
model = Region2VecExModel.load("region2vec_model/")
bed_files = [f"dataset_{i}.bed" for i in range(1, 101)]
region_sets = [RegionSet(f) for f in bed_files]
bedspace = BEDSpace(model)
bedspace.fit(region_sets, labels=[f"dataset_{i}" for i in range(1, 101)])
bedspace.save("bedspace_index/")
print(f"BEDSpace index built with {len(region_sets)} datasets")
from geniml.bedspace import BEDSpace
from geniml.io import RegionSet
# Load index and query
bedspace = BEDSpace.load("bedspace_index/")
query = RegionSet("query_sample.bed")
# Find top-k most similar datasets
results = bedspace.query(query, k=5)
for rank, (label, score) in enumerate(results, 1):
print(f" Rank {rank}: {label} similarity={score:.4f}")
Module 5: Genomic Interval Datasets for ML
PyTorch-compatible Dataset classes for training ML models on genomic intervals.
from geniml.datasets import TokenizedBEDDataset
from torch.utils.data import DataLoader
# Create a tokenized dataset from multiple BED files + labels
bed_files = ["condition_A_rep1.bed", "condition_A_rep2.bed",
"condition_B_rep1.bed", "condition_B_rep2.bed"]
labels = [0, 0, 1, 1]
dataset = TokenizedBEDDataset(
bed_files=bed_files,
universe="universe.bed",
labels=labels,
)
print(f"Dataset size: {len(dataset)} samples")
# Use with PyTorch DataLoader
loader = DataLoader(dataset, batch_size=8, shuffle=True)
for batch_tokens, batch_labels in loader:
print(f"Batch tokens shape: {batch_tokens.shape}")
print(f"Batch labels: {batch_labels}")
break
from geniml.datasets import RegionEmbeddingDataset
from geniml.region2vec import Region2VecExModel
import torch
# Pre-embed BED files and create a dataset for a classifier
model = Region2VecExModel.load("region2vec_model/")
bed_files = ["pos_1.bed", "pos_2.bed", "neg_1.bed", "neg_2.bed"]
labels = [1, 1, 0, 0]
emb_dataset = RegionEmbeddingDataset(
bed_files=bed_files,
model=model,
labels=labels,
aggregation="mean", # aggregate region embeddings per sample
)
X, y = emb_dataset[0]
print(f"Sample embedding shape: {X.shape}, label: {y}")
# Sample embedding shape: (100,), label: 1
Module 6: Embedding Evaluation
Assess embedding quality using neighborhood overlap and intrinsic metrics.
from geniml.eval import EmbeddingEvaluator
from geniml.region2vec import Region2VecExModel
from geniml.io import RegionSet
model = Region2VecExModel.load("region2vec_model/")
bed_files = [f"sample_{i}.bed" for i in range(1, 21)]
region_sets = [RegionSet(f) for f in bed_files]
evaluator = EmbeddingEvaluator(model)
metrics = evaluator.evaluate(region_sets)
print(f"Neighborhood overlap score: {metrics['neighborhood_overlap']:.4f}")
print(f"Silhouette score: {metrics.get('silhouette', 'N/A')}")
# Higher neighborhood overlap → more biologically coherent embeddings
Key Concepts
region2vec Training Analogy
Region2vec treats each BED file as a "sentence" (a document of co-occurring genomic regions) and each genomic region token (from the universe) as a "word." Word2vec's skip-gram objective is applied: regions that frequently co-occur in BED files are learned to have similar embeddings. This means two regions that tend to be open/active in the same set of samples will have nearby embeddings, enabling meaningful similarity search without explicit labels.
# Conceptual analogy: universe regions = vocabulary tokens
# BED file = sentence = [region_token_1, region_token_2, ...]
# Training: predict co-occurring regions within a sliding window
from geniml.io import RegionSet
rs = RegionSet("sample.bed")
print(f"This BED file contains {len(rs)} 'words' (tokens) from the universe vocabulary")
Universe as Vocabulary
The universe is a non-overlapping, genome-wide set of consensus regions that serves as the token vocabulary for region2vec. A well-chosen universe should cover the genomic regions present in your dataset while being compact enough for efficient training. Regions in a BED file that do not overlap the universe are ignored during training.
Common Workflows
Workflow 1: Train and Search Embeddings from ATAC-seq Peaks
Goal: Embed ATAC-seq peak sets, then find samples most similar to a query.
from geniml.universe import UniverseBuilder
from geniml.region2vec import Region2VecExModel
from geniml.bedspace import BEDSpace
from geniml.io import RegionSet
from pathlib import Path
# Step 1: Collect BED files
bed_dir = Path("atac_peaks/")
bed_files = sorted(bed_dir.glob("*.bed"))
region_sets = [RegionSet(str(f)) for f in bed_files]
labels = [f.stem for f in bed_files]
print(f"Loaded {len(region_sets)} ATAC-seq peak sets")
# Step 2: Build universe
builder = UniverseBuilder(fraction=0.3)
universe = builder.build(region_sets)
universe.to_bed("atac_universe.bed")
print(f"Universe: {len(universe)} regions")
# Step 3: Train region2vec
model = Region2VecExModel(universe="atac_universe.bed", embedding_dim=100)
model.train(region_sets, epochs=15, batch_size=64, window_size=5)
model.save("atac_region2vec/")
# Step 4: Build BEDSpace index
bedspace = BEDSpace(model)
bedspace.fit(region_sets, labels=labels)
bedspace.save("atac_bedspace/")
# Step 5: Query with a new sample
query = RegionSet("new_sample.bed")
results = bedspace.query(query, k=5)
print("Top 5 most similar samples:")
for rank, (label, score) in enumerate(results, 1):
print(f" {rank}. {label} (similarity={score:.4f})")
Workflow 2: ML Classification on Genomic Intervals
Goal: Train a logistic regression classifier on region2vec embeddings to classify cell types.
from geniml.region2vec import Region2VecExModel
from geniml.datasets import RegionEmbeddingDataset
from geniml.io import RegionSet
from torch.utils.data import DataLoader
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
import numpy as np
model = Region2VecExModel.load("atac_region2vec/")
# Prepare labeled dataset (e.g., T-cell vs B-cell ATAC peaks)
bed_files = ["tcell_1.bed", "tcell_2.bed", "tcell_3.bed",
"bcell_1.bed", "bcell_2.bed", "bcell_3.bed"]
labels = [0, 0, 0, 1, 1, 1]
dataset = RegionEmbeddingDataset(
bed_files=bed_files, model=model, labels=labels, aggregation="mean"
)
# Collect embeddings
X = np.array([dataset[i][0].numpy() for i in range(len(dataset))])
y = np.array([dataset[i][1] for i in range(len(dataset))])
print(f"Feature matrix: {X.shape}, labels: {y}")
# Cross-validate a simple classifier
clf = LogisticRegression(max_iter=1000)
scores = cross_val_score(clf, X, y, cv=3, scoring="accuracy")
print(f"Cross-val accuracy: {scores.mean():.3f} ± {scores.std():.3f}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
embedding_dim |
Region2Vec | 100 |
50–512 |
Dimensionality of region embeddings; higher = more expressive but slower |
window_size |
Region2Vec training | 5 |
2–20 |
Context window for skip-gram co-occurrence; larger = broader co-occurrence |
epochs |
Region2Vec training | 10 |
5–100 |
Training iterations; increase for large datasets |
batch_size |
Region2Vec training | 32 |
16–512 |
Mini-batch size; larger batches improve stability |
fraction |
UniverseBuilder | 0.5 |
0.0–1.0 |
Minimum fraction of samples a region must appear in to enter the universe |
aggregation |
RegionEmbeddingDataset | "mean" |
"mean", "sum", "max" |
How per-region embeddings are pooled into a sample-level vector |
k |
BEDSpace query | 10 |
1–corpus size |
Number of nearest neighbors to return |
Common Recipes
Recipe: Load a Pre-Trained Hugging Face region2vec Model
When to use: Apply a community-trained model without local training.
from geniml.region2vec import Region2VecExModel
# Load a pre-trained model from HuggingFace Hub
model = Region2VecExModel("databio/r2v-ChIP-atlas-hg38-v2")
print(f"Model embedding dim: {model.embedding_dim}")
from geniml.io import RegionSet
rs = RegionSet("my_peaks.bed")
embeddings = model.encode_region_set(rs)
print(f"Embeddings shape: {embeddings.shape}")
Recipe: UMAP Visualization of Sample Embeddings
When to use: Visually inspect clustering of samples in embedding space.
from geniml.region2vec import Region2VecExModel
from geniml.io import RegionSet
import numpy as np
model = Region2VecExModel.load("atac_region2vec/")
bed_files = [f"sample_{i}.bed" for i in range(1, 21)]
sample_embeddings = []
for f in bed_files:
rs = RegionSet(f)
emb = model.encode_region_set(rs)
sample_embeddings.append(emb.mean(axis=0)) # mean-pool regions
X = np.array(sample_embeddings)
print(f"Sample embedding matrix: {X.shape}")
try:
import umap
import matplotlib.pyplot as plt
reducer = umap.UMAP(n_components=2, random_state=42)
X_2d = reducer.fit_transform(X)
plt.figure(figsize=(6, 5))
plt.scatter(X_2d[:, 0], X_2d[:, 1], s=40)
for i, f in enumerate(bed_files):
plt.annotate(f, (X_2d[i, 0], X_2d[i, 1]), fontsize=7)
plt.title("Sample embeddings (UMAP)")
plt.savefig("sample_umap.png", dpi=150, bbox_inches="tight")
print("Saved sample_umap.png")
except ImportError:
print("Install umap-learn for UMAP visualization")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FileNotFoundError on universe |
Universe BED path incorrect or not built yet | Run UniverseBuilder.build() and save with .to_bed() before training |
| All embeddings identical or near-zero | Universe has no overlap with training BED files | Verify BED coordinate system matches universe (hg38 vs hg19, chr prefix) |
RuntimeError: CUDA out of memory |
Batch size too large for GPU | Reduce batch_size or use CPU training |
| Very low neighborhood overlap score | Too few training samples or too many epochs (overfitting) | Use ≥20 BED files; tune epochs; try smaller embedding_dim |
| BEDSpace query returns no results | Query regions not present in index universe | Check query BED overlaps universe; use fraction parameter to broaden universe |
| Slow training | Large universe + many BED files | Reduce universe size with higher fraction threshold; use GPU |
Related Skills
- gtars — fast BED file I/O and interval operations for preprocessing before geniml training
- scanpy-scrna-seq — downstream clustering and UMAP visualization of embeddings stored in AnnData
References
- Geniml documentation — official docs and tutorials
- Geniml GitHub repository — source code and examples
- PyPI: geniml — installation and version info
- HuggingFace: databio region2vec models — pre-trained region2vec model collection
skills/genomics-bioinformatics/interval-ops/gtars/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gtars -g -y
SKILL.md
Frontmatter
{
"name": "gtars",
"license": "MIT",
"description": "Rust-backed Python library for fast genomic token arithmetic and BED processing. High-performance BED I\/O, interval set ops (intersect, merge, complement, subtract), region tokenization against a universe, universe construction. Use for preprocessing large BED collections and ML token vocabularies."
}
GTARS: Fast Genomic Token Arithmetic and BED File Processing
Overview
GTARS is a Python library with a Rust-backed core for high-performance genomic interval operations. It provides BED file I/O, set-theoretic interval operations (intersection, union, merge, complement, subtract), genomic region tokenization against a reference universe, and utilities for building consensus universe BED files. GTARS is designed for workflows that process hundreds to thousands of BED files efficiently, serving as a preprocessing engine for ML pipelines (including geniml) and general bioinformatics pipelines.
When to Use
- Read and write large BED files efficiently, leveraging Rust-backed parsing for speed over pure Python alternatives
- Compute genomic interval intersections, merges, complements, or subtracts between BED file pairs or sets
- Tokenize a collection of genomic regions against a fixed universe vocabulary for ML input preparation
- Build consensus universe BED files from a collection of sample BED files
- Count overlap statistics between two BED files without launching bedtools processes
- Preprocess ATAC-seq, ChIP-seq, or ENCODE peak files before feeding into geniml or other ML tools
- For full BED/BAM/SAM reading with CIGAR-level detail, use
pysam-genomic-filesinstead
Prerequisites
- Python packages:
gtars,numpy(optional, for array conversion) - Data requirements: BED format files (3+ columns: chr, start, end); optionally a universe BED file for tokenization
- Environment: Python 3.8+; Rust toolchain not required (pre-built wheels available on PyPI)
pip install gtars
Quick Start
from gtars import GenomicIntervalSet
# Load a BED file and inspect
gis = GenomicIntervalSet("peaks.bed")
print(f"Loaded {len(gis)} intervals")
print(f"First interval: {gis[0]}")
# First interval: chr1:1000-2000
# Intersect two BED files
gis2 = GenomicIntervalSet("other_peaks.bed")
overlap = gis.intersect(gis2)
print(f"Overlapping intervals: {len(overlap)}")
Core API
Module 1: GenomicIntervalSet — BED File I/O
Primary data structure for loading, indexing, and writing genomic intervals.
from gtars import GenomicIntervalSet
# Load from file
gis = GenomicIntervalSet("peaks.bed")
print(f"Intervals loaded: {len(gis)}")
print(f"Chromosomes present: {gis.chromosomes}")
# Access by index
interval = gis[0]
print(f"chr={interval.chr}, start={interval.start}, end={interval.end}")
# Iterate all intervals
for iv in gis:
width = iv.end - iv.start
if width > 5000:
print(f"Wide interval: {iv.chr}:{iv.start}-{iv.end} ({width} bp)")
break
from gtars import GenomicIntervalSet, GenomicInterval
# Create from a list of GenomicInterval objects
intervals = [
GenomicInterval("chr1", 100, 500),
GenomicInterval("chr1", 600, 1200),
GenomicInterval("chr2", 300, 800),
]
gis = GenomicIntervalSet(intervals)
print(f"Created GenomicIntervalSet with {len(gis)} intervals")
# Write to BED file
gis.to_bed("output_intervals.bed")
print("Saved output_intervals.bed")
Module 2: Interval Set Operations
Compute intersections, unions, merges, subtracts, and complements between BED sets.
from gtars import GenomicIntervalSet
peaks_a = GenomicIntervalSet("condition_A.bed")
peaks_b = GenomicIntervalSet("condition_B.bed")
# Intersection: intervals present in both sets
shared = peaks_a.intersect(peaks_b)
print(f"Shared intervals: {len(shared)}")
# Subtraction: intervals in A not overlapping B
a_only = peaks_a.subtract(peaks_b)
print(f"A-specific intervals: {len(a_only)}")
# Union (merge both sets, then merge overlapping)
combined = peaks_a.union(peaks_b)
print(f"Union intervals: {len(combined)}")
from gtars import GenomicIntervalSet
# Merge overlapping/adjacent intervals within a single set
gis = GenomicIntervalSet("fragmented_peaks.bed")
print(f"Before merge: {len(gis)} intervals")
merged = gis.merge()
print(f"After merge: {len(merged)} intervals")
# Complement: genome-wide intervals NOT covered by peaks
# Requires chromosome sizes
chrom_sizes = {"chr1": 248956422, "chr2": 242193529, "chrX": 156040895}
complement = gis.complement(chrom_sizes)
print(f"Complement (uncovered) intervals: {len(complement)}")
Module 3: Tokenization
Convert genomic intervals to integer token IDs against a reference universe vocabulary.
from gtars import Tokenizer
# Initialize tokenizer with a universe BED file
tokenizer = Tokenizer("universe.bed")
print(f"Universe vocabulary size: {len(tokenizer)}")
# Tokenize a single BED file → list of token IDs
from gtars import GenomicIntervalSet
gis = GenomicIntervalSet("sample_peaks.bed")
tokens = tokenizer.tokenize(gis)
print(f"Token IDs: {tokens[:10]} ...")
print(f"Total tokens: {len(tokens)}")
from gtars import Tokenizer, GenomicIntervalSet
tokenizer = Tokenizer("universe.bed")
# Tokenize and convert to numpy array for ML
import numpy as np
gis = GenomicIntervalSet("sample_peaks.bed")
tokens = tokenizer.tokenize(gis)
token_array = np.array(tokens)
print(f"Token array shape: {token_array.shape}, dtype: {token_array.dtype}")
# Build a binary presence/absence vector over the full universe
vocab_size = len(tokenizer)
presence_vector = np.zeros(vocab_size, dtype=np.float32)
presence_vector[token_array] = 1.0
print(f"Presence vector shape: {presence_vector.shape}")
print(f"Fraction of universe covered: {presence_vector.mean():.4f}")
Module 4: Universe Building
Construct a consensus non-overlapping universe from a collection of BED files.
from gtars import UniverseBuilder
# Build universe from multiple BED files
bed_files = ["sample_1.bed", "sample_2.bed", "sample_3.bed", "sample_4.bed"]
builder = UniverseBuilder()
universe = builder.build(bed_files)
print(f"Universe regions: {len(universe)}")
universe.to_bed("consensus_universe.bed")
print("Saved consensus_universe.bed")
from gtars import UniverseBuilder
# Build with coverage threshold (region must appear in >= N% of samples)
bed_files = [f"chip_{i}.bed" for i in range(1, 21)]
builder = UniverseBuilder(fraction=0.25) # present in >= 25% of samples
universe = builder.build(bed_files)
print(f"Universe (fraction>=0.25): {len(universe)} regions")
# Compare thresholds
for frac in [0.1, 0.25, 0.5]:
b = UniverseBuilder(fraction=frac)
u = b.build(bed_files)
print(f" fraction={frac}: {len(u)} regions")
Module 5: Interval Statistics and Utilities
Compute coverage, overlap counts, and basic statistics over BED files.
from gtars import GenomicIntervalSet
gis = GenomicIntervalSet("peaks.bed")
# Basic statistics
widths = [iv.end - iv.start for iv in gis]
import numpy as np
print(f"Interval count: {len(gis)}")
print(f"Mean width (bp): {np.mean(widths):.1f}")
print(f"Median width (bp): {np.median(widths):.1f}")
print(f"Total coverage (bp): {sum(widths):,}")
# Per-chromosome counts
from collections import Counter
chrom_counts = Counter(iv.chr for iv in gis)
for chrom, count in sorted(chrom_counts.items())[:5]:
print(f" {chrom}: {count} intervals")
from gtars import GenomicIntervalSet
# Overlap count matrix between two sets
peaks_a = GenomicIntervalSet("condition_A.bed")
peaks_b = GenomicIntervalSet("condition_B.bed")
# Count how many intervals in A overlap at least one interval in B
overlapping_a = peaks_a.intersect(peaks_b)
pct_overlap = len(overlapping_a) / len(peaks_a) * 100
print(f"A intervals overlapping B: {len(overlapping_a)}/{len(peaks_a)} ({pct_overlap:.1f}%)")
# Filter peaks by minimum width
min_width = 200
filtered = GenomicIntervalSet([iv for iv in peaks_a if (iv.end - iv.start) >= min_width])
print(f"Peaks >= {min_width} bp: {len(filtered)}/{len(peaks_a)}")
filtered.to_bed("filtered_peaks.bed")
Key Concepts
Tokenization and Universe Vocabulary
GTARS tokenization maps genomic regions to integer indices by finding the universe region that best overlaps each query interval. If a query interval does not overlap any universe region, it receives a special out-of-vocabulary (OOV) token. This vocabulary approach is analogous to NLP tokenization and enables treating genomic intervals as discrete tokens for transformer- and embedding-based models.
from gtars import Tokenizer, GenomicIntervalSet
tokenizer = Tokenizer("universe.bed")
# OOV token index is typically 0 or len(tokenizer)
print(f"OOV token ID: {tokenizer.unknown_token}")
gis = GenomicIntervalSet("test.bed")
tokens = tokenizer.tokenize(gis)
oov_count = sum(1 for t in tokens if t == tokenizer.unknown_token)
print(f"OOV rate: {oov_count}/{len(tokens)} ({oov_count/len(tokens)*100:.1f}%)")
Common Workflows
Workflow 1: BED File Preprocessing Pipeline
Goal: Load raw ATAC-seq peaks, filter by width, merge overlaps, and tokenize for ML.
from gtars import GenomicIntervalSet, GenomicInterval, Tokenizer
import numpy as np
# Step 1: Load peaks
peaks = GenomicIntervalSet("atac_peaks_raw.bed")
print(f"Raw peaks: {len(peaks)}")
# Step 2: Filter by minimum width (remove very short noise peaks)
min_width = 150
filtered_intervals = [iv for iv in peaks if (iv.end - iv.start) >= min_width]
peaks_filtered = GenomicIntervalSet(filtered_intervals)
print(f"After width filter (>={min_width} bp): {len(peaks_filtered)}")
# Step 3: Merge overlapping peaks
peaks_merged = peaks_filtered.merge()
print(f"After merge: {len(peaks_merged)}")
peaks_merged.to_bed("atac_peaks_processed.bed")
# Step 4: Tokenize against universe
tokenizer = Tokenizer("universe.bed")
tokens = tokenizer.tokenize(peaks_merged)
token_array = np.array(tokens)
print(f"Token array: {token_array.shape}, OOV rate: {(token_array == tokenizer.unknown_token).mean():.3f}")
# Step 5: Build presence vector for ML input
vocab_size = len(tokenizer)
presence = np.zeros(vocab_size, dtype=np.float32)
valid_tokens = token_array[token_array != tokenizer.unknown_token]
presence[valid_tokens] = 1.0
np.save("sample_presence_vector.npy", presence)
print(f"Saved presence vector: {presence.shape}, coverage={presence.mean():.4f}")
Workflow 2: Batch BED File Statistics
Goal: Compute overlap and coverage statistics across a collection of BED files.
from gtars import GenomicIntervalSet
from pathlib import Path
import pandas as pd
import numpy as np
bed_dir = Path("chip_peaks/")
bed_files = sorted(bed_dir.glob("*.bed"))
reference = GenomicIntervalSet("reference_regions.bed")
records = []
for bed_file in bed_files:
gis = GenomicIntervalSet(str(bed_file))
widths = [iv.end - iv.start for iv in gis]
overlap = gis.intersect(reference)
records.append({
"sample": bed_file.stem,
"n_peaks": len(gis),
"mean_width_bp": np.mean(widths),
"total_coverage_bp": sum(widths),
"overlap_with_ref": len(overlap),
"pct_overlap": len(overlap) / len(gis) * 100 if len(gis) > 0 else 0,
})
df = pd.DataFrame(records)
print(df.to_string(index=False))
df.to_csv("bed_file_statistics.csv", index=False)
print(f"\nSaved bed_file_statistics.csv ({len(df)} samples)")
Workflow 3: Build Universe and Tokenize Corpus
Goal: From scratch — build a universe from a BED corpus and tokenize all files.
from gtars import UniverseBuilder, Tokenizer, GenomicIntervalSet
from pathlib import Path
import numpy as np
bed_dir = Path("atac_peaks/")
bed_files = [str(f) for f in sorted(bed_dir.glob("*.bed"))]
print(f"Building universe from {len(bed_files)} BED files...")
# Build universe
builder = UniverseBuilder(fraction=0.2)
universe = builder.build(bed_files)
universe.to_bed("corpus_universe.bed")
print(f"Universe: {len(universe)} regions → corpus_universe.bed")
# Tokenize all BED files
tokenizer = Tokenizer("corpus_universe.bed")
vocab_size = len(tokenizer)
print(f"Tokenizer vocab size: {vocab_size}")
presence_matrix = []
for f in bed_files:
gis = GenomicIntervalSet(f)
tokens = np.array(tokenizer.tokenize(gis))
presence = np.zeros(vocab_size, dtype=np.float32)
valid = tokens[tokens != tokenizer.unknown_token]
presence[valid] = 1.0
presence_matrix.append(presence)
X = np.stack(presence_matrix)
print(f"Presence matrix: {X.shape} (samples × universe_regions)")
np.save("corpus_presence_matrix.npy", X)
print("Saved corpus_presence_matrix.npy")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
fraction |
UniverseBuilder | 0.5 |
0.0–1.0 |
Minimum fraction of samples a region must appear in to enter universe |
merge_dist |
merge() | 0 |
≥0 bp |
Merge intervals within this distance; 0 merges only overlapping/touching |
min_overlap |
intersect() | 1 bp |
1–region width |
Minimum overlap length required to count as an intersection |
| OOV token | Tokenizer | tokenizer.unknown_token |
— | Token ID for regions not overlapping any universe region |
| Chromosome sizes | complement() | required dict | {chr: length} |
Genome-wide sizes used to compute uncovered complement intervals |
Common Recipes
Recipe: Filter BED File by Chromosome List
When to use: Remove non-canonical chromosomes (patches, alternative contigs) before analysis.
from gtars import GenomicIntervalSet, GenomicInterval
gis = GenomicIntervalSet("raw_peaks.bed")
canonical_chroms = {f"chr{i}" for i in list(range(1, 23)) + ["X", "Y", "M"]}
filtered = GenomicIntervalSet([iv for iv in gis if iv.chr in canonical_chroms])
filtered.to_bed("canonical_peaks.bed")
print(f"Canonical peaks: {len(filtered)}/{len(gis)}")
Recipe: Compute Pairwise Jaccard Similarity Between BED Sets
When to use: Measure similarity between two peak sets without external tools.
from gtars import GenomicIntervalSet
def jaccard(a: GenomicIntervalSet, b: GenomicIntervalSet) -> float:
intersection = len(a.intersect(b))
union = len(a.union(b))
return intersection / union if union > 0 else 0.0
peaks_a = GenomicIntervalSet("sample_a.bed")
peaks_b = GenomicIntervalSet("sample_b.bed")
j = jaccard(peaks_a, peaks_b)
print(f"Jaccard similarity: {j:.4f}")
Expected Outputs
- GenomicIntervalSet: iterable of
GenomicIntervalobjects with.chr,.start,.end - BED files: written via
.to_bed()— 3-column tab-separated (chr, start, end) - Token arrays:
list[int]fromtokenizer.tokenize()— convert withnp.array(tokens) - Presence vectors:
np.ndarrayof shape(vocab_size,)built from token IDs - Universe BED: non-overlapping consensus regions written via
universe.to_bed()
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: gtars |
Package not installed | pip install gtars; check Python version ≥ 3.8 |
| All intervals report OOV token | BED coordinate system mismatch | Verify both BED and universe use same genome assembly and chr prefix convention |
intersect returns empty set |
Non-overlapping chromosomes or shifted coordinates | Check chromosome names match exactly (e.g., chr1 vs 1) |
| Very large universe (>5M regions) | Low fraction threshold with many short samples |
Increase fraction to 0.3–0.5; pre-filter BED files to remove noise peaks |
to_bed() produces unsorted output |
Intervals added in arbitrary order | Sort output: sorted_gis = GenomicIntervalSet(sorted(gis, key=lambda r: (r.chr, r.start))) |
| Memory error on large BED collections | Holding all intervals in memory | Process files in batches; use streaming/chunked loading if available |
Related Skills
- geniml — uses GTARS-built universes and tokenizers for region2vec training and BEDSpace indexing
- pysam-genomic-files — for BAM/SAM/VCF-level access with CIGAR and read-level detail
References
- GTARS GitHub repository — source, issue tracker, and changelog
- PyPI: gtars — installation instructions and version history
- Geniml documentation — ecosystem context; GTARS is the preprocessing engine for geniml
skills/genomics-bioinformatics/macs3-peak-calling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill macs3-peak-calling -g -y
SKILL.md
Frontmatter
{
"name": "macs3-peak-calling",
"license": "BSD-3-Clause",
"description": "Poisson-model peak caller for ChIP-seq\/ATAC-seq BAMs. MACS3 callpeak finds enriched regions (TF sites or histone marks) vs input\/IgG; outputs BED narrowPeak\/broadPeak for motif analysis, annotation, and differential binding. Use narrow peaks for TF ChIP-seq and ATAC-seq; broad for H3K27me3, H3K9me3, and other broad marks."
}
MACS3 — ChIP-seq and ATAC-seq Peak Caller
Overview
MACS3 (Model-based Analysis of ChIP-seq) identifies regions of significant read enrichment (peaks) from ChIP-seq, ATAC-seq, CUT&RUN, and CUT&TAG experiments. It models the fragment length distribution from paired-end data or estimates it from mono-nucleosomal read shifting in single-end data, then applies a Poisson model to identify fold-enrichment over an input/IgG control. MACS3 produces BED-format narrowPeak (for transcription factors) or broadPeak (for histone marks) files with signal and q-value tracks for visualization in IGV or UCSC Genome Browser.
When to Use
- Calling transcription factor binding peaks from ChIP-seq experiments (use
--nomodel --extsize 200or let MACS3 estimate fragment length) - Identifying open chromatin regions from ATAC-seq experiments (use
--nomodel --shift -100 --extsize 200 -f BAMPE) - Calling broad histone modification peaks (H3K27me3, H3K9me3, H3K36me3) with
--broad - Generating peak signal tracks (bedGraph/bigWig) for genome browser visualization with
-B --SPMR - Performing differential binding analysis: MACS3 peaks as input to DiffBind or DESeq2
- Use HMMRATAC (part of MACS3) for nucleosome-resolution ATAC-seq peak calling
- Use SPP or HOMER as alternatives; MACS3 is the ENCODE-recommended standard
Prerequisites
- Python packages:
macs3(Python ≥ 3.8) - Input: Sorted BAM files (with index) from ChIP-seq or ATAC-seq alignment (e.g., using STAR or Bowtie2)
- Optional: Input/IgG control BAM for background normalization
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v macs3first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run macs3rather than baremacs3.
# Install with pip or conda
pip install macs3
# or
conda install -c bioconda macs3
# Verify
macs3 --version
# macs3 3.0.2
Quick Start
# Call peaks for TF ChIP-seq (narrow peaks, with input control)
macs3 callpeak \
-t chip.bam \
-c input.bam \
-f BAM \
-g hs \
-n sample_tf \
--outdir peaks/ \
-q 0.05
# Output: peaks/sample_tf_peaks.narrowPeak
wc -l peaks/sample_tf_peaks.narrowPeak
Workflow
Step 1: Prepare Input BAM Files
MACS3 requires sorted, indexed BAM files from genome alignment.
# Sort and index ChIP and control BAMs (if not already done)
samtools sort -@ 8 chip_raw.bam -o chip.bam
samtools sort -@ 8 input_raw.bam -o input.bam
samtools index chip.bam
samtools index input.bam
# Check read counts
echo "ChIP reads: $(samtools view -c -F 4 chip.bam)"
echo "Input reads: $(samtools view -c -F 4 input.bam)"
Step 2: Call Narrow Peaks (TF ChIP-seq)
Use the default mode for transcription factor binding site identification.
# TF ChIP-seq with input control
macs3 callpeak \
-t chip.bam \
-c input.bam \
-f BAM \
-g hs \
-n tf_chip \
--outdir peaks/ \
-q 0.05 \
--keep-dup auto
echo "Peaks called: $(wc -l < peaks/tf_chip_peaks.narrowPeak)"
echo "Summit file: peaks/tf_chip_summits.bed"
# Without input control (less recommended)
macs3 callpeak \
-t chip.bam \
-f BAM \
-g hs \
-n tf_noinput \
--outdir peaks/ \
--nolambda
Step 3: Call Broad Peaks (Histone Marks)
Use --broad for spread histone modifications like H3K27me3 or H3K36me3.
# H3K27me3 broad histone mark
macs3 callpeak \
-t h3k27me3.bam \
-c input.bam \
-f BAM \
-g hs \
-n h3k27me3 \
--outdir peaks/ \
--broad \
--broad-cutoff 0.1 \
-q 0.05
echo "Broad peaks: $(wc -l < peaks/h3k27me3_peaks.broadPeak)"
# H3K4me3 (sharp mark — use narrow peaks)
macs3 callpeak \
-t h3k4me3.bam \
-c input.bam \
-f BAM \
-g hs \
-n h3k4me3 \
--outdir peaks/ \
-q 0.05
Step 4: Call ATAC-seq Peaks
ATAC-seq requires special handling for the Tn5 insertion site.
# ATAC-seq with paired-end BAM (recommended)
macs3 callpeak \
-t atac.bam \
-f BAMPE \
-g hs \
-n atac_sample \
--outdir peaks/ \
--nomodel \
--nolambda \
-q 0.05 \
--keep-dup all
echo "ATAC peaks: $(wc -l < peaks/atac_sample_peaks.narrowPeak)"
# Single-end ATAC-seq: shift reads to center on Tn5 cut site
macs3 callpeak \
-t atac_se.bam \
-f BAM \
-g hs \
-n atac_se \
--outdir peaks/ \
--nomodel \
--shift -100 \
--extsize 200 \
--keep-dup all
Step 5: Generate Signal Tracks for Visualization
Produce bedGraph and bigWig files for genome browser visualization.
# Generate bedGraph normalized to million reads (SPMR)
macs3 callpeak \
-t chip.bam \
-c input.bam \
-f BAM \
-g hs \
-n chip_track \
--outdir tracks/ \
-B \
--SPMR \
--keep-dup auto
# Convert bedGraph to bigWig for IGV/UCSC
# Requires bedGraphToBigWig and chrom.sizes
sort -k1,1 -k2,2n tracks/chip_track_treat_pileup.bdg > tracks/chip_sorted.bdg
bedGraphToBigWig tracks/chip_sorted.bdg genome/hg38.chrom.sizes tracks/chip.bw
echo "BigWig track: tracks/chip.bw"
Step 6: Annotate and Analyze Peaks
Parse narrowPeak output and annotate peaks to genomic features.
import pandas as pd
# Load narrowPeak file
# Columns: chrom, start, end, name, score, strand, signalValue, pValue, qValue, peak
cols = ["chrom", "start", "end", "name", "score", "strand",
"signalValue", "pValue", "qValue", "peak"]
peaks = pd.read_csv("peaks/tf_chip_peaks.narrowPeak", sep="\t",
header=None, names=cols)
print(f"Total peaks: {len(peaks)}")
print(f"Peaks on chr1: {(peaks['chrom'] == 'chr1').sum()}")
print(f"Median peak width: {(peaks['end'] - peaks['start']).median():.0f} bp")
print(f"Peaks with q-value < 0.01: {(peaks['qValue'] > 2).sum()}") # -log10(q) > 2
# Filter high-confidence peaks
high_conf = peaks[peaks["qValue"] > 2].copy() # q < 0.01
high_conf["width"] = high_conf["end"] - high_conf["start"]
print(f"\nHigh-confidence peaks: {len(high_conf)}")
high_conf.to_csv("high_confidence_peaks.bed", sep="\t", index=False, header=False,
columns=["chrom", "start", "end", "name", "score", "strand"])
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-t / --treatment |
required | BAM/BED/SAM | ChIP or ATAC treatment file |
-c / --control |
— | BAM/BED/SAM | Input/IgG control; omit --nolambda if absent |
-g / --gsize |
required | hs, mm, ce, dm, or integer |
Effective genome size; hs=2.7e9 (human), mm=1.87e9 (mouse) |
-q / --qvalue |
0.05 |
0–1 | FDR threshold for peak calling |
-p / --pvalue |
— | 0–1 | P-value cutoff (use instead of q-value for strict control) |
--broad |
off | flag | Call broad peaks for diffuse histone marks |
--broad-cutoff |
0.1 |
0–1 | Q-value cutoff for broad region merging |
--nomodel |
off | flag | Skip fragment length modeling; required for ATAC-seq |
--extsize |
200 |
50–1000 | Fragment extension size when --nomodel is set |
--shift |
0 |
-500–500 | Read shift in bp; use -100 with --extsize 200 for ATAC-seq |
--keep-dup |
1 |
auto, all, integer |
Duplicate handling; auto uses Poisson model, all keeps all (ATAC-seq) |
-B / --bdg |
off | flag | Write bedGraph signal tracks |
--SPMR |
off | flag | Normalize bedGraph to signal per million reads |
Common Recipes
Recipe 1: Batch Peak Calling for Multiple Samples
#!/bin/bash
# Call peaks for multiple ChIP-seq samples with the same input
INPUT="input.bam"
GENOME="hs"
OUTDIR="peaks"
mkdir -p "$OUTDIR"
SAMPLES=(H3K4me3 H3K27ac H3K27me3 CTCF)
MODES=(narrow narrow broad narrow)
for i in "${!SAMPLES[@]}"; do
sample="${SAMPLES[$i]}"
mode="${MODES[$i]}"
echo "Calling peaks: $sample ($mode)"
if [ "$mode" == "broad" ]; then
BROAD_FLAG="--broad --broad-cutoff 0.1"
else
BROAD_FLAG=""
fi
macs3 callpeak \
-t "${sample}.bam" \
-c "$INPUT" \
-f BAM \
-g "$GENOME" \
-n "$sample" \
--outdir "$OUTDIR" \
$BROAD_FLAG \
-q 0.05 \
--keep-dup auto
echo "$sample: $(wc -l < $OUTDIR/${sample}_peaks.*Peak) peaks"
done
Recipe 2: Reproducible Peaks with IDR (Irreproducible Discovery Rate)
# Call peaks on individual replicates (lenient thresholds for IDR)
for rep in rep1 rep2; do
macs3 callpeak \
-t "chip_${rep}.bam" \
-c input.bam \
-f BAM \
-g hs \
-n "tf_${rep}" \
--outdir peaks/ \
-p 0.1 \
--keep-dup auto
done
# Run IDR to find reproducible peaks
# pip install idr
idr --samples peaks/tf_rep1_peaks.narrowPeak peaks/tf_rep2_peaks.narrowPeak \
--input-file-type narrowPeak \
--output-file peaks/tf_idr_peaks.txt \
--idr-threshold 0.05 \
--plot
echo "IDR peaks: $(wc -l < peaks/tf_idr_peaks.txt)"
Expected Outputs
| Output | Format | Description |
|---|---|---|
*_peaks.narrowPeak |
BED6+4 | Narrow peaks with signal, p-value, q-value, summit offset |
*_peaks.broadPeak |
BED6+3 | Broad peaks (when --broad): chrom, start, end, signal, p-val, q-val |
*_summits.bed |
BED3+2 | Peak summit positions (1 bp) with score; use for motif analysis |
*_treat_pileup.bdg |
bedGraph | Treatment signal track (when -B) |
*_control_lambda.bdg |
bedGraph | Control/local lambda track (when -B) |
*_model.r |
R script | Fragment size model; run Rscript *_model.r to plot |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Very few peaks called | Stringent q-value or low read depth | Relax to -p 1e-3; check sequencing depth (≥10M aligned reads recommended) |
| Too many peaks (>100k) | Threshold too loose or no input control | Add --control input.bam; use -q 0.01; filter on signalValue |
| Peak calling fails with "no reads" | BAM file is not sorted or indexed | Run samtools sort and samtools index before MACS3 |
| ATAC-seq peaks in mitochondria | High mtDNA content | Filter: `samtools view -h chip.bam |
| Fragment model fails | Too few reads or unusual read length | Add --nomodel --extsize 200 to skip modeling |
| bedGraph output very large | High coverage data without normalization | Add --SPMR to normalize to signal per million reads |
--broad misses narrow peaks |
Signal is actually sharp | Check ChIP target: TFs and H3K4me3 need narrow mode |
gsize mismatch |
Using wrong genome size for assembly | Use hs for hg19/hg38, mm for mm9/mm10; or provide exact integer |
References
- MACS3 GitHub: macs3-project/MACS — source code, documentation, and changelog
- Zhang Y et al. (2008) "Model-based Analysis of ChIP-Seq (MACS)" — Genome Biology 9:R137. DOI:10.1186/gb-2008-9-9-r137
- ENCODE ATAC-seq pipeline — ENCODE standardized ATAC-seq workflow using MACS3
- IDR framework — irreproducible discovery rate for reproducible peak calls
skills/genomics-bioinformatics/qc/busco-status-interpretation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill busco-status-interpretation -g -y
SKILL.md
Frontmatter
{
"name": "busco-status-interpretation",
"license": "CC-BY-4.0",
"description": "Guide to interpreting BUSCO completeness statuses: why Duplicated BUSCOs count as complete, parsing output files, computing\/comparing completeness across proteomes\/genomes, common counting mistakes. Use when running BUSCO QC, comparing assemblies, or reporting completeness. See also: prokka-genome-annotation for annotation workflows feeding BUSCO."
}
BUSCO Status Interpretation Guide
Overview
BUSCO (Benchmarking Universal Single-Copy Orthologs) is the standard tool for assessing genome, transcriptome, and proteome completeness by searching for conserved single-copy orthologs from the OrthoDB database. Correct interpretation of BUSCO output is essential for genome quality assessment, comparative genomics, and publication-ready reporting. The most common analytical error is excluding Duplicated BUSCOs from completeness counts, which artificially penalizes polyploid organisms and assemblies with legitimate gene duplications.
This guide covers BUSCO status categories, output file formats, parsing strategies, cross-proteome comparisons, lineage dataset selection, and common pitfalls in BUSCO interpretation.
Key Concepts
BUSCO Status Categories
BUSCO assigns each searched ortholog one of four statuses:
| Status | Abbreviation | Meaning | Count as Complete? |
|---|---|---|---|
| Complete (single-copy) | S | Found exactly once in the genome/proteome | YES |
| Duplicated | D | Found more than once (multiple copies) | YES |
| Fragmented | F | Partial match, likely incomplete gene model | NO |
| Missing | M | Not detected at all | NO |
The headline completeness percentage (C%) reported by BUSCO is always S + D combined. Individual category counts (S, D, F, M) are reported for transparency and should be included in publications.
Why Duplicated Equals Complete
A Duplicated BUSCO means the ortholog IS present and fully intact in the genome or proteome -- it simply exists in more than one copy. This can occur through:
- Whole-genome duplication (common in plants, fish, and amphibians)
- Tandem or segmental duplication events
- Recent polyploidy
- Proteomes containing multiple isoforms per gene
The gene is not incomplete or absent. Excluding Duplicated BUSCOs from completeness counts would incorrectly penalize polyploid organisms, recently duplicated genomes, or proteomes that include isoform-level annotations. The correct completeness formula is always:
Completeness (%) = (Complete_single_copy + Duplicated) / Total_BUSCOs * 100
A high Duplicated fraction is not inherently problematic -- it is biologically informative. For example, the zebrafish genome (a teleost with an ancient whole-genome duplication) routinely shows 15-25% Duplicated BUSCOs, and this is expected.
BUSCO Output Formats
BUSCO produces two primary output formats relevant to downstream analysis:
Short summary format -- a single-line notation found in short_summary.*.txt:
C:95.0%[S:90.0%,D:5.0%],F:3.0%,M:2.0%,n:255
Where C = Complete (S + D), S = Single-copy, D = Duplicated, F = Fragmented, M = Missing, and n = total BUSCO groups searched.
Full table format -- a TSV file (full_table.tsv) with per-ortholog results containing columns for BUSCO ID, Status, Sequence, Score, and Length. This file enables detailed per-gene analysis, filtering, and cross-species comparisons.
Decision Framework
When deciding whether and how to use BUSCO for quality assessment:
Question: What are you assessing?
├── Genome assembly completeness
│ ├── Draft assembly → Run BUSCO in genome mode
│ └── Polished/final assembly → Run BUSCO in genome mode, report in publication
├── Transcriptome completeness
│ └── De novo assembly → Run BUSCO in transcriptome mode (expect higher D%)
├── Proteome / annotation completeness
│ └── Predicted proteins → Run BUSCO in protein mode
└── Comparing multiple assemblies
└── Same lineage dataset across all → Use compare_proteome_completeness pattern
Lineage Dataset Selection
| Organism type | Recommended lineage | Example dataset | Notes |
|---|---|---|---|
| Broad eukaryotic screen | eukaryota | eukaryota_odb10 |
Low resolution, useful for initial checks |
| Vertebrate | vertebrata or class-level | mammalia_odb10, actinopterygii_odb10 |
Class-level gives better resolution |
| Insect | insecta or order-level | diptera_odb10, hymenoptera_odb10 |
Order-level preferred when available |
| Plant | viridiplantae or more specific | embryophyta_odb10, eudicots_odb10 |
Plants often show high D% due to polyploidy |
| Fungus | fungi or division-level | ascomycota_odb10, basidiomycota_odb10 |
Match to known phylogenetic placement |
| Bacterium | bacteria or phylum-level | proteobacteria_odb10 |
Use --auto-lineage-prok for unknown bacteria |
General rule: Use the most specific lineage dataset that encompasses your organism. More specific datasets contain more BUSCOs and provide higher resolution, but using a dataset that does not include your organism will produce misleadingly low scores.
Best Practices
-
Always report all four categories (S, D, F, M): Do not report only the headline C% value. Reviewers and readers need the breakdown to assess whether high completeness comes from single-copy genes (expected for haploid organisms) or duplicated genes (expected for polyploids). This is now a standard expectation in genome papers.
-
Use the same lineage dataset for all comparisons: When comparing assemblies or proteomes, every run must use the identical lineage dataset and BUSCO version. Mixing lineage datasets (e.g., comparing one assembly run with
eukaryota_odb10against another withmetazoa_odb10) produces incomparable results. -
Choose the most specific lineage available: More specific lineage datasets provide more BUSCO markers and finer resolution. A vertebrate genome assessed with
eukaryota_odb10(255 markers) gives a much coarser picture than one assessed withmammalia_odb10(9,226 markers). -
Interpret Duplicated percentage in biological context: High D% in plants, teleost fish, or salmonids is expected due to known whole-genome duplication events. High D% in a haploid bacterium, however, may indicate assembly artifacts (e.g., uncollapsed haplotypes or contamination).
-
Run BUSCO on the correct input type: Use genome mode for assemblies (FASTA of contigs/scaffolds), transcriptome mode for de novo transcriptome assemblies, and protein mode for predicted proteomes. Using the wrong mode produces misleading results because BUSCO applies different search strategies for each.
-
Include BUSCO version and dataset in methods sections: Reproducibility requires reporting the exact BUSCO version, OrthoDB dataset version, and any non-default parameters used. Example: "Completeness was assessed with BUSCO v5.4.7 using the mammalia_odb10 dataset."
-
Validate with BUSCO's built-in plotting: Use
generate_plot.pyto create the standard BUSCO stacked bar chart for visual comparison across assemblies. This standardized visualization is widely recognized by reviewers.
Common Pitfalls
-
Counting only single-copy BUSCOs as "complete": This is the most frequent error. Filtering for
Status == 'Complete'alone misses all Duplicated entries, which are fully intact orthologs.- How to avoid: Always filter for both statuses:
df['Status'].isin(['Complete', 'Duplicated']). Verify your total matches the C% in the short summary.
- How to avoid: Always filter for both statuses:
-
Comparing results across different lineage datasets: BUSCO scores from
eukaryota_odb10(255 groups) andinsecta_odb10(1,367 groups) are not comparable because they search for different sets of orthologs with different expected counts.- How to avoid: Standardize on a single lineage dataset for all assemblies in a comparison. Document the dataset in your methods.
-
Interpreting high Duplicated percentage as an assembly error: For polyploid organisms (many plants, some fish, some amphibians), high D% is biologically correct. Flagging it as an error can lead to unnecessary reassembly or incorrect filtering.
- How to avoid: Check the organism's known ploidy level and duplication history before interpreting D%. Compare against published BUSCO results for closely related species.
-
Using a lineage dataset that does not encompass the organism: Running a fungal genome through
insecta_odb10will produce near-zero completeness, not because the assembly is poor but because the wrong orthologs are being searched.- How to avoid: Use
--auto-lineagefor unknown organisms, or verify phylogenetic placement before selecting a dataset. Check the OrthoDB taxonomy browser.
- How to avoid: Use
-
Ignoring Fragmented BUSCOs during troubleshooting: A high Fragmented percentage often indicates real problems -- truncated gene models, poor assembly in genic regions, or incomplete polishing -- that are actionable.
- How to avoid: Investigate the full_table.tsv for Fragmented entries. Check whether they cluster in specific genomic regions or functional categories. Consider additional polishing rounds if F% is above 5-10%.
-
Not accounting for BUSCO version differences: BUSCO v3, v4, and v5 use different algorithms, datasets, and scoring thresholds. Results are not directly comparable across major versions.
- How to avoid: Re-run all samples with the same BUSCO version when performing comparisons. Note the version in all reports.
-
Reporting completeness without the total BUSCO count (n): Saying "95% complete" is ambiguous without knowing whether that is 95% of 255 BUSCOs (eukaryota) or 95% of 9,226 BUSCOs (mammalia).
- How to avoid: Always report n alongside percentages. Use the notation format:
C:95.0%[S:90.0%,D:5.0%],F:3.0%,M:2.0%,n:255.
- How to avoid: Always report n alongside percentages. Use the notation format:
Workflow
-
Select lineage dataset
- Identify the organism's taxonomic placement
- Choose the most specific available OrthoDB lineage dataset
- If uncertain, run
busco --auto-lineagefirst
-
Run BUSCO
- Execute BUSCO in the appropriate mode (genome, transcriptome, or protein)
- Record the exact command, version, and dataset for reproducibility
-
Parse short summary
- Extract the C/S/D/F/M/n values from the short summary file:
import re
def parse_busco_summary(filepath):
"""Parse BUSCO short summary file."""
with open(filepath) as f:
text = f.read()
# Extract the summary line
match = re.search(
r'C:(\d+\.?\d*)%\[S:(\d+\.?\d*)%,D:(\d+\.?\d*)%\],'
r'F:(\d+\.?\d*)%,M:(\d+\.?\d*)%,n:(\d+)',
text
)
if match:
return {
'complete_pct': float(match.group(1)), # S + D
'single_copy_pct': float(match.group(2)),
'duplicated_pct': float(match.group(3)),
'fragmented_pct': float(match.group(4)),
'missing_pct': float(match.group(5)),
'total': int(match.group(6))
}
return None
- Parse full table for detailed analysis
- Load the full_table.tsv for per-ortholog investigation:
import pandas as pd
def parse_busco_full_table(filepath):
"""Parse BUSCO full_table.tsv output."""
df = pd.read_csv(filepath, sep='\t', comment='#',
names=['Busco_id', 'Status', 'Sequence', 'Score', 'Length'])
# Count by status
counts = df['Status'].value_counts()
print(counts)
# Complete = Complete + Duplicated
n_complete = counts.get('Complete', 0) + counts.get('Duplicated', 0)
print(f"\nTotal complete (S+D): {n_complete}")
return df
- Count complete BUSCOs correctly
- Include both Complete and Duplicated statuses:
def count_complete_buscos(busco_results):
"""Count complete BUSCOs (single-copy + duplicated).
Args:
busco_results: DataFrame with columns including 'Status'
Status values: 'Complete', 'Duplicated', 'Fragmented', 'Missing'
Returns:
int: Count of complete orthologs
"""
complete_statuses = ['Complete', 'Duplicated']
n_complete = busco_results['Status'].isin(complete_statuses).sum()
n_single = (busco_results['Status'] == 'Complete').sum()
n_duplicated = (busco_results['Status'] == 'Duplicated').sum()
n_fragmented = (busco_results['Status'] == 'Fragmented').sum()
n_missing = (busco_results['Status'] == 'Missing').sum()
print(f"Complete (single-copy): {n_single}")
print(f"Duplicated: {n_duplicated}")
print(f"Total complete: {n_complete} (single + duplicated)")
print(f"Fragmented: {n_fragmented}")
print(f"Missing: {n_missing}")
return n_complete
- Common mistake to avoid:
# WRONG: Only counting single-copy as "complete"
n_complete = (busco_results['Status'] == 'Complete').sum() # Misses duplicated!
# CORRECT: Count both single-copy and duplicated
n_complete = busco_results['Status'].isin(['Complete', 'Duplicated']).sum()
- Compare across assemblies or proteomes
- When benchmarking multiple assemblies, compute completeness uniformly:
def compare_proteome_completeness(busco_results_dict):
"""Compare BUSCO completeness across multiple proteomes.
Args:
busco_results_dict: {proteome_name: busco_dataframe}
"""
summary = []
for name, df in busco_results_dict.items():
n_complete = df['Status'].isin(['Complete', 'Duplicated']).sum()
n_total = len(df)
pct = 100 * n_complete / n_total
summary.append({
'Proteome': name,
'Complete': n_complete,
'Total': n_total,
'Completeness_pct': round(pct, 1)
})
summary_df = pd.DataFrame(summary).sort_values('Completeness_pct', ascending=False)
print(summary_df.to_string(index=False))
return summary_df
- Report results
- Include all four categories (S, D, F, M) and the total (n)
- Use BUSCO notation format in text and generate the standard bar plot for figures
- State the BUSCO version, lineage dataset, and mode in the methods section
Further Reading
- BUSCO User Guide -- Official documentation covering installation, usage modes, lineage datasets, and interpretation guidelines
- Manni et al. (2021) "BUSCO Update" -- The BUSCO v5 paper describing the current framework, metaeuk integration, and auto-lineage selection (Molecular Biology and Evolution)
- OrthoDB -- The underlying database of orthologs that BUSCO uses; useful for understanding lineage dataset composition and ortholog definitions
- Simao et al. (2015) "BUSCO" -- The original BUSCO paper establishing the completeness assessment framework (Bioinformatics)
Related Skills
prokka-genome-annotation-- Prokaryotic genome annotation pipeline; BUSCO is commonly run on Prokka-predicted proteomes to assess annotation completenesssamtools-bam-processing-- BAM file processing; alignment quality metrics complement BUSCO completeness for assembly QCmultiqc-qc-reports-- Aggregated QC reporting; MultiQC can incorporate BUSCO results into unified quality reports across samples
skills/genomics-bioinformatics/qc/fastp-fastq-preprocessing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill fastp-fastq-preprocessing -g -y
SKILL.md
Frontmatter
{
"name": "fastp-fastq-preprocessing",
"license": "MIT",
"description": "All-in-one FASTQ QC and adapter trimming. Auto-detects Illumina adapters, filters low-quality reads, corrects paired-end overlaps, emits HTML+JSON QC in one pass. 3-10x faster than Trim Galore\/Trimmomatic. First step before STAR, BWA-MEM2, or Salmon."
}
fastp — Fast FASTQ Quality Control and Adapter Trimming
Overview
fastp performs adapter trimming, quality filtering, and QC reporting for Illumina FASTQ files in a single multi-threaded pass. It automatically detects adapter sequences from paired-end read overlaps — eliminating the need to specify adapters manually. fastp corrects mismatches in paired-end overlap regions, filters reads by quality score and length, removes polyX tails (polyA for RNA-seq), and generates interactive HTML and machine-readable JSON QC reports. Being 3–10× faster than Trim Galore and Trimmomatic while providing comparable or better results, fastp has become the standard preprocessing step before alignment in WGS, RNA-seq, and ChIP-seq pipelines.
When to Use
- Trimming Illumina adapters and low-quality bases before alignment in any NGS pipeline (RNA-seq, WGS, WES, ChIP-seq, ATAC-seq)
- Generating per-sample QC reports (HTML + JSON) as the first step of a pipeline, before MultiQC aggregation
- Processing paired-end reads where adapter auto-detection from overlap is preferred over manual adapter specification
- Removing polyA tails from RNA-seq reads from 3′ end-enriched protocols (Smart-seq, QuantSeq)
- Splitting a FASTQ file by UMI or by index for demultiplexing workflows
- Use Trim Galore as an alternative when TrimGalore's detailed per-base quality report from FastQC is required alongside trimming
- Use Trimmomatic as an alternative for fine-grained control of sliding-window trimming steps
Prerequisites
- Software: fastp (conda or pre-compiled binary)
- Input: raw Illumina FASTQ files (single-end or paired-end, .fastq or .fastq.gz)
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v fastpfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run fastprather than barefastp.
# Install with conda
conda install -c bioconda fastp
# Or download pre-compiled binary (Linux)
wget https://github.com/OpenGene/fastp/releases/download/v0.24.0/fastp
chmod +x fastp
./fastp --version
# fastp 0.24.0
# Verify
fastp --version
Quick Start
# Paired-end adapter trimming with QC report
fastp \
-i sample_R1.fastq.gz \
-I sample_R2.fastq.gz \
-o sample_R1.trimmed.fastq.gz \
-O sample_R2.trimmed.fastq.gz \
-h sample_qc.html \
-j sample_qc.json \
--thread 8
echo "Trimmed reads in: sample_R1.trimmed.fastq.gz"
Workflow
Step 1: Single-End Adapter Trimming
Run fastp on single-end FASTQ with automatic adapter detection.
# Single-end with auto adapter detection
fastp \
-i sample.fastq.gz \
-o sample.trimmed.fastq.gz \
-h sample_qc.html \
-j sample_qc.json \
--thread 8 \
--qualified_quality_phred 20 \
--length_required 36
echo "Input reads: $(zcat sample.fastq.gz | wc -l | awk '{print $1/4}')"
echo "Output reads: $(zcat sample.trimmed.fastq.gz | wc -l | awk '{print $1/4}')"
Step 2: Paired-End Adapter Trimming
Process paired-end FASTQ files with overlap-based adapter detection and correction.
# Paired-end with overlap-based adapter auto-detection
fastp \
-i sample_R1.fastq.gz \
-I sample_R2.fastq.gz \
-o sample_R1.trimmed.fastq.gz \
-O sample_R2.trimmed.fastq.gz \
-h sample_qc.html \
-j sample_qc.json \
--thread 8 \
--correction \
--detect_adapter_for_pe \
--qualified_quality_phred 20 \
--length_required 36
# Specify adapters explicitly (if auto-detection fails)
# fastp -i R1.fq.gz -I R2.fq.gz \
# --adapter_sequence AGATCGGAAGAGCACACGTCTGAACTCCAGTCA \
# --adapter_sequence_r2 AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT \
# -o R1.out.fq.gz -O R2.out.fq.gz
Step 3: Quality Filtering and Read Length Trimming
Configure quality and length thresholds for stricter or more lenient filtering.
# Strict quality filtering (e.g., for variant calling)
fastp \
-i sample_R1.fastq.gz \
-I sample_R2.fastq.gz \
-o sample_R1.filtered.fastq.gz \
-O sample_R2.filtered.fastq.gz \
-h sample_qc.html \
-j sample_qc.json \
--thread 8 \
--qualified_quality_phred 25 \
--unqualified_percent_limit 20 \
--length_required 50 \
--max_len1 150 \
--max_len2 150 \
--low_complexity_filter \
--complexity_threshold 30
echo "Filtering complete. Check sample_qc.html for pass/fail rates."
Step 4: RNA-seq polyA Tail Removal
Remove polyA tails from 3′-enriched RNA-seq protocols before alignment.
# Remove polyA tails (QuantSeq 3′ mRNA-seq)
fastp \
-i quantseq_R1.fastq.gz \
-o quantseq_R1.trimmed.fastq.gz \
-h quantseq_qc.html \
-j quantseq_qc.json \
--thread 8 \
--trim_poly_x \
--poly_x_min_len 10 \
--qualified_quality_phred 20 \
--length_required 25
# For Smart-seq2 paired-end with polyA
fastp \
-i smartseq_R1.fastq.gz \
-I smartseq_R2.fastq.gz \
-o smartseq_R1.trimmed.fastq.gz \
-O smartseq_R2.trimmed.fastq.gz \
--trim_poly_x --poly_x_min_len 10 \
--thread 8 \
-h smartseq_qc.html -j smartseq_qc.json
Step 5: Parse QC Report JSON for Pipeline Monitoring
Extract key QC metrics from fastp's JSON output for automated quality gates.
import json
from pathlib import Path
def parse_fastp_json(json_path: str) -> dict:
with open(json_path) as f:
data = json.load(f)
before = data["summary"]["before_filtering"]
after = data["summary"]["after_filtering"]
return {
"total_reads_in": before["total_reads"],
"total_reads_out": after["total_reads"],
"pct_passed": after["total_reads"] / before["total_reads"] * 100,
"q30_rate_before": before["q30_rate"] * 100,
"q30_rate_after": after["q30_rate"] * 100,
"mean_len_before": before["read1_mean_length"],
"mean_len_after": after["read1_mean_length"],
"adapter_trimmed": data["filtering_result"]["adapter_trimmed"],
}
metrics = parse_fastp_json("sample_qc.json")
for key, val in metrics.items():
print(f"{key:25s}: {val:.1f}" if isinstance(val, float) else f"{key:25s}: {val:,}")
# Quality gate: fail if < 70% reads pass filter
if metrics["pct_passed"] < 70:
print("WARNING: Low pass rate — check raw data quality")
Step 6: Batch Preprocessing Pipeline
Process multiple samples sequentially with per-sample QC summaries.
#!/bin/bash
# Batch paired-end preprocessing for multiple samples
SAMPLES=(ctrl_1 ctrl_2 treat_1 treat_2)
DATA="data"
OUT="trimmed"
QC="qc/fastp"
THREADS=8
mkdir -p "$OUT" "$QC"
for sample in "${SAMPLES[@]}"; do
echo "=== Processing $sample ==="
fastp \
-i "$DATA/${sample}_R1.fastq.gz" \
-I "$DATA/${sample}_R2.fastq.gz" \
-o "$OUT/${sample}_R1.fastq.gz" \
-O "$OUT/${sample}_R2.fastq.gz" \
-h "$QC/${sample}.html" \
-j "$QC/${sample}.json" \
--thread $THREADS \
--correction \
--detect_adapter_for_pe \
--qualified_quality_phred 20 \
--length_required 36 \
2>&1 | grep -E "Read[12]|Filtering|Adapter|passed"
done
# Aggregate QC metrics
python3 - << 'EOF'
import json, pandas as pd
from pathlib import Path
rows = []
for jf in sorted(Path("qc/fastp").glob("*.json")):
with open(jf) as f: data = json.load(f)
after = data["summary"]["after_filtering"]
before = data["summary"]["before_filtering"]
rows.append({
"sample": jf.stem,
"reads_in": before["total_reads"],
"reads_out": after["total_reads"],
"pct_passed": round(after["total_reads"]/before["total_reads"]*100, 1),
"q30_after": round(after["q30_rate"]*100, 1),
})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
df.to_csv("fastp_summary.tsv", sep="\t", index=False)
EOF
# Run MultiQC to aggregate all fastp JSON reports
multiqc qc/fastp/ -o qc/ -n fastp_multiqc_report
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-i / -I |
required | file path | Input FASTQ (R1 and R2 for paired-end) |
-o / -O |
required | file path | Output trimmed FASTQ (R1 and R2) |
-h / -j |
— | file path | HTML and JSON QC report output paths |
--thread |
3 |
1–16 | CPU threads; 8 is a good balance |
--qualified_quality_phred |
15 |
0–40 | Minimum base quality (Phred); 20 = 1% error |
--length_required |
15 |
1–1000 | Minimum read length after trimming; discard shorter reads |
--correction |
off | flag | Correct mismatches in PE overlap region |
--detect_adapter_for_pe |
off | flag | Enable overlap-based adapter auto-detection for PE data |
--adapter_sequence |
auto | string | Explicit R1 adapter; overrides auto-detection |
--trim_poly_x |
off | flag | Trim polyX (polyA/polyT) tails; use for 3′-enriched RNA-seq |
--low_complexity_filter |
off | flag | Filter reads with low complexity (< 30% complexity by default) |
--split |
off | integer | Split output into N files per direction (for parallelism) |
Common Recipes
Recipe 1: Integrate fastp into a Snakemake Pipeline
# Snakefile — fastp trimming rule
configfile: "config.yaml"
SAMPLES = config["samples"]
rule fastp_pe:
input:
r1 = "data/{sample}_R1.fastq.gz",
r2 = "data/{sample}_R2.fastq.gz"
output:
r1 = "trimmed/{sample}_R1.fastq.gz",
r2 = "trimmed/{sample}_R2.fastq.gz",
html = "qc/{sample}_fastp.html",
json = "qc/{sample}_fastp.json"
threads: 8
shell:
"""
fastp -i {input.r1} -I {input.r2} \
-o {output.r1} -O {output.r2} \
-h {output.html} -j {output.json} \
--thread {threads} \
--correction --detect_adapter_for_pe \
--qualified_quality_phred 20 \
--length_required 36
"""
Recipe 2: Aggregate fastp JSON Reports with Python
import json
import pandas as pd
from pathlib import Path
qc_dir = Path("qc/fastp")
records = []
for jf in sorted(qc_dir.glob("*.json")):
with open(jf) as f:
d = json.load(f)
b = d["summary"]["before_filtering"]
a = d["summary"]["after_filtering"]
records.append({
"sample": jf.stem.replace("_fastp", ""),
"reads_in_M": b["total_reads"] / 1e6,
"reads_out_M": a["total_reads"] / 1e6,
"pct_passed": a["total_reads"] / b["total_reads"] * 100,
"q30_pct": a["q30_rate"] * 100,
"mean_len_bp": a["read1_mean_length"],
"adapter_pct": d["filtering_result"]["adapter_trimmed"] / b["total_reads"] * 100,
})
df = pd.DataFrame(records).round(2)
print(df.to_string(index=False))
# Flag low-quality samples
low_q = df[df["pct_passed"] < 80]
if not low_q.empty:
print(f"\nSamples with < 80% reads passing: {list(low_q['sample'])}")
Expected Outputs
| Output | Format | Description |
|---|---|---|
*_R1.trimmed.fastq.gz |
FASTQ.gz | Trimmed R1 reads (adapters and low-quality bases removed) |
*_R2.trimmed.fastq.gz |
FASTQ.gz | Trimmed R2 reads (paired-end only) |
*.html |
HTML | Interactive QC report with per-base quality, GC content, adapter plots |
*.json |
JSON | Machine-readable QC metrics for automation and MultiQC parsing |
fastp.log |
Text | stderr summary with pass/fail read counts and filtering statistics |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Adapter not detected in SE mode | SE reads require explicit adapter or --adapter_sequence |
Use --detect_adapter_for_pe only for PE; specify adapter for SE: --adapter_sequence AGATCGGAAGAGC |
| Very high adapter content (> 50%) | Short inserts (small RNA, miRNA) or poor library prep | Check library protocol; use --overlap_len_require 10 to adjust overlap sensitivity |
| Too many reads filtered (< 60% pass) | Over-strict quality thresholds or low-quality sequencing run | Relax --qualified_quality_phred to 15; lower --length_required to 25 |
| JSON output missing fields | Old fastp version | Upgrade: conda update fastp or download latest binary from GitHub |
| MultiQC not parsing fastp JSON | JSON file not in the scanned directory | Run multiqc qc/ not multiqc .; verify JSON files exist with ls qc/*.json |
| Output FASTQ is empty | All reads filtered (wrong input or extreme thresholds) | Verify input FASTQ with zcat sample.fq.gz | head -8; run without --low_complexity_filter first |
| Slow performance on large files | Low thread count | Increase --thread to 8–12; ensure input is on fast storage (SSD) |
| polyA not removed | --trim_poly_x not set |
Add --trim_poly_x --poly_x_min_len 10 for 3′-enriched protocols |
References
- fastp GitHub: OpenGene/fastp — source code, changelog, and usage guide
- Chen S et al. (2018) "fastp: an ultra-fast all-in-one FASTQ preprocessor" — Bioinformatics 34(17):i884-i890. DOI:10.1093/bioinformatics/bty560
- fastp documentation — full parameter reference and recipes
- MultiQC fastp module — aggregating fastp JSON reports across samples
skills/genomics-bioinformatics/qc/multiqc-qc-reports/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill multiqc-qc-reports -g -y
SKILL.md
Frontmatter
{
"name": "multiqc-qc-reports",
"license": "GPL-3.0",
"description": "Aggregates QC from 150+ bioinformatics tools into one interactive HTML report. Scans FastQC, samtools, STAR, HISAT2, Trim Galore, featureCounts, Kallisto, Salmon, Picard, GATK logs; merges per-sample stats with plots. For NGS pipeline-wide QC. Use FastQC directly for single-sample; MultiQC for multi-sample reporting."
}
MultiQC — Multi-Sample QC Report Aggregator
Overview
MultiQC automatically searches directories for QC log files from 150+ bioinformatics tools and aggregates statistics across all samples into a single interactive HTML report. It parses outputs from FastQC, samtools flagstat, STAR, HISAT2, Trim Galore, Salmon, Kallisto, featureCounts, Picard, GATK, and many more — eliminating the need to manually review per-sample QC files. Reports include interactive bar plots, scatter plots, heatmaps, and tables with configurable warnings and pass/fail thresholds.
When to Use
- Reviewing QC metrics across 10+ samples at once after FastQC, alignment, or quantification
- Final QC checkpoint before differential expression or variant analysis
- Sharing QC summaries with collaborators or including in publications
- Identifying batch effects, outlier samples, or failed sequencing runs
- Combining QC from multi-step pipelines (trimming → alignment → quantification) into one view
- Use FastQC directly instead for initial single-sample QC exploration
- For custom QC metrics not from standard tools, use Python/R directly; MultiQC parses tool outputs only
Prerequisites
- Python packages:
multiqc - Input requirements: Output files from bioinformatics tools (FastQC
.zip, samtools.flagstat, STARLog.final.out, etc.) — MultiQC finds them automatically - Environment: Python 3.8+
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v multiqcfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run multiqcrather than baremultiqc.
pip install multiqc
# Verify
multiqc --version
# MultiQC v1.25.0
# With conda (recommended for bioinformatics)
conda install -c bioconda multiqc
Workflow
Step 1: Generate Tool-Specific QC Files
MultiQC aggregates existing output — first run your QC tools.
# FastQC on all FASTQ files
mkdir -p qc/fastqc
fastqc data/*.fastq.gz -o qc/fastqc/ -t 8
# samtools flagstat on all BAM files
for bam in results/*.bam; do
samtools flagstat $bam > qc/$(basename $bam .bam).flagstat
done
echo "QC files generated: $(ls qc/ | wc -l)"
Step 2: Run MultiQC on a Directory
MultiQC recursively scans for recognized QC files.
# Basic run: scan current directory recursively
multiqc .
# Specify output directory and report name
multiqc . -o reports/ -n project_qc_report
# Scan specific subdirectories only
multiqc qc/fastqc/ results/star/ logs/trimming/ -o reports/
# Output: reports/project_qc_report.html
echo "Report: reports/project_qc_report.html"
Step 3: Configure Report Behavior
Use multiqc_config.yaml to set custom thresholds, sample naming, and module order.
# multiqc_config.yaml — place in working directory
title: "RNA-seq QC Report — Project X"
subtitle: "Analysis date: 2026-02"
intro_text: "Quality control summary for all 48 samples."
# Sample name cleaning: remove path prefixes and suffixes
fn_clean_exts:
- ".fastq.gz"
- "_R1"
- ".sorted"
# Thresholds for pass/warn/fail coloring
general_stats_addcols:
FastQC:
pct_duplication:
max: 40
warn: 30
# Module run order
module_order:
- fastqc
- trimgalore
- star
- featurecounts
- samtools
# Run with config file
multiqc . --config multiqc_config.yaml -o reports/
Step 4: Use MultiQC Modules and Filters
Control which tools and samples are included.
# Run only specific modules
multiqc . --module fastqc --module samtools
# Exclude specific modules
multiqc . --exclude fastqc
# Include only files matching a pattern
multiqc . --filename "*.flagstat" --filename "*_fastqc.zip"
# Ignore specific directories or files
multiqc . --ignore "tmp/" --ignore "*.bam"
# Add sample name regex substitution
multiqc . --replace-names "sample_" ""
Step 5: Export Data for Downstream Analysis
Extract machine-readable statistics from the MultiQC report.
# Export data tables (CSV, JSON, YAML, TSV)
multiqc . -o reports/ --data-format json
# Generates: reports/multiqc_data/multiqc_data.json
# Export flat CSV tables per tool
multiqc . -o reports/ --export
ls reports/multiqc_data/
# multiqc_fastqc.txt, multiqc_samtools_stats.txt, ...
# Extract general stats as pandas DataFrame
python3 - << 'EOF'
import json
import pandas as pd
with open("reports/multiqc_data/multiqc_general_stats.json") as f:
data = json.load(f)
df = pd.DataFrame(data).T
print(df.head())
print(f"Shape: {df.shape}")
EOF
Step 6: Automate in Pipeline Scripts
Integrate MultiQC as the final step of any QC pipeline.
#!/bin/bash
# Complete RNA-seq QC pipeline → MultiQC summary
SAMPLES=(ctrl_rep1 ctrl_rep2 treat_rep1 treat_rep2)
OUTDIR="pipeline_output"
mkdir -p $OUTDIR/{fastqc,star,featurecounts,flagstat}
for sample in "${SAMPLES[@]}"; do
# FastQC
fastqc data/${sample}.fastq.gz -o $OUTDIR/fastqc/ -t 4
# STAR alignment
STAR --runThreadN 8 --genomeDir refs/star_index \
--readFilesIn data/${sample}.fastq.gz \
--outSAMtype BAM SortedByCoordinate \
--outFileNamePrefix $OUTDIR/star/${sample}/
# samtools flagstat
samtools flagstat $OUTDIR/star/${sample}/Aligned.sortedByCoord.out.bam \
> $OUTDIR/flagstat/${sample}.flagstat
done
# Final MultiQC report
multiqc $OUTDIR/ -o $OUTDIR/qc_report/ -n "full_pipeline_qc"
echo "Report ready: $OUTDIR/qc_report/full_pipeline_qc.html"
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-o, --outdir |
. |
directory path | Output directory for report and data |
-n, --filename |
multiqc_report |
any string | Report filename (without extension) |
-m, --module |
all | tool name | Run only specified module(s) |
--ignore |
— | glob pattern | Ignore matching files or directories |
--export |
False | flag | Export flat tab-delimited data files |
--data-format |
tsv |
tsv, json, yaml |
Format for exported data files |
--config |
auto-detected | YAML file path | Custom config file with thresholds and naming |
--replace-names |
— | regex, replacement | Clean sample names in report |
--fn_clean_exts |
(built-in) | list in config | File extensions to strip from sample names |
--profile-runtime |
False | flag | Show per-module runtime profiling |
Common Recipes
Recipe: Add MultiQC to a Snakemake Pipeline
# In Snakefile: collect all QC outputs, then run MultiQC
rule multiqc:
input:
expand("qc/fastqc/{sample}_fastqc.zip", sample=SAMPLES),
expand("qc/flagstat/{sample}.flagstat", sample=SAMPLES)
output:
html="reports/multiqc_report.html",
data=directory("reports/multiqc_data")
shell:
"multiqc qc/ -o reports/ -n multiqc_report"
Recipe: Parse MultiQC Output in Python
import json
import pandas as pd
# Load general stats from JSON export
with open("reports/multiqc_data/multiqc_general_stats.json") as f:
stats = json.load(f)
df = pd.DataFrame(stats).T
print(f"Samples: {len(df)}")
print(f"Metrics: {list(df.columns[:5])}")
# Flag samples with low mapping rate
if "STAR_mqc-generalstats-star-uniquely_mapped_percent" in df.columns:
low_mapping = df[df["STAR_mqc-generalstats-star-uniquely_mapped_percent"] < 70]
print(f"Samples with <70% mapping: {list(low_mapping.index)}")
Recipe: Compare QC Before and After Trimming
# Run FastQC on raw and trimmed reads, then combine in one report
mkdir -p qc/{raw,trimmed}
fastqc data/*.fastq.gz -o qc/raw/ -t 8
trim_galore data/*.fastq.gz --paired -o trimmed/
fastqc trimmed/*_trimmed.fastq.gz -o qc/trimmed/ -t 8
multiqc qc/raw/ qc/trimmed/ \
-o reports/ -n raw_vs_trimmed \
--dirs --dirs-depth 1 # use directory names in sample labels
Expected Outputs
| Output | Format | Description |
|---|---|---|
multiqc_report.html |
HTML | Interactive report with all plots and tables |
multiqc_data/multiqc_general_stats.txt |
TSV | Per-sample summary statistics (all tools) |
multiqc_data/multiqc_*.txt |
TSV | Per-tool detailed statistics tables |
multiqc_data/multiqc_data.json |
JSON | Full data (if --data-format json) |
multiqc_data/multiqc_sources.txt |
TSV | Mapping of source files to samples |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Empty report (no modules found) | QC files not in scanned directories | Specify directories explicitly: multiqc qc/ logs/ results/ |
| Wrong sample names in report | File extensions or paths not cleaned | Add fn_clean_exts to config or use --replace-names |
| Module missing from report | Log file format changed in tool version | Update MultiQC: pip install --upgrade multiqc; check GitHub issues |
| Duplicate sample names | Multiple files map to same sample name | Use --sample-names or fix fn_clean_exts in config |
| Report very slow to open | Too many samples (>500) in one report | Split by project or condition; use --flat for simpler rendering |
| FastQC data not parsed | FastQC ZIP not in expected location | Run MultiQC from root of project; ensure *_fastqc.zip files exist |
ModuleNotFoundError |
Missing optional module dependencies | pip install multiqc[all] for all extras |
References
- MultiQC documentation — official user guide and module list
- GitHub: ewels/MultiQC — source code and community modules
- Ewels et al. (2016) "MultiQC: summarize analysis results for multiple tools and samples in a single report" — Bioinformatics 32(19)
- MultiQC supported tools — full list of 150+ supported modules
skills/genomics-bioinformatics/rnaseq/deseq2-differential-expression/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill deseq2-differential-expression -g -y
SKILL.md
Frontmatter
{
"name": "deseq2-differential-expression",
"license": "LGPL-3.0",
"description": "Bulk RNA-seq DE with R\/Bioconductor DESeq2. Negative binomial GLM, empirical Bayes shrinkage, Wald\/LRT tests, multi-factor designs, Salmon tximeta import, apeglm LFC shrinkage, MA\/volcano\/heatmap viz. R gold standard. Use pydeseq2-differential-expression for Python; use edgeR for TMM normalization."
}
DESeq2 Differential Expression Analysis (R/Bioconductor)
Overview
DESeq2 is the Bioconductor R package for differential gene expression analysis from bulk RNA-seq count data. It fits a negative binomial generalized linear model per gene, estimates dispersion parameters using empirical Bayes shrinkage across genes, and tests differential expression using Wald tests (two-group) or likelihood ratio tests (complex designs). DESeq2 is the R gold standard for RNA-seq DE analysis, with native Bioconductor integration for seamless import from Salmon (tximeta/tximport), featureCounts, or HTSeq.
When to Use
- Identifying differentially expressed genes between two experimental conditions (treated vs. control, disease vs. healthy) from bulk RNA-seq count data
- Analyzing multi-factor designs that account for batch effects or covariates (e.g.,
~ batch + condition) - Testing complex hypotheses with interaction terms (e.g., time × treatment) or reduced models using likelihood ratio tests (LRT)
- Importing Salmon pseudoalignment output via tximeta or tximport for transcript-level uncertainty propagation
- Performing LFC shrinkage with apeglm for ranked gene lists, volcano plots, and downstream pathway analysis
- Conducting time-series experiments or any design with more than two levels requiring model comparison
- Working in an R/Bioconductor ecosystem where integration with SummarizedExperiment, clusterProfiler, or EnhancedVolcano is needed
- Use pydeseq2-differential-expression instead for Python-based pipelines with the same statistical model
- Use edgeR for negative binomial DE with TMM normalization, quasi-likelihood F-tests, or TREAT testing
- Use gseapy-gene-enrichment after DE to interpret results at the pathway level
Prerequisites
- R packages:
DESeq2(Bioconductor),tximetaortximport(Salmon import),apeglm(LFC shrinkage),pheatmap,ggplot2,EnhancedVolcano - Data requirements: Raw (unnormalized) integer count matrix — gene rows × sample columns — plus a sample metadata data frame with matching column names. If using Salmon: per-sample
quant.sffiles and a transcript-to-gene mapping - Environment: R ≥ 4.2, Bioconductor ≥ 3.16
# Install Bioconductor packages
if (!require("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install(c("DESeq2", "tximeta", "tximport", "apeglm",
"EnhancedVolcano"))
install.packages(c("pheatmap", "ggplot2", "dplyr"))
Quick Start
Complete two-group comparison from a count matrix in under 20 lines.
library(DESeq2)
# Load count matrix (genes x samples) and metadata
counts <- as.matrix(read.csv("counts.csv", row.names = 1))
coldata <- read.csv("metadata.csv", row.names = 1)
coldata$condition <- factor(coldata$condition)
# Build DESeqDataSet, run full pipeline
dds <- DESeqDataSetFromMatrix(countData = counts,
colData = coldata,
design = ~ condition)
dds <- dds[rowSums(counts(dds)) >= 10, ] # pre-filter
dds <- DESeq(dds)
# Extract results (treated vs. control)
res <- results(dds, contrast = c("condition", "treated", "control"),
alpha = 0.05)
summary(res)
# LFC shrinkage for visualization
res_shrunk <- lfcShrink(dds, contrast = c("condition", "treated", "control"),
type = "apeglm", coef = 2)
Workflow
Step 1: Prepare Count Matrix and Sample Metadata
Build a DESeqDataSet from a gene × sample count matrix and a colData data frame. Column names of the count matrix must match row names of colData.
library(DESeq2)
library(dplyr)
# Load raw count matrix (genes as rows, samples as columns)
counts <- as.matrix(read.csv("featureCounts_matrix.csv", row.names = 1))
# Or from featureCounts tab-delimited output — skip first comment line
# fc <- read.table("featurecounts_output.txt", header = TRUE, skip = 1, row.names = 1)
# counts <- as.matrix(fc[, 7:ncol(fc)]) # columns 7+ are sample counts
# Load sample metadata
coldata <- data.frame(
condition = factor(c("control", "control", "control",
"treated", "treated", "treated")),
batch = factor(c("A", "A", "B", "A", "B", "B")),
row.names = colnames(counts)
)
# Build DESeqDataSet
dds <- DESeqDataSetFromMatrix(countData = counts,
colData = coldata,
design = ~ condition)
cat("Samples:", ncol(dds), "\n")
cat("Genes:", nrow(dds), "\n")
cat("Condition levels:", levels(coldata$condition), "\n")
Step 2: Import from Salmon via tximeta
When reads were quantified with Salmon, use tximeta to import transcript-level estimates with proper offset correction that accounts for transcript length and GC bias.
library(tximeta)
# Build a coldata with a "files" column pointing to quant.sf files
quant_dirs <- file.path("salmon_output", coldata$sample_id, "quant.sf")
coldata$files <- quant_dirs
coldata$names <- coldata$sample_id
# Import with tximeta — automatically fetches transcript metadata from Ensembl
se <- tximeta(coldata) # SummarizedExperiment with transcript-level data
# Summarize to gene level (requires Ensembl or custom txdb)
gse <- summarizeToGene(se) # gene-level SummarizedExperiment
# Build DESeqDataSet from the SummarizedExperiment
dds <- DESeqDataSet(gse, design = ~ condition)
cat("Gene-level SE dimensions:", dim(gse), "\n")
cat("Assay names:", assayNames(gse), "\n")
# Assay "counts" = estimated counts; "abundance" = TPM; "length" = eff. lengths
Step 3: Pre-Filtering and Quality Control
Remove genes with very low counts to improve statistical power and reduce the multiple testing burden. Explore sample quality with PCA on variance-stabilized counts.
library(ggplot2)
# Pre-filter: keep genes with at least 10 reads total
keep <- rowSums(counts(dds)) >= 10
dds <- dds[keep, ]
cat("Genes after pre-filtering:", nrow(dds), "\n")
# Variance-stabilizing transformation for QC visualization
vsd <- vst(dds, blind = TRUE) # blind = TRUE for exploratory QC
# PCA plot
pca_data <- plotPCA(vsd, intgroup = c("condition", "batch"), returnData = TRUE)
percent_var <- round(100 * attr(pca_data, "percentVar"))
ggplot(pca_data, aes(PC1, PC2, color = condition, shape = batch)) +
geom_point(size = 3) +
xlab(paste0("PC1: ", percent_var[1], "% variance")) +
ylab(paste0("PC2: ", percent_var[2], "% variance")) +
ggtitle("PCA — Variance-Stabilized Counts") +
theme_bw()
ggsave("pca_plot.pdf", width = 6, height = 5)
cat("Saved pca_plot.pdf\n")
Step 4: Run DESeq() — Normalization, Dispersion, and Model Fitting
DESeq() runs three sequential steps: (1) median-of-ratios size factor estimation, (2) gene-wise and shrunken dispersion estimation, (3) negative binomial GLM fitting and Wald statistics.
# Run full DESeq2 pipeline
dds <- DESeq(dds)
# Inspect size factors (library normalization)
cat("Size factors:\n")
print(sizeFactors(dds))
# Expected range: 0.3–3.0; outliers indicate library quality issues
# Dispersion plot — should show decreasing dispersion trend with mean
plotDispEsts(dds, main = "Dispersion Estimates")
# Inspect fitted model coefficients
resultsNames(dds)
# [1] "Intercept" "condition_treated_vs_control"
Step 5: Extract Results and Apply FDR Correction
Use results() to extract Wald test statistics. Specify the contrast explicitly for clarity. Independent filtering maximizes the number of detectable genes.
# Extract results for the contrast of interest
res <- results(dds,
contrast = c("condition", "treated", "control"),
alpha = 0.05, # FDR threshold for independent filtering
lfcThreshold = 0, # Test H0: |LFC| = 0 (Wald test)
independentFilter = TRUE) # Maximize power via adaptive filtering
# Summary: how many genes are up/down/NA
summary(res)
# out of N with nonzero total read count:
# adjusted p-value < 0.05: ...
# LFC > 0 (up): ...
# LFC < 0 (down): ...
# outliers [Cook's distance]: ...
# low counts [below independent filtering threshold]: ...
# Convert to data frame and sort by adjusted p-value
res_df <- as.data.frame(res)
res_df <- res_df[order(res_df$padj, na.last = TRUE), ]
cat("Significant genes (padj < 0.05):", sum(res_df$padj < 0.05, na.rm = TRUE), "\n")
cat("Upregulated:", sum(res_df$padj < 0.05 & res_df$log2FoldChange > 0, na.rm = TRUE), "\n")
cat("Downregulated:", sum(res_df$padj < 0.05 & res_df$log2FoldChange < 0, na.rm = TRUE), "\n")
write.csv(res_df, "deseq2_all_results.csv")
Step 6: LFC Shrinkage with apeglm
Shrink log2 fold changes using the adaptive t prior (apeglm). Use shrunk LFCs for MA plots, volcano plots, and gene ranking — NOT for significance calling (padj comes from the unshrunk Wald test).
library(apeglm)
# apeglm shrinkage — specify coefficient name from resultsNames(dds)
res_shrunk <- lfcShrink(dds,
coef = "condition_treated_vs_control",
type = "apeglm",
res = res) # pass original results for speed
# Compare shrunk vs. unshrunk LFC
cat("Max |LFC| unshrunk:", max(abs(res$log2FoldChange), na.rm = TRUE), "\n")
cat("Max |LFC| shrunk: ", max(abs(res_shrunk$log2FoldChange), na.rm = TRUE), "\n")
# Export shrunk results
res_shrunk_df <- as.data.frame(res_shrunk)
res_shrunk_df <- res_shrunk_df[order(res_shrunk_df$padj, na.last = TRUE), ]
write.csv(res_shrunk_df, "deseq2_shrunk_results.csv")
# Filter significant with effect size threshold
sig_genes <- res_shrunk_df[
!is.na(res_shrunk_df$padj) &
res_shrunk_df$padj < 0.05 &
abs(res_shrunk_df$log2FoldChange) > 1, ]
cat("Significant (padj<0.05, |LFC|>1):", nrow(sig_genes), "\n")
write.csv(sig_genes, "deseq2_significant.csv")
Step 7: Visualize — MA Plot, Volcano Plot, and Heatmap
Compute/viz separation (required): Do not combine DESeq2 computation and plotting in the same code block. Step 6 (or Step 5) saves the results CSV; this step loads that CSV and produces plots only. This way plots can be regenerated or restyled without re-running the expensive computation.
library(EnhancedVolcano)
library(pheatmap)
# --- MA Plot (base DESeq2) ---
plotMA(res_shrunk, ylim = c(-5, 5),
main = "MA Plot (apeglm-shrunk LFC)",
colSig = "firebrick", colNonSig = "grey60")
# Blue points are significantly DE genes; y-axis is shrunk log2FC
# --- Volcano Plot (EnhancedVolcano) ---
EnhancedVolcano(res_shrunk_df,
lab = rownames(res_shrunk_df),
x = "log2FoldChange",
y = "padj",
pCutoff = 0.05,
FCcutoff = 1.0,
xlim = c(-6, 6),
title = "Treated vs. Control",
subtitle = "apeglm-shrunk LFC | BH-adjusted p-value",
legendLabels = c("NS", "LFC", "padj", "padj & LFC"))
ggsave("volcano_plot.pdf", width = 8, height = 7)
# --- Heatmap of top 50 DE genes ---
top50 <- head(rownames(sig_genes[order(sig_genes$padj), ]), 50)
mat <- assay(vsd)[top50, ] # variance-stabilized expression
mat <- mat - rowMeans(mat) # center by gene mean
annotation_col <- data.frame(
Condition = coldata$condition,
Batch = coldata$batch,
row.names = colnames(mat)
)
pheatmap(mat,
annotation_col = annotation_col,
cluster_rows = TRUE,
cluster_cols = TRUE,
show_rownames = TRUE,
fontsize_row = 6,
filename = "heatmap_top50.pdf",
width = 8,
height = 10)
cat("Saved volcano_plot.pdf and heatmap_top50.pdf\n")
Step 8: Multi-Factor Design and Interaction Terms
For designs with batch correction, paired samples, or interaction effects between two variables.
# --- Batch-corrected two-group comparison ---
dds_batch <- DESeqDataSetFromMatrix(countData = counts,
colData = coldata,
design = ~ batch + condition)
dds_batch <- dds_batch[rowSums(counts(dds_batch)) >= 10, ]
dds_batch <- DESeq(dds_batch)
res_batch <- results(dds_batch,
contrast = c("condition", "treated", "control"),
alpha = 0.05)
cat("Significant (batch-corrected):", sum(res_batch$padj < 0.05, na.rm = TRUE), "\n")
# --- Interaction design: test whether treatment effect differs between genotypes ---
# H0: the effect of treatment is the same in WT and KO
coldata$genotype <- factor(coldata$genotype) # "WT" or "KO"
dds_int <- DESeqDataSetFromMatrix(
countData = counts, colData = coldata,
design = ~ genotype + condition + genotype:condition
)
dds_int <- DESeq(dds_int)
resultsNames(dds_int)
# Interaction coefficient: "genotypeKO.conditiontreated"
# Extract interaction term — genes where treatment effect differs by genotype
res_int <- results(dds_int, name = "genotypeKO.conditiontreated", alpha = 0.05)
cat("Genes with significant interaction:", sum(res_int$padj < 0.05, na.rm = TRUE), "\n")
# --- Likelihood Ratio Test for complex designs (e.g., time series) ---
# LRT compares full model vs. reduced model
dds_lrt <- DESeq(dds_int, test = "LRT", reduced = ~ genotype + condition)
res_lrt <- results(dds_lrt)
cat("Genes with genotype:condition interaction (LRT):",
sum(res_lrt$padj < 0.05, na.rm = TRUE), "\n")
Key Parameters
| Parameter | Function | Default | Range / Options | Effect |
|---|---|---|---|---|
design |
DESeqDataSetFromMatrix() |
(required) | ~ var or ~ cov + var |
Specifies the GLM formula; put batch/covariates before variable of interest |
contrast |
results() |
NULL |
c(var, level1, level2) or coefficient name |
Defines the comparison; always specify explicitly for clarity |
alpha |
results() |
0.1 |
0.01–0.10 |
FDR threshold used for independent filtering; does not change padj values |
lfcThreshold |
results() |
0 |
0–2 |
Test H0: |
independentFilter |
results() |
TRUE |
TRUE/FALSE |
Adaptive mean-count filtering to maximize the number of rejectible hypotheses |
type |
lfcShrink() |
"apeglm" |
"apeglm", "ashr", "normal" |
Shrinkage prior; apeglm is fastest and best calibrated |
coef |
lfcShrink() |
(required) | coefficient name from resultsNames() |
Which model coefficient to shrink; must match resultsNames() output |
test |
DESeq() |
"Wald" |
"Wald", "LRT" |
Wald: two-group comparisons; LRT: model comparison for complex designs |
reduced |
DESeq() |
NULL |
formula without the term to test | Required for LRT; specifies the null model |
blind |
vst() / rlog() |
TRUE |
TRUE/FALSE |
TRUE for QC/exploration; FALSE for downstream analysis using dispersion info |
Key Concepts
Negative Binomial Model and Dispersion
DESeq2 models each gene's count as Negative Binomial(mean = µ, dispersion = α), where mean µ depends on the experimental design through a log-linear model. The key innovation is dispersion shrinkage: gene-wise dispersion estimates are shrunk toward a fitted trend across all genes using an empirical Bayes approach, borrowing information across genes to stabilize estimates from small sample sizes.
Size Factor Normalization
DESeq2 uses the median-of-ratios method: for each sample, compute the ratio of each gene's count to the geometric mean of that gene across all samples, then take the median ratio across all genes as the size factor. This is robust to differentially expressed genes and does not require any assumptions about the fraction of DE genes.
Wald Test vs. Likelihood Ratio Test
- Wald test (default): tests a single coefficient
β = 0; fast and appropriate for two-group comparisons and specific contrasts in multi-factor designs - LRT: compares the full model to a
reducedmodel by the likelihood ratio; appropriate for testing whether any level of a multi-level factor (e.g., 5 time points) has an effect, or for testing interaction terms as a group
Cook's Distance Outlier Detection
DESeq2 computes Cook's distance per gene per sample to flag potential expression outliers. Genes where any sample has a Cook's distance above a threshold are automatically replaced with NA in the results. With refit = TRUE (default), outlier samples are excluded and the model is refit for that gene.
Common Recipes
Recipe: Multiple Contrasts from One Fitted Model
Efficient when comparing multiple treatment groups — fit DESeq() once, extract multiple contrasts.
# Fit once
dds_multi <- DESeqDataSetFromMatrix(counts, coldata, design = ~ condition)
dds_multi <- dds_multi[rowSums(counts(dds_multi)) >= 10, ]
dds_multi <- DESeq(dds_multi)
# Extract contrasts — all vs. "control"
contrasts_list <- list(
A_vs_ctrl = c("condition", "treatment_A", "control"),
B_vs_ctrl = c("condition", "treatment_B", "control"),
C_vs_ctrl = c("condition", "treatment_C", "control")
)
results_list <- lapply(names(contrasts_list), function(nm) {
res <- results(dds_multi, contrast = contrasts_list[[nm]], alpha = 0.05)
res_shrunk <- lfcShrink(dds_multi, contrast = contrasts_list[[nm]],
type = "apeglm", res = res)
write.csv(as.data.frame(res_shrunk), paste0("results_", nm, ".csv"))
cat(nm, "— significant:", sum(res$padj < 0.05, na.rm = TRUE), "\n")
as.data.frame(res_shrunk)
})
names(results_list) <- names(contrasts_list)
Recipe: Paired Sample Design
Eliminate within-subject variability by including subject as a blocking factor.
# Paired design: each subject has pre- and post-treatment samples
coldata$subject <- factor(c("S1","S1","S2","S2","S3","S3"))
coldata$timepoint <- factor(c("pre","post","pre","post","pre","post"))
dds_paired <- DESeqDataSetFromMatrix(counts, coldata,
design = ~ subject + timepoint)
dds_paired <- dds_paired[rowSums(counts(dds_paired)) >= 10, ]
dds_paired <- DESeq(dds_paired)
res_paired <- results(dds_paired,
contrast = c("timepoint", "post", "pre"),
alpha = 0.05)
cat("Significant (paired):", sum(res_paired$padj < 0.05, na.rm = TRUE), "\n")
Recipe: Rank Genes for GSEA Using Shrunk LFC × -log10(pvalue)
Create a pre-ranked gene list for input to GSEA or gseapy's prerank function.
# Build ranking metric: signed -log10(pvalue) with LFC direction
res_rank <- as.data.frame(res_shrunk)
res_rank <- res_rank[!is.na(res_rank$pvalue) & !is.na(res_rank$log2FoldChange), ]
res_rank$rank_metric <- sign(res_rank$log2FoldChange) *
(-log10(res_rank$pvalue + 1e-300))
res_rank <- res_rank[order(res_rank$rank_metric, decreasing = TRUE), ]
# Export as two-column TSv for fgsea or gseapy prerank
write.table(data.frame(gene = rownames(res_rank),
score = res_rank$rank_metric),
"gsea_ranked_list.rnk",
sep = "\t", quote = FALSE, row.names = FALSE, col.names = FALSE)
cat("Ranked gene list saved:", nrow(res_rank), "genes\n")
Recipe: Save and Reload Fitted DESeq2 Object
Avoid re-running the expensive DESeq() step in subsequent sessions.
# Save fitted DDS object
saveRDS(dds, "dds_fitted.rds")
cat("Saved fitted DESeqDataSet to dds_fitted.rds\n")
# Reload in a new session
dds_loaded <- readRDS("dds_fitted.rds")
# Continue with results(), lfcShrink(), etc.
res_reloaded <- results(dds_loaded, contrast = c("condition", "treated", "control"))
Expected Outputs
| File | Description |
|---|---|
deseq2_all_results.csv |
Full results table: baseMean, log2FoldChange, lfcSE, stat, pvalue, padj for all genes |
deseq2_shrunk_results.csv |
Results with apeglm-shrunk LFC; use for ranking and visualization |
deseq2_significant.csv |
Filtered results: padj < 0.05 and |
pca_plot.pdf |
PCA of variance-stabilized counts; used to check batch structure and outliers |
dispersion_plot.pdf |
Gene-wise vs. fitted dispersions; should show tight cloud around trend |
volcano_plot.pdf |
Volcano plot with significance and LFC thresholds labeled |
heatmap_top50.pdf |
Hierarchically clustered heatmap of top 50 DE genes |
gsea_ranked_list.rnk |
Pre-ranked gene list for pathway enrichment (fgsea/gseapy) |
dds_fitted.rds |
Serialized DESeqDataSet for checkpoint/resume |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error: design contains one or more variables with all samples having the same value |
A design factor has only one level | Check table(coldata$condition); ensure all factor levels are present |
Model matrix not full rank |
Confounded design (e.g., batch perfectly correlated with condition) | Run table(coldata$batch, coldata$condition); drop the confounded variable or collect more samples |
All or most padj = NA |
Genes flagged by independent filtering (low mean count) or Cook's outliers | Check summary(res) for count of filtered genes; relax alpha or verify data quality |
| No significant genes despite strong biology | Under-powered experiment or high within-group variability | Verify n ≥ 3 per group; check PCA for outliers; inspect p-value histogram (should show spike near 0) |
Error in lfcShrink: coefficient not found |
coef name does not match resultsNames(dds) |
Run resultsNames(dds) and copy the exact coefficient string into coef = |
apeglm fails with convergence warning |
Extreme LFC estimates (sparse data or complete separation) | Switch to type = "ashr" which is more robust to these cases |
| Very large size factors (> 5) | Extremely different library sizes, or normalized counts accidentally used | Check colSums(counts(dds)); ensure input is raw integer counts from aligner/counter |
| Dispersion plot shows outlier cloud far from trend | Noisy or failed libraries; incorrect metadata grouping | Examine PCA; remove outlier samples; check that design formula matches biological groups |
| Volcano plot: no labeled points | EnhancedVolcano defaults label too few genes |
Adjust pCutoff and FCcutoff; or set selectLab = rownames(sig_genes)[1:20] |
References
- DESeq2 Bioconductor page — Package home, vignette, and reference manual
- DESeq2 vignette — Comprehensive worked example covering all design types
- Love et al. (2014) Genome Biology — Original DESeq2 paper: moderated estimation of fold change and dispersion for RNA-seq data
- Zhu et al. (2019) Bioinformatics — apeglm LFC shrinkage for DESeq2
- tximeta Bioconductor page — Salmon import with transcript-level metadata
- EnhancedVolcano Bioconductor page — Publication-quality volcano plots for DE results
skills/genomics-bioinformatics/rnaseq/featurecounts-rna-counting/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill featurecounts-rna-counting -g -y
SKILL.md
Frontmatter
{
"name": "featurecounts-rna-counting",
"license": "GPL-3.0",
"description": "Counts RNA-seq reads overlapping GTF gene features. Takes sorted STAR BAMs plus GTF; outputs a per-gene tab-delimited matrix across samples. Handles strandedness (0\/1\/2), paired-end, multi-sample batch counting in one command, and outputs assignment statistics. Use Salmon for alignment-free quantification; use featureCounts when STAR BAMs already exist."
}
featureCounts — RNA-seq Read Counting
Overview
featureCounts (part of the Subread package) assigns sequencing reads in BAM files to genomic features defined in a GTF/GFF annotation. It counts how many reads overlap each gene (or exon, intron, or custom feature), producing a gene × sample count matrix suitable for differential expression analysis with DESeq2 or edgeR. featureCounts processes multiple BAM files in a single command, reporting read assignment statistics (assigned, unassigned by category) alongside the count matrix. It is the standard counting step after STAR alignment in RNA-seq pipelines.
When to Use
- Generating gene-level count matrices from STAR-aligned BAM files for DESeq2 or edgeR
- Counting reads from multiple samples simultaneously in a single featureCounts command
- Handling stranded RNA-seq libraries where sense/antisense assignment matters
- Producing exon-level or custom-feature counts (e.g., for splicing analysis with DEXSeq)
- Verifying strandedness of an RNA-seq library when protocol documentation is unavailable
- Use Salmon instead when no BAM file exists and fast pseudoalignment is preferred
- Use HTSeq-count as an alternative with slower but more flexible counting modes
Prerequisites
- Software: Subread package (contains
featureCounts) - Input: Sorted BAM files from STAR or HISAT2, plus a matching GTF annotation file
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v featureCountsfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run featureCountsrather than barefeatureCounts.
# Install with conda (recommended)
conda install -c bioconda subread
# Verify
featureCounts -v
# featureCounts v2.0.6
# Alternative: install via apt (Ubuntu/Debian)
sudo apt-get install subread
Quick Start
# Count reads for multiple samples (unstranded paired-end RNA-seq)
featureCounts \
-a gencode.v47.annotation.gtf \
-o counts/gene_counts.txt \
-T 8 \
-p --countReadPairs \
results/sample1/Aligned.sortedByCoord.out.bam \
results/sample2/Aligned.sortedByCoord.out.bam
echo "Count matrix: counts/gene_counts.txt"
head -3 counts/gene_counts.txt
Workflow
Step 1: Prepare BAM Files and GTF
Ensure BAM files are sorted and indexed, and the GTF matches the genome assembly.
# Verify BAM files are sorted
samtools view -H results/sample1/Aligned.sortedByCoord.out.bam | grep "SO:"
# Expected: SO:coordinate
# List BAMs to count
ls results/*/Aligned.sortedByCoord.out.bam | head -5
# Download GENCODE GTF (same version used for STAR indexing)
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_47/gencode.v47.primary_assembly.annotation.gtf.gz
gunzip gencode.v47.primary_assembly.annotation.gtf.gz
echo "GTF lines: $(wc -l < gencode.v47.primary_assembly.annotation.gtf)"
Step 2: Determine Library Strandedness
Test strandedness using a small read count to set the -s parameter correctly.
# Quick strandedness check: count 1 sample with all 3 modes
# Compare assigned rates: highest = correct mode
for strand in 0 1 2; do
echo "=== Strandedness -s $strand ==="
featureCounts \
-a gencode.v47.primary_assembly.annotation.gtf \
-o /tmp/test_s${strand}.txt \
-T 4 \
-p --countReadPairs \
-s $strand \
results/sample1/Aligned.sortedByCoord.out.bam 2>&1 \
| grep "Successfully assigned"
done
# Rule: 0=unstranded if similar rates; 1 or 2 if one is much higher
Step 3: Count Unstranded Paired-End RNA-seq
Standard configuration for unstranded libraries (most polyA-selected RNA-seq).
mkdir -p counts
# Multi-sample batch counting: pass all BAMs as positional arguments
featureCounts \
-a gencode.v47.primary_assembly.annotation.gtf \
-o counts/gene_counts.txt \
-T 8 \
-p \
--countReadPairs \
-s 0 \
-t exon \
-g gene_id \
results/ctrl_1/Aligned.sortedByCoord.out.bam \
results/ctrl_2/Aligned.sortedByCoord.out.bam \
results/treat_1/Aligned.sortedByCoord.out.bam \
results/treat_2/Aligned.sortedByCoord.out.bam
echo "Count matrix: $(wc -l < counts/gene_counts.txt) genes"
# Also generates: counts/gene_counts.txt.summary (assignment stats)
cat counts/gene_counts.txt.summary
Step 4: Count Stranded Libraries
For strand-specific libraries (TruSeq Stranded, QuantSeq), set the correct strandedness.
# Reverse-stranded library (most TruSeq Stranded protocols): -s 2
featureCounts \
-a gencode.v47.primary_assembly.annotation.gtf \
-o counts/gene_counts_stranded.txt \
-T 8 \
-p --countReadPairs \
-s 2 \
results/*/Aligned.sortedByCoord.out.bam
# Forward-stranded (e.g., Lexogen QuantSeq, Takara SMARTer): -s 1
# featureCounts ... -s 1 ...
echo "Stranded count complete."
head -2 counts/gene_counts_stranded.txt
Step 5: Load Count Matrix into Python for DESeq2
Parse the featureCounts output file and prepare for differential expression.
import pandas as pd
# featureCounts output has 6 metadata columns before count columns
counts_raw = pd.read_csv("counts/gene_counts.txt", sep="\t", comment="#")
print(f"Columns: {list(counts_raw.columns)}")
# Metadata columns: Geneid, Chr, Start, End, Strand, Length
# Count columns start at index 6
count_cols = counts_raw.columns[6:] # BAM file paths as column names
counts = counts_raw.set_index("Geneid")[count_cols].copy()
# Rename columns to sample names (strip path and file extension)
import re
counts.columns = [re.sub(r".*/|Aligned\.sortedByCoord\.out\.bam", "", col)
for col in counts.columns]
print(f"Count matrix shape: {counts.shape}") # (genes × samples)
print(f"Samples: {list(counts.columns)}")
print(f"Genes with counts > 0: {(counts.sum(axis=1) > 0).sum()}")
counts.to_csv("gene_count_matrix.tsv", sep="\t")
print("Saved: gene_count_matrix.tsv")
Step 6: Run DESeq2 with featureCounts Matrix
Use the count matrix directly in pydeseq2 for differential expression.
import pandas as pd
from pydeseq2.dds import DeseqDataSet
from pydeseq2.default_inference import DefaultInference
from pydeseq2.ds import DeseqStats
# Load count matrix (genes × samples)
counts = pd.read_csv("gene_count_matrix.tsv", sep="\t", index_col=0).T
print(f"Count matrix: {counts.shape} (samples × genes)")
# Sample metadata
metadata = pd.DataFrame({
"condition": ["control", "control", "treated", "treated"]
}, index=counts.index)
# Filter low-count genes (recommended before DESeq2)
counts_filtered = counts.loc[:, counts.sum() > 10]
print(f"Genes after low-count filter: {counts_filtered.shape[1]}")
# Run DESeq2
dds = DeseqDataSet(counts=counts_filtered, metadata=metadata,
design_factors="condition",
inference=DefaultInference(n_cpus=8))
dds.deseq2()
stat_res = DeseqStats(dds, contrast=["condition", "treated", "control"],
inference=DefaultInference())
stat_res.summary()
results = stat_res.results_df
sig = results[results["padj"] < 0.05]
print(f"DE genes (padj < 0.05): {len(sig)}")
print(sig.sort_values("log2FoldChange", ascending=False).head())
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-a |
required | GTF/GFF3 path | Annotation file; must match genome assembly used for alignment |
-o |
required | file path | Output count table path (also creates <output>.summary) |
-T |
1 |
1–64 | CPU threads; 8–16 is typical |
-s |
0 |
0 (unstranded), 1 (stranded), 2 (reverse-stranded) |
Library strandedness; wrong value causes major undercounting |
-p |
off | flag | Paired-end mode; reads counted as fragments not individual reads |
--countReadPairs |
off | flag | For PE: count pairs not reads (use with -p) |
-t |
exon |
feature type string | Feature type to count from GTF column 3 |
-g |
gene_id |
attribute string | GTF attribute to group features (use gene_id for genes) |
--minOverlap |
1 |
1–100 | Minimum bases a read must overlap a feature to be counted |
--fracOverlap |
0 |
0–1 | Fraction of read that must overlap; 0.2 for stricter counting |
-O |
off | flag | Allow reads to be assigned to multiple overlapping features |
-M |
off | flag | Count multi-mapping reads (default: only uniquely mapped) |
Common Recipes
Recipe 1: Count with Subread Package via Python subprocess
import subprocess
import re
from pathlib import Path
def run_featurecounts(bam_files: list, gtf: str, outfile: str,
threads: int = 8, strandedness: int = 0,
paired_end: bool = True) -> dict:
"""Run featureCounts and return assignment statistics."""
cmd = [
"featureCounts",
"-a", gtf,
"-o", outfile,
"-T", str(threads),
"-s", str(strandedness),
"-t", "exon",
"-g", "gene_id",
]
if paired_end:
cmd += ["-p", "--countReadPairs"]
cmd += bam_files
result = subprocess.run(cmd, capture_output=True, text=True)
# Parse summary from stderr
stats = {}
for line in result.stderr.splitlines():
if "Assigned" in line:
stats["assigned_pct"] = float(re.search(r"(\d+\.\d+)%", line).group(1))
return stats
bams = list(Path("results").glob("*/Aligned.sortedByCoord.out.bam"))
bam_list = [str(b) for b in sorted(bams)]
stats = run_featurecounts(bam_list, "gencode.v47.primary_assembly.annotation.gtf",
"counts/gene_counts.txt")
print(f"Assigned reads: {stats.get('assigned_pct', 'N/A')}%")
Recipe 2: Add featureCounts to a Snakemake Pipeline
# Snakefile — featureCounts rule after STAR alignment
configfile: "config.yaml"
SAMPLES = config["samples"]
rule featurecounts:
input:
bams = expand("results/{sample}/Aligned.sortedByCoord.out.bam", sample=SAMPLES),
gtf = config["gtf"]
output:
counts = "counts/gene_counts.txt",
summary = "counts/gene_counts.txt.summary"
params:
strandedness = config.get("strandedness", 0)
threads: 8
shell:
"""
featureCounts \
-a {input.gtf} \
-o {output.counts} \
-T {threads} \
-p --countReadPairs \
-s {params.strandedness} \
-t exon -g gene_id \
{input.bams}
"""
Expected Outputs
| Output | Format | Description |
|---|---|---|
gene_counts.txt |
TSV | Count matrix: gene metadata + one count column per BAM |
gene_counts.txt.summary |
TSV | Read assignment statistics per sample (Assigned, Unassigned_*) |
| stderr log | Text | Per-sample assignment percentages and warnings |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Very low assigned rate (< 40%) | Wrong strandedness -s value |
Test all 3 -s modes; match to library prep protocol |
| GTF not matching genome | Different assembly or annotation version | Verify genome + GTF are same version (e.g., both GRCh38/GENCODE v47) |
Error: Failed to open the annotation file |
GTF file path wrong or compressed | Decompress GTF; use absolute path |
| Count matrix has 0 for all genes | Wrong -t feature type |
Check GTF column 3 with awk '{print $3}' file.gtf | sort -u | head |
| Multi-mapping reads not counted | -M not set |
Add -M to count multi-mappers; may inflate counts for repetitive regions |
| Paired-end reads counted as single | -p flag missing |
Add -p --countReadPairs for paired-end BAMs |
| Very slow on large BAM files | Low thread count | Increase -T to 8–16; ensure BAMs are sorted by coordinate |
gene_id attribute missing |
GFF3 file uses different attribute | Use -g ID for GFF3; check attributes with grep -v "^#" file.gff3 | head -5 |
References
- Subread documentation — featureCounts user guide and parameter reference
- Liao Y, Smyth GK, Shi W (2014) "featureCounts: an efficient general purpose program for assigning sequence reads to genomic features" — Bioinformatics 30(7):923-930. DOI:10.1093/bioinformatics/btt656
- Subread GitHub: ShiLab-Bioinformatics/subread — source code and releases
- RNAseq123 workflow — Bioconductor RNA-seq workflow using featureCounts → edgeR
skills/genomics-bioinformatics/rnaseq/gseapy-gene-enrichment/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gseapy-gene-enrichment -g -y
SKILL.md
Frontmatter
{
"name": "gseapy-gene-enrichment",
"license": "MIT",
"description": "GSEA and over-representation analysis (ORA) for RNA-seq and proteomics. Wraps Enrichr for ORA against MSigDB, KEGG, GO, and 200+ databases; runs preranked GSEA on ranked DE gene lists. Outputs enrichment tables and running-score plots. Use after DESeq2 or edgeR for pathway-level interpretation."
}
GSEApy — Gene Set Enrichment Analysis in Python
Overview
GSEApy provides Python implementations of GSEA and over-representation analysis (ORA) for interpreting gene expression changes at the pathway level. The enrich module queries the Enrichr API to test a gene list against 200+ databases (GO, KEGG, MSigDB Hallmarks, Reactome, WikiPathways). The prerank and gsea modules run the GSEA algorithm on a pre-ranked gene list or expression matrix — computing normalized enrichment scores (NES) and FDR values for each gene set. GSEApy integrates directly with pandas DataFrames from DESeq2 or scanpy differential expression output, making it the standard Python tool for pathway analysis in RNA-seq workflows.
When to Use
- Interpreting DESeq2 or edgeR differential expression results at pathway/GO-term level
- Running fast ORA (over-representation analysis) against Enrichr's 200+ databases including GO, KEGG, and MSigDB Hallmarks
- Performing GSEA prerank analysis on a log2-fold-change-ranked gene list without an expression matrix
- Identifying enriched pathways in scRNA-seq cluster marker genes
- Generating publication-ready enrichment dot plots and GSEA running-score plots
- Use GSEA Java application for the official GUI-based analysis with full GSEA desktop interface
- Use fgsea (R) as an alternative with fast permutation-based p-values; GSEApy is preferred for Python-native pipelines
Prerequisites
- Python packages:
gseapy,pandas,matplotlib - Internet access:
enrichmodule queries the Enrichr API (requires connection)
pip install gseapy
# Verify
python -c "import gseapy; print(gseapy.__version__)"
# 1.1.3
Quick Start
import gseapy as gp
# ORA: test a gene list against GO Biological Process
gene_list = ["TP53", "BRCA1", "CDK2", "CCND1", "MYC", "EGFR", "KRAS", "PTEN"]
enr = gp.enrichr(gene_list=gene_list,
gene_sets=["GO_Biological_Process_2023"],
organism="human",
outdir=None)
print(enr.results.head(5)[["Term", "P-value", "Adjusted P-value", "Genes"]])
Workflow
Step 1: Over-Representation Analysis with Enrichr (ORA)
Test a gene list against pathway databases via the Enrichr API.
import gseapy as gp
import pandas as pd
# Gene list from DESeq2 (significant upregulated genes)
sig_genes = ["TP53", "BRCA1", "CDK2", "CCND1", "MYC", "EGFR",
"KRAS", "PTEN", "RB1", "AKT1", "PIK3CA", "MDM2"]
# Run ORA against multiple databases
enr = gp.enrichr(
gene_list=sig_genes,
gene_sets=[
"GO_Biological_Process_2023",
"KEGG_2021_Human",
"MSigDB_Hallmark_2020",
"Reactome_2022",
],
organism="human",
outdir="enrichr_results/",
cutoff=0.05,
)
# Display top results
results = enr.results
print(f"Enriched terms: {len(results[results['Adjusted P-value'] < 0.05])}")
print(results[results["Adjusted P-value"] < 0.05].sort_values("Adjusted P-value")
.head(10)[["Gene_set", "Term", "Adjusted P-value", "Combined Score"]])
Step 2: List Available Gene Set Databases
Discover the 200+ databases available through Enrichr.
import gseapy as gp
# List all available gene set libraries
libraries = gp.get_library_name(organism="human")
print(f"Available databases: {len(libraries)}")
print("Selected databases:")
for lib in sorted(libraries):
if any(kw in lib for kw in ["GO_Bio", "KEGG", "Hallmark", "Reactome"]):
print(f" {lib}")
# Mouse databases
mouse_libs = gp.get_library_name(organism="mouse")
print(f"\nMouse databases: {len(mouse_libs)}")
Step 3: GSEA Prerank — Ranked Gene List Analysis
Run GSEA on a log2 fold-change ranked gene list from differential expression.
import gseapy as gp
import pandas as pd
import numpy as np
# Load DESeq2 results (or create example ranked list)
# deseq_results = pd.read_csv("deseq2_results.tsv", sep="\t", index_col=0)
# ranked = deseq_results["log2FoldChange"].dropna().sort_values(ascending=False)
# Example ranked gene list (gene → log2FC)
np.random.seed(42)
gene_names = [f"GENE_{i}" for i in range(1000)]
log2fc = np.random.normal(0, 2, 1000)
ranked = pd.Series(log2fc, index=gene_names).sort_values(ascending=False)
# Run preranked GSEA against MSigDB Hallmarks
pre_res = gp.prerank(
rnk=ranked,
gene_sets="MSigDB_Hallmark_2020",
threads=4,
min_size=15,
max_size=500,
permutation_num=1000,
outdir="gsea_results/prerank/",
seed=42,
verbose=True,
)
# View results
res_df = pre_res.res2d
sig = res_df[res_df["FDR q-val"] < 0.25]
print(f"Significant gene sets (FDR < 0.25): {len(sig)}")
print(sig.sort_values("NES", ascending=False)[["Term", "NES", "NOM p-val", "FDR q-val"]].head(10))
Step 4: Plot GSEA Running Score
Visualize the enrichment score curve for a specific gene set.
import gseapy as gp
from gseapy.plot import gseaplot
import matplotlib.pyplot as plt
# Re-use pre_res from Step 3 (or load saved results)
# Select the top enriched gene set
top_term = pre_res.res2d.sort_values("NES", ascending=False).index[0]
print(f"Top enriched gene set: {top_term}")
# Plot running enrichment score
ax = gseaplot(
rank_metric=pre_res.ranking,
term=top_term,
**pre_res.results[top_term],
ofname="gsea_results/top_geneset_enrichment.pdf",
)
plt.tight_layout()
plt.savefig("gsea_enrichment_plot.png", dpi=150)
print("Saved: gsea_enrichment_plot.png")
Step 5: Enrichment Dot Plot for Multiple Terms
Generate a dot plot showing enrichment significance and gene ratio across top pathways.
import gseapy as gp
import matplotlib.pyplot as plt
from gseapy.plot import dotplot
# Run ORA and plot results
enr = gp.enrichr(
gene_list=["TP53", "BRCA1", "CDK2", "CCND1", "MYC", "EGFR",
"KRAS", "PTEN", "RB1", "AKT1", "PIK3CA", "MDM2",
"BCL2", "CDKN1A", "E2F1", "CCNE1"],
gene_sets=["KEGG_2021_Human"],
organism="human",
outdir=None,
cutoff=0.05,
)
# Dot plot: x=gene ratio, size=-log10(p), color=adjusted p-value
ax = dotplot(
enr.results,
column="Adjusted P-value",
x="Gene_set",
title="KEGG Enrichment",
cmap="viridis_r",
size=10,
top_term=15,
figsize=(6, 8),
ofname="enrichment_dotplot.pdf",
)
plt.tight_layout()
plt.savefig("enrichment_dotplot.png", dpi=150, bbox_inches="tight")
print("Saved: enrichment_dotplot.png")
Step 6: Integrate with DESeq2 / scanpy Output
Use GSEApy directly on differential expression results.
import gseapy as gp
import pandas as pd
# From DESeq2 output loaded into Python
# deseq_df = pd.read_csv("deseq2_results.tsv", sep="\t", index_col=0)
# deseq_df = deseq_df.dropna(subset=["log2FoldChange", "padj"])
# Simulate DESeq2 output
import numpy as np
np.random.seed(0)
n = 500
deseq_df = pd.DataFrame({
"log2FoldChange": np.random.normal(0, 1.5, n),
"padj": np.random.uniform(0, 1, n),
}, index=[f"GENE{i}" for i in range(n)])
# Significant up/down gene lists for ORA
up_genes = deseq_df[(deseq_df["padj"] < 0.05) & (deseq_df["log2FoldChange"] > 1)].index.tolist()
dn_genes = deseq_df[(deseq_df["padj"] < 0.05) & (deseq_df["log2FoldChange"] < -1)].index.tolist()
print(f"Upregulated: {len(up_genes)}, Downregulated: {len(dn_genes)}")
# ORA on upregulated genes
if up_genes:
enr_up = gp.enrichr(gene_list=up_genes,
gene_sets=["GO_Biological_Process_2023", "KEGG_2021_Human"],
organism="human", outdir=None)
sig_up = enr_up.results[enr_up.results["Adjusted P-value"] < 0.05]
print(f"Enriched terms (upregulated): {len(sig_up)}")
print(sig_up.sort_values("Adjusted P-value").head(5)[["Term", "Adjusted P-value"]])
# Preranked GSEA on full ranked list
ranked = deseq_df["log2FoldChange"].sort_values(ascending=False)
pre = gp.prerank(rnk=ranked, gene_sets="MSigDB_Hallmark_2020",
threads=4, permutation_num=500, outdir="gsea_out/", seed=42)
print(pre.res2d[pre.res2d["FDR q-val"] < 0.25].sort_values("NES", ascending=False)
.head(5)[["Term", "NES", "FDR q-val"]])
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
gene_sets (enrichr) |
required | string or list | Database name(s) from Enrichr; use gp.get_library_name() to list |
organism (enrichr) |
"human" |
"human", "mouse", "fly", "fish", "worm", "yeast" |
Species for gene set lookup |
cutoff (enrichr) |
0.05 |
0–1 | Adjusted p-value cutoff for filtering results |
rnk (prerank) |
required | pd.Series | Gene → score mapping; sorted descending (log2FC recommended) |
permutation_num (prerank) |
1000 |
100–10000 | Permutations for p-value estimation; 1000 for publication |
min_size (prerank) |
15 |
5–50 | Minimum gene set size; filters small/poorly characterized sets |
max_size (prerank) |
500 |
100–2000 | Maximum gene set size; filters very large generic sets |
threads (prerank) |
4 |
1–64 | CPU threads for permutation |
seed (prerank) |
None |
integer | Random seed for reproducibility |
weighted_score_type (prerank) |
1 |
0, 1, 1.5 | GSEA weighting; 1 = standard weighted GSEA |
Common Recipes
Recipe 1: Compare Enrichment Between Two Conditions
import gseapy as gp
import pandas as pd
conditions = {
"treated_vs_ctrl": ["TP53", "BRCA1", "CDK2", "CCND1", "MYC"],
"treated2_vs_ctrl": ["EGFR", "KRAS", "PTEN", "RB1", "AKT1"],
}
results = {}
for label, genes in conditions.items():
enr = gp.enrichr(gene_list=genes,
gene_sets=["MSigDB_Hallmark_2020"],
organism="human",
outdir=None)
sig = enr.results[enr.results["Adjusted P-value"] < 0.05]
results[label] = set(sig["Term"])
print(f"{label}: {len(sig)} significant Hallmark terms")
# Overlap
shared = results["treated_vs_ctrl"] & results["treated2_vs_ctrl"]
print(f"Shared terms: {shared}")
Recipe 2: Batch Prerank for Multiple Comparisons
import gseapy as gp
import pandas as pd
from pathlib import Path
# Load multiple DESeq2 result files
comparisons = {
"treat_vs_ctrl": "deseq_treat_vs_ctrl.tsv",
"drug_vs_ctrl": "deseq_drug_vs_ctrl.tsv",
}
for name, file in comparisons.items():
# df = pd.read_csv(file, sep="\t", index_col=0)
# ranked = df["log2FoldChange"].dropna().sort_values(ascending=False)
# Example: generate synthetic ranked list
import numpy as np
ranked = pd.Series(np.random.normal(0, 1, 800),
index=[f"G{i}" for i in range(800)]).sort_values(ascending=False)
pre = gp.prerank(
rnk=ranked,
gene_sets=["MSigDB_Hallmark_2020", "KEGG_2021_Human"],
threads=4,
permutation_num=500,
outdir=f"gsea_results/{name}/",
seed=42,
)
sig = pre.res2d[pre.res2d["FDR q-val"] < 0.25]
print(f"{name}: {len(sig)} significant gene sets")
pre.res2d.to_csv(f"gsea_results/{name}/all_results.tsv", sep="\t")
Expected Outputs
| Output | Format | Description |
|---|---|---|
enr.results |
DataFrame | ORA results: Term, P-value, Adjusted P-value, Combined Score, Genes |
pre_res.res2d |
DataFrame | Prerank results: Term, ES, NES, NOM p-val, FDR q-val, Gene % |
gsea_results/*.csv |
CSV | Saved enrichment tables per database |
gsea_results/*.pdf |
GSEA running-score plots (one per gene set) | |
enrichment_dotplot.png |
PNG | Dot plot of top enriched terms |
gseaplot output |
PNG/PDF | Running enrichment score + ranked list plot |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ConnectionError in enrichr |
No internet or Enrichr API down | Check https://maayanlab.cloud/Enrichr/; use local gene sets with gene_sets="path/to/gmt" |
| No significant terms returned | Gene list too small or wrong gene ID format | Use ≥10 genes; ensure HGNC symbols (not Ensembl IDs); convert with pyensembl |
| Prerank returns all NES ≈ 0 | Ranked list not sorted or too few genes | Verify rnk is sorted descending; check min_size ≤ gene set sizes |
KeyError in gene set |
Gene set name misspelled | Use gp.get_library_name() to get exact database names |
| Low NES with FDR > 0.25 | Signal is weak or permutation count too low | Increase permutation_num to 1000; check raw p-values in NOM p-val |
| GSEA plot shows flat line | Gene set has no intersection with ranked list | Check gene naming; confirm gene set species matches data |
| Memory error during prerank | Large expression matrix + high permutations | Reduce permutation_num; use prerank instead of gsea when possible |
| Enrichr results differ from Java GSEA | Different gene set versions | Specify exact database version string from gp.get_library_name() |
References
- GSEApy documentation — official usage guide and API reference
- GSEApy GitHub: zqfang/GSEApy — source code and examples
- Fang Z et al. (2023) "GSEApy: a comprehensive package for performing gene set enrichment analysis in Python" — Bioinformatics 39(1):btac757. DOI:10.1093/bioinformatics/btac757
- Subramanian A et al. (2005) "Gene set enrichment analysis: A knowledge-based approach for interpreting genome-wide expression profiles" — PNAS 102(43):15545-15550. DOI:10.1073/pnas.0506580102
- Enrichr gene set databases — full list of 200+ available gene set libraries
skills/genomics-bioinformatics/rnaseq/pydeseq2-differential-expression/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pydeseq2-differential-expression -g -y
SKILL.md
Frontmatter
{
"name": "pydeseq2-differential-expression",
"license": "CC-BY-4.0",
"description": "Bulk RNA-seq DE with PyDESeq2: load counts, normalize, fit negative binomial models, Wald test (BH-FDR), LFC shrinkage, volcano\/MA plots. Use for two-group comparisons, multi-factor designs with batch correction, multiple contrasts."
}
PyDESeq2 Differential Expression Analysis
Overview
PyDESeq2 is a Python reimplementation of the R DESeq2 package for differential gene expression analysis from bulk RNA-seq count data. It fits negative binomial generalized linear models per gene, estimates dispersion with empirical Bayes shrinkage, and performs Wald tests with Benjamini-Hochberg FDR correction. This skill covers the full pipeline from raw counts to publication-ready result tables and visualizations.
When to Use
- Identifying differentially expressed genes between two or more experimental conditions from bulk RNA-seq
- Performing two-group comparisons (e.g., treated vs control) with proper statistical testing
- Running multi-factor designs that account for batch effects or covariates (e.g.,
~batch + condition) - Applying log2 fold change shrinkage (apeGLM) for ranking and visualization
- Generating volcano plots, MA plots, and heatmaps from differential expression results
- Converting R-based DESeq2 workflows to a pure Python environment
- Integrating DE analysis into larger Python bioinformatics pipelines (e.g., with scanpy, pandas)
- Use DESeq2 (R/Bioconductor) or edgeR instead for the reference R implementations with the broadest method support and community validation
Prerequisites
- Python packages:
pydeseq2>=0.4,pandas>=1.4,numpy>=1.23,scipy>=1.11,scikit-learn>=1.1,anndata>=0.8 - Data requirements: Raw (unnormalized) integer count matrix (samples x genes) + sample metadata DataFrame
- Environment: Python 3.10+; optional
matplotlib,seabornfor visualization
pip install pydeseq2 matplotlib seaborn
Workflow
Step 1: Data Loading and Validation
Load the count matrix and metadata. PyDESeq2 expects counts as a samples x genes DataFrame with non-negative integers, and metadata as a samples x variables DataFrame with matching indices.
import pandas as pd
# Load data — typical CSV has genes as rows, samples as columns
counts_raw = pd.read_csv("counts.csv", index_col=0)
metadata = pd.read_csv("metadata.csv", index_col=0)
# Transpose if needed: PyDESeq2 requires samples x genes
if counts_raw.shape[0] > counts_raw.shape[1]:
counts_df = counts_raw.T # genes x samples → samples x genes
else:
counts_df = counts_raw
# Validate alignment
common_samples = counts_df.index.intersection(metadata.index)
counts_df = counts_df.loc[common_samples]
metadata = metadata.loc[common_samples]
print(f"Samples: {counts_df.shape[0]}, Genes: {counts_df.shape[1]}")
print(f"Metadata columns: {list(metadata.columns)}")
print(f"Condition counts:\n{metadata['condition'].value_counts()}")
Step 2: Gene Filtering
Remove lowly expressed genes to improve statistical power and reduce multiple testing burden.
# Filter genes with total counts below threshold
min_total_counts = 10
gene_counts = counts_df.sum(axis=0)
genes_to_keep = gene_counts[gene_counts >= min_total_counts].index
counts_df = counts_df[genes_to_keep]
# Optional: require minimum counts in a minimum number of samples
min_count_per_sample = 5
min_samples = 3
genes_expressed = (counts_df >= min_count_per_sample).sum(axis=0) >= min_samples
counts_df = counts_df.loc[:, genes_expressed]
print(f"Genes after filtering: {counts_df.shape[1]}")
Step 3: DeseqDataSet Initialization and Fitting
Create the DESeq dataset object, specify the design formula, and run the full pipeline (size factor estimation, dispersion estimation, model fitting).
from pydeseq2.dds import DeseqDataSet
dds = DeseqDataSet(
counts=counts_df,
metadata=metadata,
design="~condition", # Wilkinson-style formula
refit_cooks=True, # Refit after Cook's outlier removal
n_cpus=4 # Parallel threads
)
# Run: size factors → dispersions → trend → MAP shrinkage → LFC fitting
dds.deseq2()
# Inspect normalization
print(f"Size factors (first 5): {dds.obsm['size_factors'][:5]}")
print(f"Size factor range: {dds.obsm['size_factors'].min():.2f} - {dds.obsm['size_factors'].max():.2f}")
Step 4: Statistical Testing (Wald Test)
Perform Wald tests to identify differentially expressed genes. Specify the contrast as [variable, test_level, reference_level].
from pydeseq2.ds import DeseqStats
ds = DeseqStats(
dds,
contrast=["condition", "treated", "control"],
alpha=0.05, # FDR threshold
cooks_filter=True, # Filter Cook's outliers
independent_filter=True # Independent filtering for power
)
ds.summary()
# Access full results
results = ds.results_df
print(f"Total genes tested: {len(results)}")
print(f"Significant (padj < 0.05): {(results.padj < 0.05).sum()}")
Step 5: LFC Shrinkage (Optional)
Apply apeGLM shrinkage to reduce noise in log2 fold change estimates. Use shrunk values for visualization and ranking, not for significance calls.
# Apply shrinkage — modifies results_df.log2FoldChange in place
ds.lfc_shrink()
# Compare pre/post shrinkage effect
print(f"Max |LFC| after shrinkage: {results.log2FoldChange.abs().max():.2f}")
print(f"Genes with |LFC| > 2: {(results.log2FoldChange.abs() > 2).sum()}")
Step 6: Result Filtering and Export
Filter significant genes and export results for downstream analysis.
import numpy as np
# Significance + effect size filter
significant = results[
(results.padj < 0.05) &
(results.log2FoldChange.abs() > 1.0)
].copy()
# Separate up/down-regulated
up = significant[significant.log2FoldChange > 0].sort_values("padj")
down = significant[significant.log2FoldChange < 0].sort_values("padj")
print(f"Upregulated: {len(up)}, Downregulated: {len(down)}")
# Export
results.to_csv("deseq2_all_results.csv")
significant.to_csv("deseq2_significant.csv")
up.to_csv("deseq2_upregulated.csv")
down.to_csv("deseq2_downregulated.csv")
print("Results exported to CSV files")
Step 7: Visualization — Volcano Plot
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(10, 7))
res = results.dropna(subset=["padj"]).copy()
res["-log10_padj"] = -np.log10(res.padj)
# Color categories
is_sig = (res.padj < 0.05) & (res.log2FoldChange.abs() > 1.0)
is_up = is_sig & (res.log2FoldChange > 0)
is_down = is_sig & (res.log2FoldChange < 0)
ax.scatter(res.loc[~is_sig, "log2FoldChange"], res.loc[~is_sig, "-log10_padj"],
c="grey", alpha=0.3, s=8, label="Not significant")
ax.scatter(res.loc[is_up, "log2FoldChange"], res.loc[is_up, "-log10_padj"],
c="firebrick", alpha=0.6, s=12, label=f"Up ({is_up.sum()})")
ax.scatter(res.loc[is_down, "log2FoldChange"], res.loc[is_down, "-log10_padj"],
c="steelblue", alpha=0.6, s=12, label=f"Down ({is_down.sum()})")
ax.axhline(-np.log10(0.05), ls="--", c="black", alpha=0.4)
ax.axvline(-1, ls="--", c="black", alpha=0.4)
ax.axvline(1, ls="--", c="black", alpha=0.4)
ax.set_xlabel("Log2 Fold Change")
ax.set_ylabel("-Log10(Adjusted P-value)")
ax.set_title("Volcano Plot — Treated vs Control")
ax.legend()
plt.tight_layout()
plt.savefig("volcano_plot.png", dpi=300)
print("Saved volcano_plot.png")
Step 8: Visualization — MA Plot
fig, ax = plt.subplots(figsize=(10, 7))
ax.scatter(np.log10(res.loc[~is_sig, "baseMean"] + 1),
res.loc[~is_sig, "log2FoldChange"],
c="grey", alpha=0.3, s=8)
ax.scatter(np.log10(res.loc[is_sig, "baseMean"] + 1),
res.loc[is_sig, "log2FoldChange"],
c="firebrick", alpha=0.6, s=12)
ax.axhline(0, ls="--", c="black", alpha=0.5)
ax.set_xlabel("Log10(Mean Normalized Count + 1)")
ax.set_ylabel("Log2 Fold Change")
ax.set_title("MA Plot")
plt.tight_layout()
plt.savefig("ma_plot.png", dpi=300)
print("Saved ma_plot.png")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
design |
(required) | Wilkinson formula | Model formula; put covariates before variable of interest |
contrast |
None |
[var, test, ref] |
Which comparison to test; None uses last coefficient |
alpha |
0.05 |
0.01–0.10 |
FDR threshold for significance calling |
refit_cooks |
True |
True/False |
Refit model after removing Cook's distance outliers |
cooks_filter |
True |
True/False |
Apply Cook's distance filtering during testing |
independent_filter |
True |
True/False |
Independent filtering to optimize detection power |
n_cpus |
1 |
1–N |
Number of parallel threads for dispersion fitting |
min_total_counts (user) |
10 |
5–50 |
Gene filtering: minimum total reads across all samples |
lfc_shrink() |
off | call after summary() |
apeGLM shrinkage; reduces noisy LFC estimates |
Common Recipes
Recipe: Multiple Contrasts from One Model
When comparing multiple treatment groups against a shared control.
dds = DeseqDataSet(counts=counts_df, metadata=metadata, design="~condition")
dds.deseq2()
contrasts = {
"A_vs_ctrl": ["condition", "treatment_A", "control"],
"B_vs_ctrl": ["condition", "treatment_B", "control"],
"C_vs_ctrl": ["condition", "treatment_C", "control"],
}
for name, contrast in contrasts.items():
ds = DeseqStats(dds, contrast=contrast, alpha=0.05)
ds.summary()
n_sig = (ds.results_df.padj < 0.05).sum()
ds.results_df.to_csv(f"results_{name}.csv")
print(f"{name}: {n_sig} significant genes")
Recipe: Batch-Corrected Analysis
When samples come from multiple batches or sequencing runs.
# Verify batch is not confounded with condition
print(pd.crosstab(metadata["batch"], metadata["condition"]))
dds = DeseqDataSet(
counts=counts_df, metadata=metadata,
design="~batch + condition" # batch first, then variable of interest
)
dds.deseq2()
ds = DeseqStats(dds, contrast=["condition", "treated", "control"])
ds.summary()
print(f"Significant genes (batch-corrected): {(ds.results_df.padj < 0.05).sum()}")
Recipe: P-value Distribution QC
Diagnostic check — a healthy analysis shows a flat histogram with a spike near 0.
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# P-value histogram
axes[0].hist(ds.results_df.pvalue.dropna(), bins=50, edgecolor="black")
axes[0].set_xlabel("P-value")
axes[0].set_ylabel("Frequency")
axes[0].set_title("P-value Distribution")
# Dispersion plot
axes[1].scatter(
np.log10(dds.varm["_normed_means"] + 1),
np.log10(dds.varm["dispersions"]),
alpha=0.3, s=5
)
axes[1].set_xlabel("Log10(Mean Expression + 1)")
axes[1].set_ylabel("Log10(Dispersion)")
axes[1].set_title("Dispersion vs Mean")
plt.tight_layout()
plt.savefig("qc_diagnostics.png", dpi=300)
print("Saved qc_diagnostics.png")
Recipe: Save and Reload DESeq Dataset
For resuming analysis without re-running the expensive fitting step.
import pickle
# Save
with open("dds_fitted.pkl", "wb") as f:
pickle.dump(dds.to_picklable_anndata(), f)
print("Saved fitted DESeqDataSet")
# Reload
with open("dds_fitted.pkl", "rb") as f:
adata = pickle.load(f)
# Note: reload requires re-constructing DeseqDataSet from the AnnData
Expected Outputs
deseq2_all_results.csv— Full results table (baseMean, log2FoldChange, lfcSE, stat, pvalue, padj) for all tested genesdeseq2_significant.csv— Filtered results (padj < 0.05 and |LFC| > 1)deseq2_upregulated.csv— Significant upregulated genes sorted by padjdeseq2_downregulated.csv— Significant downregulated genes sorted by padjvolcano_plot.png— Volcano plot with significance and fold change thresholdsma_plot.png— MA plot showing fold change vs mean expressionqc_diagnostics.png— P-value distribution and dispersion plot
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: Index mismatch |
Sample names differ between counts and metadata | Use counts_df.index.intersection(metadata.index) to align |
All genes have padj = NaN |
Genes have zero variance or all zero counts | Apply stricter gene filtering (increase min_total_counts) |
Design matrix is not full rank |
Confounded variables (e.g., all treated in one batch) | Check with pd.crosstab(); simplify design or remove confounded variable |
| No significant genes found | Small effect size, high variability, or low sample size | Check p-value distribution; relax alpha or |
MemoryError during fitting |
Too many genes or very large dataset | Pre-filter more aggressively; reduce n_cpus; use machine with more RAM |
| Very large size factors (>5) | Extreme library size differences | Verify raw counts are unnormalized; check for contamination or failed libraries |
| Shrinkage produces unexpected LFCs | Calling lfc_shrink() before summary() |
Always call ds.summary() first, then ds.lfc_shrink() |
References
- PyDESeq2 Documentation — Official API reference and tutorials
- Muzellec et al. (2023) — PyDESeq2: a Python package for bulk differential expression analysis with DESeq2. Bioinformatics.
- Love et al. (2014) — Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology.
- DESeq2 Vignette (Bioconductor) — Comprehensive R DESeq2 guide with design formula examples
skills/genomics-bioinformatics/rnaseq/salmon-rna-quantification/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill salmon-rna-quantification -g -y
SKILL.md
Frontmatter
{
"name": "salmon-rna-quantification",
"license": "GPL-3.0",
"description": "Ultra-fast RNA-seq transcript\/gene quantification via quasi-mapping (no BAM). Builds a k-mer index from transcriptome FASTA, quantifies in minutes. Outputs TPM\/count tables (quant.sf) with optional GC- and sequence-bias correction. Integrates with tximeta\/tximport for DESeq2\/edgeR. Use STAR when a genome-aligned BAM is needed."
}
Salmon — Fast RNA-seq Quantification
Overview
Salmon quantifies transcript abundance from RNA-seq reads using quasi-mapping — matching reads to a k-mer index of the transcriptome without full genome alignment. This makes Salmon 20–50× faster than alignment-based tools while producing accurate TPM and estimated count values. Salmon corrects for sequence-specific bias (--seqBias), GC-content bias (--gcBias), and fragment length distribution automatically. Output quant.sf files integrate directly with tximeta (R) or pydeseq2 (Python) for differential expression analysis. For improved accuracy, decoy-aware indexing uses the full genome to identify spurious quasi-mappings.
When to Use
- Performing fast RNA-seq quantification when you do not need a genome-aligned BAM file
- Running large-scale RNA-seq studies where alignment speed is a bottleneck (Salmon is 20-50× faster than STAR + featureCounts)
- Computing TPM and estimated counts from bulk RNA-seq for differential expression with DESeq2 or edgeR
- Correcting for GC bias, fragment length, and sequence context bias with
--gcBias --seqBias - Estimating transcript-level uncertainty via bootstrap resampling with
--numBootstraps - Use STAR instead when you need a genome-aligned BAM for downstream tools (variant calling, deeptools, IGV visualization)
- Use Kallisto as an alternative for similar speed; Salmon provides better bias correction and decoy-aware indexing
Prerequisites
- Software: Salmon ≥ 1.10 (conda or pre-compiled binary)
- Reference: transcriptome FASTA (cDNA sequences, e.g., GENCODE or Ensembl) + genome FASTA for decoy-aware indexing
- Python packages:
pandasfor parsing output;pydeseq2for differential expression
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v salmonfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run salmonrather than baresalmon.
# Install with conda (recommended)
conda install -c bioconda salmon
# Verify
salmon --version
# salmon 1.10.3
# Or download pre-compiled binary
wget https://github.com/COMBINE-lab/salmon/releases/download/v1.10.0/salmon-1.10.0_linux_x86_64.tar.gz
tar xzvf salmon-1.10.0_linux_x86_64.tar.gz
export PATH="$PWD/salmon-latest_linux_x86_64/bin:$PATH"
Quick Start
# 1. Build transcriptome index (~5 min)
salmon index -t transcriptome.fa -i salmon_index/ -p 8
# 2. Quantify paired-end reads (~2-5 min per sample)
salmon quant \
-i salmon_index/ \
-l A \
-1 sample_R1.fastq.gz \
-2 sample_R2.fastq.gz \
-p 8 \
--gcBias --validateMappings \
-o results/sample1/
# Output: results/sample1/quant.sf
head results/sample1/quant.sf
Workflow
Step 1: Download Transcriptome Reference
Fetch a transcript FASTA from GENCODE or Ensembl (cDNA sequences only — not genome).
# Human transcriptome from GENCODE (recommended)
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_47/gencode.v47.transcripts.fa.gz
gunzip gencode.v47.transcripts.fa.gz
# Count transcripts
grep -c "^>" gencode.v47.transcripts.fa
# ~252,000 transcripts
echo "Reference ready."
ls -lh gencode.v47.transcripts.fa
Step 2: Build Salmon Index
Index the transcriptome for quasi-mapping. Add genome decoys for improved accuracy.
# Standard index (fast, sufficient for most analyses)
salmon index \
-t gencode.v47.transcripts.fa \
-i salmon_index/ \
-p 8
echo "Standard index complete."
# Decoy-aware index (recommended for accuracy — uses full genome as decoy)
# Step 1: create decoy list from genome chromosome names
grep "^>" GRCh38.primary_assembly.genome.fa | cut -d " " -f 1 | sed 's/>//' > decoys.txt
# Step 2: concatenate transcriptome + genome
cat gencode.v47.transcripts.fa GRCh38.primary_assembly.genome.fa > gentrome.fa
# Step 3: build decoy-aware index
salmon index \
-t gentrome.fa \
-d decoys.txt \
-i salmon_decoy_index/ \
-p 8
echo "Decoy-aware index complete."
Step 3: Quantify Single-End Reads
Run Salmon on single-end FASTQ files.
# Single-end quantification
salmon quant \
-i salmon_index/ \
-l A \
-r sample1.fastq.gz \
-p 8 \
--seqBias \
--validateMappings \
-o results/sample1/
echo "Mapping rate: $(grep 'Mapping rate' results/sample1/logs/salmon_quant.log | tail -1)"
echo "Output: results/sample1/quant.sf"
Step 4: Quantify Paired-End Reads with Bias Correction
Run Salmon on paired-end FASTQ files with recommended bias correction flags.
# Paired-end with GC bias + sequence bias correction
salmon quant \
-i salmon_decoy_index/ \
-l A \
-1 sample1_R1.fastq.gz \
-2 sample1_R2.fastq.gz \
-p 8 \
--gcBias \
--seqBias \
--validateMappings \
--numBootstraps 100 \
-o results/sample1/
# quant.sf columns: Name, Length, EffectiveLength, TPM, NumReads
head results/sample1/quant.sf
Step 5: Load and Summarize Quantification Output
Parse quant.sf to build a gene-level count matrix for differential expression.
import pandas as pd
from pathlib import Path
# Load single-sample output
quant = pd.read_csv("results/sample1/quant.sf", sep="\t")
print(f"Transcripts quantified: {len(quant)}")
print(f"Total estimated reads: {quant['NumReads'].sum():.0f}")
print(f"Transcripts with TPM > 1: {(quant['TPM'] > 1).sum()}")
print(quant.sort_values("TPM", ascending=False).head())
# Build a multi-sample TPM matrix
samples = ["ctrl_1", "ctrl_2", "treat_1", "treat_2"]
tpm_matrix = pd.DataFrame({
s: pd.read_csv(f"results/{s}/quant.sf", sep="\t").set_index("Name")["TPM"]
for s in samples
})
print(f"\nTPM matrix: {tpm_matrix.shape}")
tpm_matrix.to_csv("tpm_matrix.tsv", sep="\t")
Step 6: Aggregate to Gene Level and Run DESeq2
Summarize transcript-level estimates to gene level and perform differential expression.
import pandas as pd
import re
from pathlib import Path
from pydeseq2.dds import DeseqDataSet
from pydeseq2.default_inference import DefaultInference
from pydeseq2.ds import DeseqStats
# Aggregate transcript counts to gene level using Ensembl gene IDs
# quant.sf Name format: "ENST00000456328.2|ENSG00000223972.6|..."
def extract_gene_id(transcript_id):
parts = transcript_id.split("|")
return parts[1].split(".")[0] if len(parts) > 1 else transcript_id
samples = ["ctrl_1", "ctrl_2", "treat_1", "treat_2"]
count_frames = []
for s in samples:
df = pd.read_csv(f"results/{s}/quant.sf", sep="\t")
df["gene_id"] = df["Name"].apply(extract_gene_id)
gene_counts = df.groupby("gene_id")["NumReads"].sum().round().astype(int)
count_frames.append(gene_counts.rename(s))
count_matrix = pd.DataFrame(count_frames).fillna(0).astype(int)
metadata = pd.DataFrame({
"condition": ["control", "control", "treated", "treated"]
}, index=samples)
# Run DESeq2
dds = DeseqDataSet(counts=count_matrix, metadata=metadata,
design_factors="condition",
inference=DefaultInference(n_cpus=4))
dds.deseq2()
stat_res = DeseqStats(dds, contrast=["condition", "treated", "control"],
inference=DefaultInference())
stat_res.summary()
results = stat_res.results_df
print(f"DE genes (padj < 0.05): {(results['padj'] < 0.05).sum()}")
print(results[results['padj'] < 0.05].sort_values('log2FoldChange').head())
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-l / --libType |
required | A (auto), SF, SR, IU, IS, MS, MR |
Library strandedness; A auto-detects from first reads |
-p / --threads |
1 |
1–64 | CPU threads; 8–16 is typical |
--gcBias |
off | flag | Correct for GC-content bias in fragment selection; recommended for most samples |
--seqBias |
off | flag | Correct for sequence-specific bias at read starts; recommended |
--validateMappings |
off | flag | Use selective alignment for improved accuracy; slight speed cost |
--numBootstraps |
0 |
0–200 | Bootstrap replicates for uncertainty estimation; enables Sleuth/Swish |
--dumpCsvCounts |
off | flag | Dump raw counts to CSV alongside quant.sf |
-d / --decoys |
— | file | Decoy sequence list for decoy-aware indexing |
--rangeFactorizationBins |
4 |
1–8 | Bins for range-factorization model; increases accuracy at small speed cost |
--skipQuant |
off | flag | Build index and exit; useful for cluster pipelines |
Common Recipes
Recipe 1: Batch Quantify All Samples
#!/bin/bash
# Quantify all paired-end samples with recommended settings
INDEX="salmon_decoy_index"
DATA="data"
OUT="results"
THREADS=12
SAMPLES=(ctrl_1 ctrl_2 treat_1 treat_2)
mkdir -p "$OUT"
for sample in "${SAMPLES[@]}"; do
echo "Quantifying: $sample"
salmon quant \
-i "$INDEX" \
-l A \
-1 "$DATA/${sample}_R1.fastq.gz" \
-2 "$DATA/${sample}_R2.fastq.gz" \
-p "$THREADS" \
--gcBias --seqBias --validateMappings \
-o "$OUT/$sample/"
echo "Done: $sample — mapping $(grep 'Mapping rate' $OUT/$sample/logs/salmon_quant.log | tail -1)"
done
echo "All samples quantified."
Recipe 2: Add Salmon to a Snakemake Pipeline
# Snakefile — Salmon quantification rule
configfile: "config.yaml"
SAMPLES = config["samples"]
rule all:
input:
expand("results/{sample}/quant.sf", sample=SAMPLES)
rule salmon_index:
input:
transcriptome = config["transcriptome_fa"]
output:
directory("salmon_index")
threads: 8
shell:
"salmon index -t {input.transcriptome} -i {output} -p {threads}"
rule salmon_quant:
input:
index = "salmon_index",
r1 = "data/{sample}_R1.fastq.gz",
r2 = "data/{sample}_R2.fastq.gz"
output:
quant = "results/{sample}/quant.sf"
params:
outdir = "results/{sample}"
threads: 8
shell:
"""
salmon quant -i {input.index} -l A \
-1 {input.r1} -2 {input.r2} \
-p {threads} --gcBias --seqBias --validateMappings \
-o {params.outdir}
"""
Recipe 3: Filter Low-Expression Transcripts and Compute Fold Changes
import pandas as pd
import numpy as np
samples = {
"ctrl_1": "results/ctrl_1/quant.sf",
"ctrl_2": "results/ctrl_2/quant.sf",
"treat_1": "results/treat_1/quant.sf",
"treat_2": "results/treat_2/quant.sf",
}
# Build TPM matrix
tpm = pd.DataFrame({
name: pd.read_csv(path, sep="\t").set_index("Name")["TPM"]
for name, path in samples.items()
})
# Filter: keep transcripts with TPM > 1 in at least 2 samples
expressed = (tpm > 1).sum(axis=1) >= 2
tpm_filt = tpm[expressed]
print(f"Expressed transcripts: {expressed.sum()} / {len(tpm)}")
# Simple log2 fold change (treat vs ctrl)
ctrl_mean = tpm_filt[["ctrl_1", "ctrl_2"]].mean(axis=1)
treat_mean = tpm_filt[["treat_1", "treat_2"]].mean(axis=1)
lfc = np.log2(treat_mean + 0.5) - np.log2(ctrl_mean + 0.5)
top_up = lfc.sort_values(ascending=False).head(10)
print("Top upregulated transcripts:")
print(top_up)
Expected Outputs
| Output | Format | Description |
|---|---|---|
quant.sf |
TSV | Transcript-level quantification: Name, Length, EffectiveLength, TPM, NumReads |
quant.genes.sf |
TSV | Gene-level quantification (when --geneMap provided) |
logs/salmon_quant.log |
Text | Detailed log with mapping rate, processed reads, elapsed time |
aux_info/meta_info.json |
JSON | Run metadata: library type detected, mapping rate, num processed reads |
aux_info/fld.gz |
Binary | Fragment length distribution (paired-end) |
bootstrap/ |
Binary | Bootstrap count distributions (when --numBootstraps > 0) |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Mapping rate < 50% | Wrong transcriptome species or assembly mismatch | Verify transcriptome FASTA matches sample organism; use genome-decoy index |
| Library type detection wrong | Ambiguous or mixed-strand library | Specify explicitly: -l SF (stranded fwd) or -l SR (stranded rev) |
quant.sf all zeros |
Index built from wrong reference | Rebuild index with correct transcriptome FASTA |
| Out of memory during indexing | Transcriptome + genome concatenation too large | Use standard index without genome decoy; or increase available RAM |
| Many low-mapping transcripts | No GC/seq bias correction | Add --gcBias --seqBias --validateMappings; helps with low-complexity regions |
meta_info.json mapping rate < 30% |
Reads are from a different molecule (e.g., rRNA contamination) | Check FastQC overrepresented sequences; verify library preparation |
| Gene-level output missing | --geneMap or -g not provided |
Re-run with -g gencode.v47.gtf to get quant.genes.sf |
| Bootstrap takes too long | High --numBootstraps on slow disk |
Reduce to --numBootstraps 30 for most DE tests; use SSD |
References
- Salmon documentation — official usage guide and FAQ
- Salmon GitHub: COMBINE-lab/salmon — source, releases, and issues
- Patro R et al. (2017) "Salmon provides fast and bias-aware quantification of transcript expression" — Nature Methods 14:417-419. DOI:10.1038/nmeth.4197
- tximeta R package — recommended for importing Salmon output into R/Bioconductor with full provenance
skills/genomics-bioinformatics/scikit-bio/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scikit-bio -g -y
SKILL.md
Frontmatter
{
"name": "scikit-bio",
"license": "BSD-3-Clause",
"description": "Python library for biology: sequence manipulation (DNA\/RNA\/protein), pairwise\/multiple alignment, phylogenetic trees (NJ, UPGMA), diversity (Shannon, Faith PD, Bray-Curtis, UniFrac), ordination (PCoA, CCA, RDA), stats (PERMANOVA, ANOSIM, Mantel), file I\/O (FASTA, FASTQ, Newick, BIOM). Use for microbiome, community ecology, or phylogenetics."
}
scikit-bio
Overview
scikit-bio is a comprehensive Python library for biological data analysis, spanning sequence manipulation, alignment, phylogenetics, microbial ecology, and multivariate statistics. It provides specialized data structures (DNA, RNA, Protein, DistanceMatrix, TreeNode, TabularMSA) that integrate with the broader Python scientific stack.
When to Use
- Calculating alpha/beta diversity and running PERMANOVA on microbiome data
- Building phylogenetic trees from distance matrices (NJ, UPGMA)
- Performing PCoA ordination on community composition data
- Reading/writing biological formats (FASTA, FASTQ, Newick, BIOM)
- Pairwise sequence alignment (Smith-Waterman, Needleman-Wunsch)
- Computing UniFrac distances for phylogenetic beta diversity
- Statistical testing on ecological distance matrices (ANOSIM, Mantel)
- Working with QIIME 2 artifacts and microbiome pipelines
- For high-throughput NGS alignment/variant calling, use STAR/BWA instead
- For protein structure prediction, use AlphaFold/ESMFold instead
Prerequisites
pip install scikit-bio
# Optional: pip install biom-format — HDF5 BIOM table support
# Optional: pip install matplotlib seaborn — visualization
Quick Start
import skbio
from skbio.diversity import alpha_diversity, beta_diversity
from skbio.stats.distance import permanova
from skbio.stats.ordination import pcoa
import numpy as np
# Sample OTU counts (samples × features)
counts = np.array([[10, 20, 30], [15, 25, 5], [5, 10, 40], [20, 5, 15]])
sample_ids = ['S1', 'S2', 'S3', 'S4']
grouping = ['control', 'control', 'treatment', 'treatment']
# Alpha diversity
shannon = alpha_diversity('shannon', counts, ids=sample_ids)
print(f"Shannon diversity: {shannon.values}") # [1.09, 1.04, 0.94, 1.03]
# Beta diversity → PCoA → PERMANOVA
bc_dm = beta_diversity('braycurtis', counts, ids=sample_ids)
pcoa_results = pcoa(bc_dm)
results = permanova(bc_dm, grouping, permutations=999)
print(f"PERMANOVA p-value: {results['p-value']}")
Core API
1. Sequence Manipulation
from skbio import DNA, RNA, Protein
# Create and manipulate sequences
dna = DNA('ATCGATCGATCG', metadata={'id': 'gene1', 'description': 'test'})
rc = dna.reverse_complement()
rna = dna.transcribe()
protein = rna.translate()
print(f"DNA: {dna}, RC: {rc}, Protein: {protein}")
# Motif finding and k-mer analysis
motif_positions = dna.find_with_regex('ATG.{3}')
kmer_freqs = dna.kmer_frequencies(k=3)
print(f"3-mer frequencies: {dict(list(kmer_freqs.items())[:3])}")
# Sequence properties
print(f"Has degenerates: {dna.has_degenerates()}")
print(f"GC content: {dna.gc_content():.2f}")
degapped = dna.degap() # Remove gap characters
# Metadata: sequence-level, positional, interval
dna = DNA('ATCGATCG', metadata={'id': 'seq1'},
positional_metadata={'quality': [30, 35, 40, 38, 32, 36, 34, 33]})
dna.interval_metadata.add([(0, 4)], metadata={'type': 'promoter'})
print(f"Quality scores: {list(dna.positional_metadata['quality'])}")
2. Sequence Alignment
from skbio import DNA
from skbio.alignment import local_pairwise_align_ssw, TabularMSA
# Pairwise local alignment (Smith-Waterman via SSW)
seq1 = DNA('ACTCGATCGATCGATCGATCG')
seq2 = DNA('ATCGATCGATCGATCGATCGA')
alignment, score, start_end = local_pairwise_align_ssw(seq1, seq2)
print(f"Score: {score}, Positions: {start_end}")
# Multiple sequence alignment from file
msa = TabularMSA.read('alignment.fasta', constructor=DNA)
consensus = msa.consensus()
conservation = msa.conservation()
print(f"Consensus: {consensus[:20]}, Conservation: {conservation[:5]}")
3. Phylogenetic Trees
from skbio import TreeNode, DistanceMatrix
from skbio.tree import nj, upgma
# Build tree from distance matrix
data = [[0, 5, 9, 9], [5, 0, 10, 10], [9, 10, 0, 8], [9, 10, 8, 0]]
dm = DistanceMatrix(data, ids=['A', 'B', 'C', 'D'])
tree = nj(dm)
print(tree.ascii_art())
# Tree operations
subtree = tree.shear(['A', 'B', 'C']) # Prune to subset
tips = [node.name for node in tree.tips()]
lca = tree.lowest_common_ancestor(['A', 'B'])
print(f"Tips: {tips}, LCA children: {len(list(lca.children))}")
# Tree comparison
tree2 = upgma(dm)
rf_dist = tree.compare_rfd(tree2)
cophenetic_dm = tree.cophenetic_matrix()
print(f"Robinson-Foulds distance: {rf_dist}")
4. Diversity Analysis
from skbio.diversity import alpha_diversity, beta_diversity
import numpy as np
counts = np.array([[10, 20, 30, 0], [15, 25, 5, 10], [5, 10, 40, 2]])
sample_ids = ['S1', 'S2', 'S3']
# Alpha diversity (multiple metrics)
for metric in ['shannon', 'simpson', 'observed_otus', 'pielou_e']:
alpha = alpha_diversity(metric, counts, ids=sample_ids)
print(f"{metric}: {alpha.values.round(3)}")
# Beta diversity
bc_dm = beta_diversity('braycurtis', counts, ids=sample_ids)
jaccard_dm = beta_diversity('jaccard', counts, ids=sample_ids)
print(f"Bray-Curtis S1-S2: {bc_dm['S1', 'S2']:.3f}")
# Phylogenetic diversity (requires tree + OTU IDs)
from skbio.diversity import alpha_diversity, beta_diversity
faith_pd = alpha_diversity('faith_pd', counts, ids=sample_ids,
tree=tree, otu_ids=feature_ids)
unifrac_dm = beta_diversity('unweighted_unifrac', counts,
ids=sample_ids, tree=tree, otu_ids=feature_ids)
w_unifrac_dm = beta_diversity('weighted_unifrac', counts,
ids=sample_ids, tree=tree, otu_ids=feature_ids)
print(f"Faith PD: {faith_pd.values}")
5. Ordination
from skbio.stats.ordination import pcoa, cca
# PCoA from distance matrix
pcoa_results = pcoa(bc_dm)
pc1 = pcoa_results.samples['PC1']
pc2 = pcoa_results.samples['PC2']
prop = pcoa_results.proportion_explained
print(f"PC1 explains {prop.iloc[0]:.1%}, PC2 explains {prop.iloc[1]:.1%}")
# CCA with environmental variables (constrained ordination)
# species_matrix: samples × species counts
# env_matrix: samples × environmental variables
cca_results = cca(species_matrix, env_matrix)
biplot_scores = cca_results.biplot_scores
print(f"Biplot scores shape: {biplot_scores.shape}")
# Save/load ordination results
pcoa_results.write('pcoa_results.txt')
loaded = skbio.OrdinationResults.read('pcoa_results.txt')
6. Statistical Testing
from skbio.stats.distance import permanova, anosim, permdisp, mantel
grouping = ['control', 'control', 'treatment']
# PERMANOVA — test group differences
perm_results = permanova(bc_dm, grouping, permutations=999)
print(f"PERMANOVA: F={perm_results['test statistic']:.3f}, "
f"p={perm_results['p-value']:.4f}")
# ANOSIM — alternative group test
anosim_results = anosim(bc_dm, grouping, permutations=999)
print(f"ANOSIM: R={anosim_results['test statistic']:.3f}, "
f"p={anosim_results['p-value']:.4f}")
# PERMDISP — test homogeneity of dispersions
permdisp_results = permdisp(bc_dm, grouping, permutations=999)
# Mantel test — correlation between distance matrices
r, p_value, n = mantel(bc_dm, jaccard_dm, method='pearson', permutations=999)
print(f"Mantel: r={r:.3f}, p={p_value:.4f}")
7. File I/O
import skbio
# Read various formats
seq = skbio.DNA.read('input.fasta', format='fasta')
tree = skbio.TreeNode.read('tree.nwk')
dm = skbio.DistanceMatrix.read('distances.txt')
# Memory-efficient reading (generator for large files)
for seq in skbio.io.read('large.fasta', format='fasta', constructor=skbio.DNA):
print(f"{seq.metadata['id']}: {len(seq)} bp")
# Write and convert formats
seq.write('output.fasta', format='fasta')
seqs = list(skbio.io.read('input.fastq', format='fastq', constructor=skbio.DNA))
skbio.io.write(seqs, format='fasta', into='output.fasta')
8. Distance Matrices
from skbio import DistanceMatrix
import numpy as np
# Create from array
data = np.array([[0, 1, 2], [1, 0, 3], [2, 3, 0]])
dm = DistanceMatrix(data, ids=['A', 'B', 'C'])
# Access and slice
print(f"A-B distance: {dm['A', 'B']}")
subset = dm.filter(['A', 'C'])
condensed = dm.condensed_form() # scipy-compatible
df = dm.to_data_frame()
print(f"Shape: {df.shape}")
Key Concepts
Data Structures
- DNA/RNA/Protein: Validated biological sequence objects with metadata (sequence-level, positional per-base, interval regions)
- TabularMSA: Multiple sequence alignment as a table; supports consensus, conservation, position entropy
- TreeNode: Phylogenetic tree with traversal, pruning, rerooting, distance calculations
- DistanceMatrix: Symmetric distance matrix with ID-based indexing; integrates with ordination and stats
- Table: BIOM feature table (samples × features) for microbiome count data; interoperates with pandas, polars, AnnData
Phylogenetic vs Non-Phylogenetic Diversity
| Metric Type | Non-Phylogenetic | Phylogenetic (requires tree) |
|---|---|---|
| Alpha diversity | Shannon, Simpson, observed_otus, Pielou's evenness | Faith's PD |
| Beta diversity | Bray-Curtis, Jaccard | Unweighted UniFrac, Weighted UniFrac |
Counts Must Be Integers
Diversity functions require integer abundance counts, not relative frequencies. If you have proportions, multiply back:
# Wrong: relative abundance
# counts = np.array([0.1, 0.5, 0.4])
# Correct: integer counts
counts = np.array([10, 50, 40])
Common Workflows
Workflow 1: Microbiome Diversity Analysis
import skbio
from skbio import TreeNode
from skbio.diversity import alpha_diversity, beta_diversity
from skbio.stats.ordination import pcoa
from skbio.stats.distance import permanova
import numpy as np
# 1. Load data
counts = np.loadtxt('otu_table.tsv', delimiter='\t', dtype=int, skiprows=1)
sample_ids = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6']
feature_ids = ['OTU1', 'OTU2', 'OTU3', 'OTU4']
tree = TreeNode.read('phylogeny.nwk')
grouping = ['control', 'control', 'control', 'treatment', 'treatment', 'treatment']
# 2. Alpha diversity
shannon = alpha_diversity('shannon', counts, ids=sample_ids)
faith = alpha_diversity('faith_pd', counts, ids=sample_ids,
tree=tree, otu_ids=feature_ids)
print(f"Mean Shannon - Control: {shannon[:3].mean():.2f}, "
f"Treatment: {shannon[3:].mean():.2f}")
# 3. Beta diversity + PCoA
unifrac_dm = beta_diversity('unweighted_unifrac', counts,
ids=sample_ids, tree=tree, otu_ids=feature_ids)
pcoa_results = pcoa(unifrac_dm)
print(f"PC1: {pcoa_results.proportion_explained.iloc[0]:.1%} variance")
# 4. Statistical testing
results = permanova(unifrac_dm, grouping, permutations=999)
print(f"PERMANOVA p={results['p-value']:.4f}")
Workflow 2: Phylogenetic Tree Construction
from skbio import DNA, DistanceMatrix
from skbio.tree import nj
from skbio.alignment import local_pairwise_align_ssw
import numpy as np
# 1. Read sequences
seqs = list(skbio.io.read('sequences.fasta', format='fasta', constructor=DNA))
n = len(seqs)
print(f"Loaded {n} sequences")
# 2. Compute pairwise distances (Hamming-like via alignment scores)
dist_data = np.zeros((n, n))
for i in range(n):
for j in range(i+1, n):
_, score, _ = local_pairwise_align_ssw(seqs[i], seqs[j])
max_len = max(len(seqs[i]), len(seqs[j]))
dist_data[i, j] = dist_data[j, i] = 1 - (score / max_len)
# 3. Build tree
ids = [s.metadata.get('id', f'seq{i}') for i, s in enumerate(seqs)]
dm = DistanceMatrix(dist_data, ids=ids)
tree = nj(dm)
print(tree.ascii_art())
# 4. Analyze tree
tree.write('output.nwk', format='newick')
cophenetic = tree.cophenetic_matrix()
print(f"Cophenetic matrix shape: {cophenetic.shape}")
Workflow 3: Sequence Processing Pipeline
- Read FASTQ sequences using generator (
skbio.io.readwithconstructor=DNA) - Filter by quality scores from
positional_metadata['quality'] - Trim adapters using
find_with_regex()and sequence slicing - Find motifs with
find_with_regex('ATG.{3}') - Translate via
dna.transcribe().translate() - Write output FASTA with
skbio.io.write()
Key Parameters
| Parameter | Function | Default | Effect |
|---|---|---|---|
metric |
alpha/beta_diversity | Required | Diversity metric name (e.g., 'shannon', 'braycurtis') |
permutations |
permanova/anosim/mantel | 999 | Number of permutations; higher = more precise p-values |
tree |
alpha/beta_diversity | None | Required for phylogenetic metrics (Faith PD, UniFrac) |
otu_ids |
alpha/beta_diversity | None | Feature IDs mapping counts to tree tips |
method |
mantel | 'pearson' | Correlation method: 'pearson' or 'spearman' |
constructor |
io.read | None | Sequence class for parsing (DNA, RNA, Protein) |
format |
io.read/write | Auto | File format (fasta, fastq, newick, etc.) |
genetic_code |
translate | 1 | NCBI genetic code (1=standard, 11=bacterial) |
k |
kmer_frequencies | Required | k-mer length for frequency calculation |
Best Practices
- Use generators for large files —
skbio.io.read()returns a generator; avoidlist()on millions of sequences - Use projected CRS for phylogenetic diversity — always provide matching
treeandotu_ids; prune tree to feature set withtree.shear(feature_ids) - Pair PERMANOVA with PERMDISP — PERMANOVA is sensitive to dispersion differences; run PERMDISP to check group homogeneity
- Use 999+ permutations for publication-quality p-values; 99 only for exploratory analysis
- Use HDF5 BIOM format over JSON for large feature tables (faster I/O, smaller files)
- Anti-pattern — relative abundance in diversity functions: diversity functions require integer counts, not proportions. Convert back if needed
- Anti-pattern — small k for k-mer analysis: k < 3 provides little discriminatory power; use k=5-7 for sequence comparison
Common Recipes
Recipe: Rarefaction Before Diversity
from skbio.diversity import subsample_counts, alpha_diversity
import numpy as np
# Rarefy to minimum depth
min_depth = min(counts.sum(axis=1))
rarefied = np.array([subsample_counts(row, n=min_depth) for row in counts])
print(f"Rarefied to {min_depth} counts per sample")
# Calculate diversity on rarefied data
shannon = alpha_diversity('shannon', rarefied, ids=sample_ids)
Recipe: Partial Beta Diversity (Large Datasets)
from skbio.diversity import partial_beta_diversity
import itertools
# Only compute specific pairs (saves time on large matrices)
pairs = list(itertools.combinations(sample_ids[:10], 2))
partial_dm = partial_beta_diversity('braycurtis', counts,
ids=sample_ids, id_pairs=pairs)
print(f"Computed {len(pairs)} pairwise distances")
Recipe: BIOM Table Operations
from skbio import Table
import numpy as np
# Read BIOM table
table = Table.read('feature_table.biom')
print(f"Samples: {table.shape[1]}, Features: {table.shape[0]}")
# Filter low-abundance features
filtered = table.filter(lambda row, id_, md: row.sum() > 10, axis='observation')
# Convert to pandas
df = table.to_dataframe()
# Normalize to relative abundance
rel_abundance = df.div(df.sum(axis=0), axis=1)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: Ids must be unique |
Duplicate IDs in DistanceMatrix/sequences | Deduplicate IDs: ids = list(dict.fromkeys(ids)) |
ValueError: Counts must be integers |
Relative abundance passed to diversity | Use integer counts; multiply back: (proportions * 1000).astype(int) |
| Memory error on large FASTA | Loading all sequences at once | Use generator: for seq in skbio.io.read(...) |
| Tree tip / OTU ID mismatch | Phylogenetic diversity fails | Prune tree: tree = tree.shear(feature_ids) |
| PERMANOVA p=0.001 but groups overlap in PCoA | Significant dispersion difference | Run permdisp() to check; PERMANOVA tests location AND dispersion |
| Wrong translation | Default genetic code (standard) used for bacteria | Set genetic_code=11 for bacterial/archaeal sequences |
| Slow NJ tree construction | O(n³) for large n | Use GME or BME for >1000 taxa: from skbio.tree import gme |
Bundled Resources
references/extended_api.md— Extended API reference covering advanced alignment parameters (SSW, gap penalties, CIGAR), tree construction algorithms (NJ vs UPGMA vs GME/BME), BIOM table manipulation, protein embeddings, and integration patterns with QIIME 2, pandas, and scikit-learn
Not migrated from original: the original's single api_reference.md (749 lines) was condensed into references/extended_api.md with focus on capabilities not covered inline. Omitted content: basic examples duplicating Core API, verbose troubleshooting (covered in Troubleshooting table above).
References
- scikit-bio documentation: https://scikit.bio/docs/latest/
- GitHub: https://github.com/scikit-bio/scikit-bio
- QIIME 2 forum: https://forum.qiime2.org
Related Skills
- scanpy-scrna-seq — single-cell RNA-seq analysis (different data domain but shares ordination concepts)
- statistical-analysis — general statistical testing to complement ecological stats
- matplotlib-scientific-plotting — visualization of PCoA plots and diversity comparisons
skills/genomics-bioinformatics/single-cell/anndata-data-structure/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill anndata-data-structure -g -y
SKILL.md
Frontmatter
{
"name": "anndata-data-structure",
"license": "BSD-3-Clause",
"description": "Annotated matrices for single-cell genomics. Stores X with obs\/var metadata, layers, embeddings (obsm\/varm), graphs (obsp\/varp), uns. Use for .h5ad\/.zarr I\/O, concatenation, scverse integration. For analysis use scanpy; for probabilistic models use scvi-tools."
}
AnnData — Annotated Data Matrices for Single-Cell Genomics
Overview
AnnData provides the standard data structure for single-cell genomics in the scverse ecosystem. It stores an observations-by-variables matrix (X) alongside cell metadata (obs), gene metadata (var), layers, embeddings (obsm/varm), graphs (obsp/varp), and unstructured metadata (uns). Supports sparse matrices, H5AD/Zarr storage, backed mode for large files, and integration with Scanpy, scvi-tools, and Muon.
When to Use
- Constructing annotated matrices from raw count data with cell/gene metadata
- Reading/writing
.h5ador.zarrfiles for single-cell experiments - Subsetting cells by quality metrics, gene sets, or metadata conditions
- Concatenating multiple experimental batches with consistent metadata
- Storing multiple data layers (raw counts, normalized, scaled) in one object
- Working with large datasets exceeding RAM (backed mode, lazy concatenation)
- Preparing data for Scanpy or scvi-tools pipelines
- For single-cell analysis (clustering, DE, visualization), use
scanpyinstead - For probabilistic models, use
scvi-toolsinstead
Prerequisites
- Python packages:
anndata,scipy,pandas,numpy - Optional:
scanpy(analysis),zarr(cloud storage),h5py(HDF5 backend) - Data requirements: count matrices (dense or sparse), cell/gene metadata tables
pip install "anndata>=0.10"
# Full ecosystem
pip install anndata scanpy zarr
Quick Start
import anndata as ad
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix
counts = csr_matrix(np.random.poisson(0.5, (500, 2000)).astype(np.float32))
obs = pd.DataFrame({"cell_type": np.random.choice(["T", "B", "NK"], 500)},
index=[f"cell_{i}" for i in range(500)])
var = pd.DataFrame(index=[f"ENSG{i:05d}" for i in range(2000)])
adata = ad.AnnData(X=counts, obs=obs, var=var)
adata.layers["raw_counts"] = counts.copy()
adata.write_h5ad("example.h5ad", compression="gzip")
print(f"Created: {adata.n_obs} cells x {adata.n_vars} genes")
# Created: 500 cells x 2000 genes
Core API
1. Object Creation
Build AnnData objects from arrays, DataFrames, and sparse matrices.
import anndata as ad
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix
# Minimal: just a matrix
adata_min = ad.AnnData(X=np.random.rand(100, 50).astype(np.float32))
print(f"Minimal: {adata_min.shape}") # (100, 50)
# Full: sparse matrix + obs/var metadata
n_obs, n_vars = 300, 1000
X = csr_matrix(np.random.poisson(1, (n_obs, n_vars)).astype(np.float32))
obs = pd.DataFrame({"cell_type": np.random.choice(["T", "B", "Mono"], n_obs),
"batch": np.repeat(["ctrl", "stim"], n_obs // 2)},
index=[f"cell_{i}" for i in range(n_obs)])
var = pd.DataFrame({"gene_symbol": [f"Gene_{i}" for i in range(n_vars)],
"mt": [i < 13 for i in range(n_vars)]},
index=[f"ENSG{i:05d}" for i in range(n_vars)])
adata = ad.AnnData(X=X, obs=obs, var=var)
print(f"Full: {adata.shape}, obs cols: {list(adata.obs.columns)}")
# Full: (300, 1000), obs cols: ['cell_type', 'batch']
# From a pandas DataFrame (rows=obs, columns=vars)
df = pd.DataFrame(np.random.rand(50, 20),
index=[f"sample_{i}" for i in range(50)],
columns=[f"feature_{i}" for i in range(20)])
adata_df = ad.AnnData(df)
print(f"From DataFrame: {adata_df.shape}") # (50, 20)
2. I/O Operations
Read and write in multiple formats including backed mode for large files.
import anndata as ad
# H5AD (native format, recommended for most use cases)
adata = ad.read_h5ad("data.h5ad")
adata.write_h5ad("output.h5ad", compression="gzip") # gzip: smaller files
# 10X Genomics formats
adata_10x = ad.read_10x_h5("filtered_feature_bc_matrix.h5")
# adata_mtx = ad.read_10x_mtx("filtered_feature_bc_matrix/")
# Zarr format (cloud-friendly, parallel I/O)
adata.write_zarr("output.zarr")
adata_zarr = ad.read_zarr("output.zarr")
# Other formats
# adata = ad.read_csv("expression.csv")
# adata = ad.read_loom("data.loom")
print(f"Loaded: {adata.n_obs} obs x {adata.n_vars} vars")
import anndata as ad
# Backed mode: lazy loading for files larger than RAM
adata_backed = ad.read_h5ad("large_data.h5ad", backed="r") # read-only
print(f"Backed: {adata_backed.n_obs} obs, isbacked={adata_backed.isbacked}")
# Filter on metadata (no data loaded), then load subset into memory
subset = adata_backed[adata_backed.obs["tissue"] == "brain"].to_memory()
print(f"Loaded subset: {subset.n_obs} cells")
# Read-write backed mode: adata_rw = ad.read_h5ad("data.h5ad", backed="r+")
# Format conversion: ad.read_loom("data.loom").write_h5ad("out.h5ad", compression="gzip")
3. Subsetting and Views
Select cells and genes by indices, names, boolean masks, or metadata conditions.
import anndata as ad
adata = ad.read_h5ad("data.h5ad")
# Boolean mask (most common)
t_cells = adata[adata.obs["cell_type"] == "T_cell"]
print(f"T cells: {t_cells.n_obs}, is_view: {t_cells.is_view}") # is_view: True
# Integer index / name-based / combined axis
first_100 = adata[:100, :500]
selected = adata[["cell_0", "cell_1"], ["ENSG00000", "ENSG00001"]]
# Combined metadata conditions
high_quality = adata[
(adata.obs["n_genes"] > 200) & (adata.obs["pct_mito"] < 0.2)
]
print(f"QC filter: {high_quality.n_obs} / {adata.n_obs} cells")
# Views vs copies: subsetting returns a view (lightweight, shares data)
# .copy() creates an independent object (REQUIRED before modification)
independent = adata[adata.obs["batch"] == "ctrl"].copy()
print(f"Is view: {independent.is_view}") # False
4. Layers, Embeddings, and Graphs
Store multiple data representations, dimensionality reductions, and cell-cell graphs.
import anndata as ad
import numpy as np
from scipy.sparse import csr_matrix
adata = ad.read_h5ad("data.h5ad")
# Layers: alternative representations of X (same shape as X)
adata.layers["raw_counts"] = adata.X.copy()
adata.layers["normalized"] = adata.X.copy()
print(f"Layers: {list(adata.layers.keys())}")
# Layers: ['raw_counts', 'normalized']
# Embeddings in obsm (n_obs x n_components)
adata.obsm["X_pca"] = np.random.randn(adata.n_obs, 50).astype(np.float32)
adata.obsm["X_umap"] = np.random.randn(adata.n_obs, 2).astype(np.float32)
print(f"obsm keys: {list(adata.obsm.keys())}")
# Variable loadings in varm (n_vars x n_components)
adata.varm["PCs"] = np.random.randn(adata.n_vars, 50).astype(np.float32)
# Pairwise graphs in obsp (n_obs x n_obs, sparse)
adata.obsp["connectivities"] = csr_matrix(
np.random.rand(adata.n_obs, adata.n_obs) > 0.99)
adata.obsp["distances"] = adata.obsp["connectivities"].copy()
# Unstructured metadata in uns (arbitrary dict)
adata.uns["experiment"] = {"date": "2024-06-01", "protocol": "10x_v3"}
adata.uns["neighbors"] = {"params": {"n_neighbors": 15, "method": "umap"}}
adata.uns["cell_type_colors"] = ["#1f77b4", "#ff7f0e", "#2ca02c"]
print(f"uns keys: {list(adata.uns.keys())}")
5. Concatenation
Merge datasets along observations or variables with flexible join and merge strategies.
import anndata as ad
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix
# Create sample datasets
def make_adata(n, genes, batch_name):
X = csr_matrix(np.random.poisson(1, (n, len(genes))).astype(np.float32))
obs = pd.DataFrame({"sample": batch_name}, index=[f"{batch_name}_{i}" for i in range(n)])
return ad.AnnData(X=X, obs=obs, var=pd.DataFrame(index=genes))
shared = [f"Gene_{i}" for i in range(100)]
adata1 = make_adata(200, shared + ["GeneA"], "batch1")
adata2 = make_adata(300, shared + ["GeneB"], "batch2")
# Along observations (axis=0): stack cells
combined = ad.concat(
[adata1, adata2], axis=0, join="inner",
label="batch", keys=["B1", "B2"], merge="same",
)
print(f"Inner join: {combined.n_obs} cells, {combined.n_vars} genes")
# Inner join: 500 cells, 100 genes
# Outer join: keeps all genes, fills missing with NaN/0
combined_outer = ad.concat([adata1, adata2], join="outer")
print(f"Outer join: {combined_outer.n_vars} genes") # 102 genes
# Along variables (axis=1): multi-modal
n = 100
obs = pd.DataFrame(index=[f"cell_{i}" for i in range(n)])
rna = ad.AnnData(X=csr_matrix(np.random.poisson(1, (n, 500)).astype(np.float32)),
obs=obs, var=pd.DataFrame(index=[f"RNA_{i}" for i in range(500)]))
protein = ad.AnnData(X=csr_matrix(np.random.rand(n, 50).astype(np.float32)),
obs=obs, var=pd.DataFrame(index=[f"ADT_{i}" for i in range(50)]))
multimodal = ad.concat([rna, protein], axis=1)
print(f"Multimodal: {multimodal.shape}") # (100, 550)
# Lazy concatenation for very large datasets (no data copying)
from anndata.experimental import AnnCollection
collection = AnnCollection(
{"batch1": adata1, "batch2": adata2},
join_obs="inner",
)
print(f"Lazy collection: {collection.n_obs} total obs")
# On-disk concat (writes directly to disk without loading all into memory)
# ad.experimental.concat_on_disk({"b1": "batch1.h5ad", "b2": "batch2.h5ad"}, "combined.h5ad")
6. Data Manipulation
Type conversions, metadata management, renaming, and quality control filtering.
import anndata as ad
import numpy as np
from scipy.sparse import csr_matrix, issparse
adata = ad.read_h5ad("data.h5ad")
# Type conversions
adata.strings_to_categoricals() # string cols -> categorical (saves memory)
if not issparse(adata.X):
adata.X = csr_matrix(adata.X) # dense -> sparse
dense_X = adata.X.toarray() if issparse(adata.X) else adata.X # sparse -> dense
# Adding/removing metadata columns
adata.obs["log_counts"] = np.log1p(np.array(adata.X.sum(axis=1)).flatten())
adata.var["mean_expr"] = np.array(adata.X.mean(axis=0)).flatten()
del adata.obs["unwanted_column"] # remove
# Renaming observations/variables/categories
adata.obs_names_make_unique() # add suffixes to duplicate names
adata.var_names_make_unique()
adata.obs["cell_type"] = adata.obs["cell_type"].cat.rename_categories(
{"T": "T_cell", "B": "B_cell"})
# Quality control filtering (always .copy() after subsetting)
adata.obs["n_genes"] = np.array((adata.X > 0).sum(axis=1)).flatten()
mito_mask = adata.var_names.str.startswith("MT-")
adata.obs["pct_mito"] = (np.array(adata[:, mito_mask].X.sum(axis=1)).flatten()
/ np.array(adata.X.sum(axis=1)).flatten())
adata_qc = adata[(adata.obs["n_genes"] > 200) & (adata.obs["pct_mito"] < 0.2)].copy()
print(f"After QC: {adata_qc.n_obs} / {adata.n_obs} cells")
Key Concepts
AnnData Object Architecture
The AnnData object is an annotated matrix with the following slots:
| Slot | Type | Shape | Description | Common Keys |
|---|---|---|---|---|
X |
matrix (sparse/dense) | (n_obs, n_vars) | Primary data (expression counts) | -- |
obs |
DataFrame | (n_obs, _) | Cell/observation metadata | cell_type, sample, n_genes, batch |
var |
DataFrame | (n_vars, _) | Gene/variable metadata | gene_name, highly_variable, mt |
layers |
dict of matrices | same as X | Alternative representations | raw_counts, normalized, scaled |
obsm |
dict of arrays | (n_obs, _) | Embeddings per observation | X_pca, X_umap, X_tsne |
varm |
dict of arrays | (n_vars, _) | Loadings per variable | PCs |
obsp |
dict of sparse | (n_obs, n_obs) | Pairwise observation graphs | connectivities, distances |
varp |
dict of sparse | (n_vars, n_vars) | Pairwise variable relationships | -- |
uns |
dict | unstructured | Analysis parameters and metadata | neighbors, colors, experiment |
raw |
AnnData | original shape | Snapshot before gene filtering | -- |
Views vs Copies
Subsetting returns a view (lightweight reference sharing data with parent). Always .copy() before modification to avoid ImplicitModificationWarning.
view = adata[adata.obs["cell_type"] == "T_cell"]
print(f"is_view: {view.is_view}") # True -- shares memory
independent = view.copy()
print(f"is_view: {independent.is_view}") # False -- independent
Storage Formats
| Format | Extension | Best For | Backed Mode | Notes |
|---|---|---|---|---|
| H5AD | .h5ad |
Default storage, random access | Yes ("r", "r+") |
Based on HDF5; supports compression |
| Zarr | .zarr |
Cloud storage, parallel I/O | No | Directory-based; good for S3/GCS |
| 10X H5 | .h5 |
10X Genomics CellRanger output | No | Read-only via read_10x_h5 |
| Loom | .loom |
Legacy format (HDF5-based) | No | Deprecated in favor of H5AD |
| CSV | .csv |
Interoperability, small datasets | No | No sparse/metadata support |
Common Workflows
Workflow 1: Single-cell RNA-seq Data Preparation
Goal: Load raw data, QC filter, normalize, and save for downstream Scanpy/scvi-tools analysis.
import anndata as ad
import numpy as np
from scipy.sparse import issparse
# 1. Load and QC filter (see Core API 6 for metric computation details)
adata = ad.read_h5ad("raw_counts.h5ad")
adata.obs["n_genes"] = np.array((adata.X > 0).sum(axis=1)).flatten()
adata.obs["total_counts"] = np.array(adata.X.sum(axis=1)).flatten()
mito = adata.var_names.str.startswith("MT-")
adata.obs["pct_mito"] = (np.array(adata[:, mito].X.sum(axis=1)).flatten()
/ np.array(adata.X.sum(axis=1)).flatten())
adata = adata[(adata.obs["n_genes"].between(200, 5000)) &
(adata.obs["pct_mito"] < 0.2)].copy()
adata = adata[:, np.array((adata.X > 0).sum(axis=0)).flatten() >= 3].copy()
# 2. Store raw counts, then normalize (total-count + log1p)
adata.layers["counts"] = adata.X.copy()
totals = np.array(adata.X.sum(axis=1)).flatten()
if issparse(adata.X):
adata.X = np.log1p(adata.X.multiply(1.0 / totals[:, None]).toarray() * 1e4)
else:
adata.X = np.log1p(adata.X / totals[:, None] * 1e4)
# 3. Save
adata.strings_to_categoricals()
adata.write_h5ad("processed.h5ad", compression="gzip")
print(f"Saved: {adata.n_obs} cells x {adata.n_vars} genes, layers: {list(adata.layers.keys())}")
Workflow 2: Multi-batch Integration
Goal: Load multiple batches, harmonize genes, concatenate with labels, and save.
import anndata as ad
from pathlib import Path
# 1. Load all batches
batches = {}
for h5 in sorted(Path("batches/").glob("*.h5ad")):
batches[h5.stem] = ad.read_h5ad(str(h5))
print(f" {h5.stem}: {batches[h5.stem].n_obs} cells")
# 2. Harmonize genes and concatenate
shared = set.intersection(*[set(a.var_names) for a in batches.values()])
batches = {k: v[:, list(shared)].copy() for k, v in batches.items()}
combined = ad.concat(batches, label="batch", join="inner", merge="same")
# 3. Clean up and save
combined.obs_names_make_unique()
combined.strings_to_categoricals()
combined.write_h5ad("combined_batches.h5ad", compression="gzip")
print(f"Combined: {combined.n_obs} cells x {combined.n_vars} genes, "
f"{combined.obs['batch'].nunique()} batches")
Workflow 3: Large Dataset Processing (Backed Mode)
Goal: Process datasets too large for memory using lazy loading.
- Open file in backed mode:
adata = ad.read_h5ad("huge.h5ad", backed="r") - Inspect metadata without loading data: check
adata.obs,adata.var - Filter on metadata conditions:
mask = adata.obs["tissue"] == "brain" - Load filtered subset into memory:
subset = adata[mask].to_memory() - Process the in-memory subset normally (normalize, filter genes)
- For chunked processing: iterate
adata[i:i+chunk_size].to_memory()(uses Core API modules 2 and 3)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
backed |
read_h5ad |
None |
None, "r", "r+" |
Lazy loading; "r" read-only, "r+" read-write |
compression |
write_h5ad |
None |
None, "gzip", "lzf" |
File compression; gzip=smaller, lzf=faster |
axis |
concat |
0 |
0, 1 |
0=stack observations, 1=stack variables |
join |
concat |
"inner" |
"inner", "outer" |
inner=shared features, outer=union with fill |
merge |
concat |
None |
"same", "unique", "first", "only" |
Strategy for non-concatenated annotations |
label |
concat |
None |
Any string | Column name added to obs tracking source |
keys |
concat |
None |
list of strings | Labels for each dataset in the label column |
chunks |
write_zarr |
None |
Tuple of ints | Chunk dimensions for Zarr arrays |
as_sparse |
read_h5ad |
{} |
Dict mapping slot to format | Convert dense arrays to sparse on read |
Best Practices
-
Use sparse matrices for count data: Single-cell count matrices are typically 90%+ zeros. Use
scipy.sparse.csr_matrixto reduce memory by ~10x.from scipy.sparse import csr_matrix adata.X = csr_matrix(adata.X) -
Convert strings to categoricals before saving: Repeated string columns (cell_type, batch, sample) waste memory. Call
adata.strings_to_categoricals()before.write_h5ad(). -
Use backed mode for files larger than RAM: Open with
backed="r", filter on obs/var metadata, then.to_memory()only the subset you need. Never try to load a 50GB file directly. -
Always copy views before modifying: Subsetting returns a view. Modifying triggers
ImplicitModificationWarning. Useadata[mask].copy()before any modification. -
Store raw counts in layers before normalization:
adata.layers["counts"] = adata.X.copy()before any transformation -- raw counts cannot be recovered from normalized data. -
Use gzip compression for long-term storage:
adata.write_h5ad("f.h5ad", compression="gzip")reduces size 2-5x. Uselzffor speed-critical workflows. -
Align external data on index: Pandas index alignment silently inserts NaN. Always use
external_series.reindex(adata.obs_names).valueswhen assigning external data to obs/var.
Common Recipes
Recipe: PyTorch DataLoader Integration
When to use: Training deep learning models on single-cell data.
import anndata as ad
from anndata.experimental.pytorch import AnnLoader
adata = ad.read_h5ad("data.h5ad")
# Create PyTorch DataLoader directly from AnnData
dataloader = AnnLoader(adata, batch_size=128, shuffle=True)
for batch in dataloader:
X_batch = batch.X # torch.Tensor, shape (128, n_vars)
obs_batch = batch.obs # DataFrame with batch metadata
print(f"Batch shape: {X_batch.shape}")
break # demo: process first batch only
Recipe: Pandas DataFrame Conversion
When to use: Interoperating with non-scverse tools that expect DataFrames.
import anndata as ad
import pandas as pd
import numpy as np
adata = ad.read_h5ad("data.h5ad")
# AnnData to DataFrame (dense, uses var_names as columns)
df = adata.to_df()
print(f"DataFrame: {df.shape}") # (n_obs, n_vars)
# Include a specific layer instead of X
df_raw = adata.to_df(layer="raw_counts")
# DataFrame back to AnnData
new_adata = ad.AnnData(df)
print(f"Back to AnnData: {new_adata.shape}")
Recipe: Optimized File Saving
When to use: Minimizing file size and save time for large datasets.
import anndata as ad
from scipy.sparse import issparse, csr_matrix
adata = ad.read_h5ad("data.h5ad")
if not issparse(adata.X):
adata.X = csr_matrix(adata.X) # ensure sparse
adata.strings_to_categoricals() # compress string columns
for key in ["temp_results"]:
adata.uns.pop(key, None) # remove bulky items
adata.write_h5ad("optimized.h5ad", compression="gzip")
print(f"Saved: {adata.n_obs} x {adata.n_vars}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
MemoryError when reading H5AD |
File too large for RAM | Use ad.read_h5ad(path, backed="r") for lazy loading |
Slow .write_h5ad() |
Large dense matrix | Convert to sparse: adata.X = csr_matrix(adata.X); use compression="gzip" |
ValueError on ad.concat() |
Mismatched var indices | Use join="inner" for shared genes, or harmonize var_names before concat |
| NaN values after adding obs column | Pandas index misalignment | Use .reindex(adata.obs_names).values when assigning external data |
ImplicitModificationWarning |
Modifying a view in-place | Call .copy() on the subset before modification |
IORegistryError on save |
Unsupported dtype in uns/obsm | Convert complex objects to strings/arrays; remove non-serializable items from uns |
| Duplicated obs_names after concat | Same barcodes across batches | Use adata.obs_names_make_unique() after concatenation |
KeyError accessing layer/obsm |
Key doesn't exist | Check available keys: list(adata.layers.keys()), list(adata.obsm.keys()) |
Ecosystem Integration
# Scanpy: preprocessing, clustering, visualization (operates on AnnData in-place)
import scanpy as sc
adata = ad.read_h5ad("data.h5ad")
sc.pp.normalize_total(adata); sc.tl.pca(adata); sc.pl.umap(adata, color="cell_type")
# Muon: multimodal data -- mu.MuData({"rna": adata_rna, "atac": adata_atac})
# scvi-tools: scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="batch")
Bundled Resources
Two reference files consolidate the original 5 reference files:
-
references/data_structure_io.md-- Consolidates data_structure.md + io_operations.md. Covers: detailed slot-by-slot API, all I/O format parameters, backed mode advanced patterns (chunked iteration, write-back). Relocated inline: core slot table (Key Concepts), basic I/O (Core API 2), format comparison (Key Concepts). Omitted: introductory prose redundant with Core API. -
references/manipulation_concatenation.md-- Consolidates manipulation.md + concatenation.md + best_practices.md. Covers: advanced merge behaviors (same/unique/first/only edge cases), on-disk concat, AnnCollection API, bulk renaming, memory optimization. Relocated inline: QC filtering (Core API 6), basic concat (Core API 5), best practices (Best Practices). Omitted: generic Python advice not AnnData-specific.
Related Skills
- scanpy-scrna-seq -- downstream analysis: preprocessing, clustering, DE testing, visualization using AnnData objects
- scvi-tools-single-cell -- probabilistic latent variable models (scVI, scANVI, TOTALVI) consuming AnnData
- cellxgene-census -- querying the CZ CELLxGENE Census database, returns AnnData objects
References
- AnnData documentation -- official API reference and tutorials
- scverse ecosystem -- coordinated single-cell analysis tools
- AnnData GitHub -- source code and issue tracker
- Virshup et al. (2024) "anndata: Access and store annotated data matrices" -- JOSS
skills/genomics-bioinformatics/single-cell/celltypist-cell-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill celltypist-cell-annotation -g -y
SKILL.md
Frontmatter
{
"name": "celltypist-cell-annotation",
"license": "MIT",
"description": "Automated scRNA-seq cell type annotation via pre-trained logistic regression. 45+ models: immune, gut, lung, brain, fetal, cancer microenvironments. Input normalized AnnData; outputs per-cell labels, majority-vote cluster labels, confidence scores. Use for fast, reference-backed annotation without manual marker inspection."
}
CellTypist Cell Type Annotation
Overview
CellTypist is an automated cell type classifier for single-cell RNA-seq data built on logistic regression models trained on curated reference atlases. Given a normalized AnnData object, it predicts cell type labels at the single-cell level and optionally applies majority voting within user-defined clusters to produce consensus, biologically coherent annotations. The tool ships with 45+ ready-to-use models spanning pan-immune, organ-specific, and developmental contexts, and supports training custom models from labeled data.
When to Use
- Annotating PBMC, whole-blood, lymph node, or other immune cell datasets using a single standardized reference model
- Generating a first-pass cell type annotation before manual curation with canonical marker genes
- Annotating cluster-level cell types in published or in-house datasets using majority voting to smooth noisy per-cell predictions
- Comparing annotation results across multiple tissue-specific models to determine the most biologically relevant reference
- Training a custom CellTypist model from a labeled reference dataset for a tissue or species not covered by pre-built models
- Quantifying annotation confidence to flag low-certainty cells (confidence score < 0.5) for manual review or exclusion
- Use scVI/scANVI (scvi-tools-single-cell) instead when you need probabilistic label transfer with batch correction and uncertainty quantification via a variational autoencoder
- Use popV (popv-cell-annotation) instead when you want ensemble consensus from 10+ methods including deep learning and KNN-based approaches
Prerequisites
- Python packages:
celltypist>=1.6,scanpy>=1.9,anndata - Data requirements: AnnData with normalized, log1p-transformed counts in
adata.X(10,000 UMIs per cell target sum). Raw counts must be normalized before calling CellTypist - Environment: Python 3.8+; 8 GB RAM sufficient for most datasets; internet access required for model downloads (first run only)
pip install celltypist "scanpy[leiden]" anndata
Quick Start
Minimal pipeline — annotate a preprocessed AnnData with the pan-immune model:
import celltypist
import scanpy as sc
# Load a preprocessed AnnData (normalized + log1p, Leiden clusters already in adata.obs)
adata = sc.read_h5ad("preprocessed_pbmc.h5ad")
# Run annotation with majority voting across Leiden clusters
predictions = celltypist.annotate(
adata,
model="Immune_All_Low.pkl",
majority_voting=True,
)
adata = predictions.to_adata()
print(adata.obs[["predicted_labels", "majority_voting", "conf_score"]].head(10))
# predicted_labels majority_voting conf_score
# CD4+ T cells CD4+ T cells 0.92
# ...
Workflow
Step 1: Installation and Model Setup
Install CellTypist and download pre-trained models. Models are cached locally after the first download.
pip install celltypist "scanpy[leiden]" anndata
import celltypist
from celltypist import models
# Download all available models (only needed once; ~2 GB total)
models.download_models(force_update=False)
# List available models with metadata
models_df = models.models_description()
print(models_df[["model", "description", "n_celltypes", "n_cells"]].to_string())
# Output (excerpt):
# model description n_celltypes n_cells
# Immune_All_Low.pkl Pan-immune low-hierarchy (98 cell types) 98 324,320
# Immune_All_High.pkl Pan-immune high-hierarchy (30 cell types) 30 324,320
# Human_Lung_Atlas.pkl Lung cell types from Human Lung Atlas 61 584,944
Step 2: Data Preparation
CellTypist requires normalized, log1p-transformed counts in adata.X. Run normalization before annotation. Raw counts must be stored separately.
import scanpy as sc
# Load raw count matrix
adata = sc.read_h5ad("raw_counts.h5ad")
# Alternatively from 10X:
# adata = sc.read_10x_mtx("filtered_feature_bc_matrix/")
# adata.var_names_make_unique()
# Store raw counts before normalization
adata.layers["counts"] = adata.X.copy()
# Normalize to 10,000 UMIs per cell and log1p-transform
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
print(f"Prepared: {adata.n_obs} cells x {adata.n_vars} genes")
print(f"adata.X mean: {adata.X.mean():.3f} (expected ~0.5–2.0 after log1p normalization)")
Step 3: Model Selection
Choose the model that best matches your tissue type and desired annotation resolution.
from celltypist import models
# Show full model table with filtering
models_df = models.models_description()
# Filter to human immune models
immune_models = models_df[models_df["description"].str.contains("immune|Immune", case=False)]
print(immune_models[["model", "description", "n_celltypes"]].to_string())
# Load a specific model to inspect its cell type labels
model = models.Model.load("Immune_All_Low.pkl")
print(f"Model cell types ({len(model.cell_types)}):")
print(model.cell_types[:20]) # first 20 labels
Available models (key selection guide):
| Model | Cell Types | Best For |
|---|---|---|
Immune_All_Low.pkl |
98 | Pan-immune with fine subtypes (e.g., MAIT, Tfh, cDC1) |
Immune_All_High.pkl |
30 | Pan-immune major lineages (T, B, NK, monocyte, DC) |
Human_Lung_Atlas.pkl |
61 | Lung: alveolar, stromal, immune, endothelial |
Pan_Fetal_Human.pkl |
139 | Fetal human multi-organ development |
Developing_Human_Brain.pkl |
51 | Brain development: progenitors, neurons, glia |
Human_Colorectal_Cancer.pkl |
62 | Colorectal cancer cells + tumor microenvironment |
Step 4: Automated Annotation
Run celltypist.annotate() with majority_voting=True for cluster-level consensus labels alongside per-cell predictions.
import celltypist
import scanpy as sc
# Ensure Leiden clusters exist for majority voting
# If not already computed:
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
sc.pp.pca(adata)
sc.pp.neighbors(adata, n_pcs=30)
sc.tl.leiden(adata, resolution=0.5, key_added="leiden")
# Run CellTypist annotation
predictions = celltypist.annotate(
adata,
model="Immune_All_Low.pkl",
majority_voting=True, # cluster-level consensus
over_clustering="leiden", # clustering key for majority voting
p_thres=0.5, # cells below threshold → "Unassigned"
mode="best match", # assign the single highest-probability label
)
# Inspect prediction object
print(type(predictions)) # celltypist.classifier.AnnotationResult
print(predictions.predicted_labels.head())
print(predictions.probability_matrix.shape) # (n_cells, n_cell_types)
Step 5: Results Integration
Transfer predictions back to the AnnData object and review confidence scores.
# Merge predictions into adata.obs
adata = predictions.to_adata()
# Key result columns:
# adata.obs["predicted_labels"] — per-cell best-match label
# adata.obs["majority_voting"] — cluster-level consensus label
# adata.obs["conf_score"] — probability of the predicted label (0–1)
print(adata.obs[["predicted_labels", "majority_voting", "conf_score"]].head(10))
print(f"\nCell type distribution (majority voting):")
print(adata.obs["majority_voting"].value_counts().head(15))
# Flag low-confidence cells
low_conf = adata.obs["conf_score"] < 0.5
print(f"\nLow-confidence cells (conf_score < 0.5): {low_conf.sum()} ({low_conf.mean():.1%})")
adata.obs["high_conf"] = ~low_conf
Step 6: Visualization and Validation
Plot predictions on UMAP, validate with canonical marker genes, and confirm annotation quality.
import scanpy as sc
import matplotlib.pyplot as plt
# Compute UMAP if not already done
if "X_umap" not in adata.obsm:
sc.tl.umap(adata)
# UMAP colored by annotation results
fig, axes = plt.subplots(1, 3, figsize=(21, 6))
sc.pl.umap(adata, color="majority_voting", legend_loc="on data",
legend_fontsize=7, title="Majority Voting", ax=axes[0], show=False)
sc.pl.umap(adata, color="predicted_labels", legend_loc="right margin",
legend_fontsize=7, title="Per-Cell Prediction", ax=axes[1], show=False)
sc.pl.umap(adata, color="conf_score", cmap="RdYlGn",
title="Confidence Score", ax=axes[2], show=False)
plt.tight_layout()
plt.savefig("celltypist_annotation.png", dpi=150, bbox_inches="tight")
plt.show()
print("Saved celltypist_annotation.png")
# Validate with canonical immune markers
marker_genes = {
"CD4+ T": ["CD3D", "CD4", "IL7R"],
"CD8+ T": ["CD3D", "CD8A", "GZMK"],
"B cells": ["MS4A1", "CD79A"],
"NK cells": ["GNLY", "NKG7"],
"CD14 Mono": ["CD14", "LYZ"],
}
sc.pl.dotplot(adata, var_names=marker_genes, groupby="majority_voting",
use_raw=False, standard_scale="var",
save="_celltypist_markers.png")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
model |
— | Any .pkl filename or path |
Selects the reference atlas for annotation; must match tissue/species |
majority_voting |
False |
True, False |
When True, smooths per-cell labels to cluster consensus; requires a clustering key in over_clustering |
over_clustering |
None |
Any adata.obs key, "leiden", "louvain" |
Clustering column used for majority voting; auto-detected if common keys present |
p_thres |
0.5 |
0.0–1.0 |
Minimum probability to assign a label; cells below threshold are labeled "Unassigned" |
mode |
"best match" |
"best match", "prob match" |
"best match": top label regardless of threshold; "prob match": applies p_thres |
min_prop |
0.0 |
0.0–1.0 |
For majority voting: minimum fraction of cluster cells with the consensus label; rare labels may be suppressed |
Key Concepts
Pre-Trained Model Architecture
Each CellTypist model is a one-vs-rest logistic regression classifier trained on a curated cell atlas. Key properties:
- Input: 33,694 genes (or fewer if the dataset has a smaller gene space — unshared genes are zero-filled)
- Output: per-cell probability vector over all cell type classes; highest probability is the predicted label
- Confidence score: the probability assigned to the winning class (0–1); high values (>0.7) indicate reliable predictions
- Species/version specificity: models are trained on specific atlases; using a human model on mouse data will produce spurious results
Majority Voting
Majority voting applies a two-stage correction after per-cell prediction:
- Each cell receives a per-cell label from the logistic regression output
- Within each cluster (e.g., Leiden cluster), the most frequent per-cell label becomes the cluster's consensus
majority_votinglabel - Cells whose per-cell label disagrees with the cluster majority are re-labeled to the cluster consensus unless
min_propis set
Majority voting is recommended when individual cells have noisy expression but the cluster is biologically coherent. Disable it when cells within a cluster are biologically heterogeneous (e.g., transitional states).
Gene Space Alignment
CellTypist automatically intersects the model's training genes with the input AnnData's gene names. Genes present in the model but absent from the query are zero-filled. Annotations degrade if fewer than ~60% of model genes are present — check with model.cell_types and adata.var_names.
Common Recipes
Recipe: Train a Custom Model
When to use: your tissue or species is not covered by an existing model, and you have a labeled reference dataset.
import celltypist
import scanpy as sc
# Load labeled reference AnnData (must be normalized + log1p)
ref = sc.read_h5ad("labeled_reference.h5ad")
# ref.obs["cell_type"] must contain string cell type labels
# Train custom model
new_model = celltypist.train(
ref,
labels="cell_type", # obs column with training labels
n_jobs=4, # parallel workers
max_iter=200, # logistic regression iterations
use_SGD=False, # use full L-BFGS-B solver (recommended for <100k cells)
top_genes=500, # number of most informative genes per class
)
# Save for reuse
new_model.write("custom_tissue_model.pkl")
print(f"Trained model: {len(new_model.cell_types)} cell types")
# Apply to query
predictions = celltypist.annotate(query_adata, model="custom_tissue_model.pkl",
majority_voting=True)
Recipe: Multi-Model Comparison
When to use: uncertain which model best matches your dataset; run multiple models and compare agreement.
import celltypist
import pandas as pd
model_names = ["Immune_All_High.pkl", "Immune_All_Low.pkl", "Human_Lung_Atlas.pkl"]
results = {}
for model_name in model_names:
preds = celltypist.annotate(adata, model=model_name, majority_voting=True)
adata_tmp = preds.to_adata()
key = model_name.replace(".pkl", "")
results[key] = adata_tmp.obs["majority_voting"].values
comparison = pd.DataFrame(results, index=adata.obs_names)
print("Agreement between Immune_All_High and Immune_All_Low:")
agreement = (comparison["Immune_All_High"] == comparison["Immune_All_Low"]).mean()
print(f" {agreement:.1%} of cells agree")
print(comparison.head(10))
Recipe: Export Annotations for Downstream Analysis
When to use: saving annotated data with all prediction metadata for downstream differential expression or trajectory analysis.
import scanpy as sc
import pandas as pd
# Save full annotated AnnData
adata.write_h5ad("annotated_celltypist.h5ad", compression="gzip")
print(f"Saved annotated_celltypist.h5ad ({adata.n_obs} cells)")
# Export cell type table
cell_table = adata.obs[[
"predicted_labels", "majority_voting", "conf_score", "leiden"
]].copy()
cell_table.to_csv("celltypist_annotations.csv")
# Cell type proportions per sample
if "sample" in adata.obs.columns:
props = (adata.obs.groupby(["sample", "majority_voting"])
.size().unstack(fill_value=0))
props_norm = props.div(props.sum(axis=1), axis=0)
props_norm.to_csv("celltypist_proportions.csv")
print(f"Cell type proportions saved (shape: {props_norm.shape})")
Expected Outputs
| Output | Description |
|---|---|
adata.obs["predicted_labels"] |
Per-cell best-match label from logistic regression |
adata.obs["majority_voting"] |
Cluster-consensus label (when majority_voting=True) |
adata.obs["conf_score"] |
Probability of the predicted label (0–1); >0.5 = confident |
adata.obsm["X_umap"] |
UMAP embedding (if computed in preprocessing step) |
celltypist_annotation.png |
UMAP panels: majority voting label, per-cell label, confidence scores |
celltypist_annotations.csv |
Per-cell annotation table with predicted labels and confidence |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: adata.X does not appear to be log1p normalized |
Raw counts passed directly | Run sc.pp.normalize_total(adata, target_sum=1e4) then sc.pp.log1p(adata) before calling celltypist.annotate() |
Many cells labeled "Unassigned" |
p_thres too high or model species mismatch |
Lower p_thres to 0.3; verify model matches species and tissue; check conf_score distribution |
KeyError for over_clustering key |
Clustering column name not found in adata.obs |
Run sc.tl.leiden(adata, key_added="leiden") first, or set over_clustering="leiden" explicitly |
| Implausible labels (e.g., immune labels on neurons) | Wrong model selected for tissue | Choose a tissue-specific model (e.g., Developing_Human_Brain.pkl for brain data); list options with models.models_description() |
MemoryError on large datasets (>500k cells) |
Full probability matrix held in RAM | Subsample to 200k cells for annotation, then transfer labels via KNN; or use mode="best match" to skip storing full probability matrix |
Low overall conf_score (<0.4 median) |
Dataset is poorly represented by the reference model | Train a custom model from a matched reference or use popv-cell-annotation for ensemble voting |
Model not found error on download |
Network issue or wrong model name | Run models.download_models(force_update=True); verify name with models.models_description()["model"].tolist() |
Related Skills
- scanpy-scrna-seq — preprocessing pipeline (QC, normalization, clustering) that produces the AnnData input for CellTypist
- popv-cell-annotation — ensemble annotation using 10+ methods; use when you want consensus across methods rather than a single model
- scvi-tools-single-cell — scANVI for semi-supervised label transfer with deep generative models and probabilistic uncertainty
- harmony-batch-correction — batch correction to apply before annotation when integrating multiple samples
References
- CellTypist documentation — official API reference, model descriptions, and tutorials
- GitHub: Teichlab/celltypist — source code and issue tracker
- Dominguez Conde et al., Science 2022 — "Cross-tissue immune cell analysis reveals tissue-specific features in humans", original CellTypist paper
- CellTypist model portal — interactive model browser with cell type hierarchies and training dataset details
skills/genomics-bioinformatics/single-cell/cellxgene-census/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cellxgene-census -g -y
SKILL.md
Frontmatter
{
"name": "cellxgene-census",
"license": "MIT",
"description": "Query CELLxGENE Census (61M+ cells). Search by cell type\/tissue\/disease\/organism; get AnnData, stream out-of-core, train PyTorch models. For your own data use scanpy; for annotated data use anndata."
}
CZ CELLxGENE Census
Overview
CZ CELLxGENE Census provides programmatic access to 61+ million standardized single-cell RNA-seq observations from human and mouse. It enables population-scale queries by cell type, tissue, disease, and donor metadata, returning expression data as AnnData objects or PyTorch dataloaders for ML workflows.
When to Use
- Querying single-cell expression data across tissues, diseases, or cell types from a curated atlas
- Building reference datasets for cell type classification or marker gene discovery
- Training ML models on large-scale single-cell data (PyTorch integration)
- Comparing gene expression across conditions (e.g., COVID-19 vs healthy) at population scale
- Exploring what single-cell datasets are available for a tissue or disease of interest
- For analyzing your own scRNA-seq data, use scanpy instead
- For manipulating AnnData objects (subsetting, concatenation), use anndata instead
Prerequisites
pip install cellxgene-census
# For ML workflows
pip install cellxgene-census[experimental]
API Rate Limits: Census uses TileDB-SOMA cloud backend. No explicit rate limit, but large queries (>1M cells) should use out-of-core processing (Module 4) to avoid memory exhaustion. Always use context managers for proper resource cleanup.
Quick Start
import cellxgene_census
with cellxgene_census.open_soma() as census:
# Get B cells from lung
adata = cellxgene_census.get_anndata(
census=census,
organism="Homo sapiens",
obs_value_filter="cell_type == 'B cell' and tissue_general == 'lung' and is_primary_data == True",
obs_column_names=["cell_type", "disease", "donor_id"],
)
print(f"Retrieved {adata.n_obs} cells × {adata.n_vars} genes")
# Retrieved ~15000 cells × 60664 genes
Core API
1. Opening and Exploring the Census
Connect to Census and discover available data.
import cellxgene_census
# Open latest stable version (always use context manager)
with cellxgene_census.open_soma() as census:
# Summary statistics
summary = census["census_info"]["summary"].read().concat().to_pandas()
print(f"Total cells: {summary['total_cell_count'][0]:,}")
# List all datasets
datasets = census["census_info"]["datasets"].read().concat().to_pandas()
print(f"Total datasets: {len(datasets)}")
print(datasets[["dataset_title", "cell_count"]].head())
# Open specific version for reproducibility
with cellxgene_census.open_soma(census_version="2023-07-25") as census:
# Reproducible analysis code here
pass
2. Cell Metadata Queries
Query cell-level metadata without downloading expression data.
import cellxgene_census
with cellxgene_census.open_soma() as census:
# Get unique cell types in brain
cell_metadata = cellxgene_census.get_obs(
census,
"homo_sapiens",
value_filter="tissue_general == 'brain' and is_primary_data == True",
column_names=["cell_type", "disease", "assay"]
)
print(f"Total brain cells: {len(cell_metadata):,}")
print(cell_metadata["cell_type"].value_counts().head(10))
# Gene metadata query
gene_metadata = cellxgene_census.get_var(
census,
"homo_sapiens",
value_filter="feature_name in ['CD4', 'CD8A', 'FOXP3']",
column_names=["feature_id", "feature_name", "feature_length"]
)
print(gene_metadata)
# Returns DataFrame with Ensembl IDs, gene symbols, and lengths
3. Expression Data Queries (Small-Medium Scale)
Retrieve expression matrices as AnnData objects for queries returning <100k cells.
import cellxgene_census
with cellxgene_census.open_soma() as census:
# Query by cell type + tissue + disease
adata = cellxgene_census.get_anndata(
census=census,
organism="Homo sapiens",
obs_value_filter="cell_type == 'T cell' and disease == 'COVID-19' and is_primary_data == True",
var_value_filter="feature_name in ['CD4', 'CD8A', 'CD19', 'FOXP3']",
obs_column_names=["cell_type", "tissue_general", "donor_id"],
)
print(f"Shape: {adata.shape}") # (n_cells, 4)
print(f"Metadata columns: {list(adata.obs.columns)}")
Filter syntax reference:
- Combine conditions:
and,or - Multiple values:
feature_name in ['CD4', 'CD8A'] - Comparison:
cell_count > 1000 - Always include
is_primary_data == Trueto avoid duplicate cells
4. Large-Scale Out-of-Core Queries
Stream expression data in chunks for queries exceeding available RAM.
import cellxgene_census
import tiledbsoma as soma
with cellxgene_census.open_soma() as census:
# Estimate query size first
metadata = cellxgene_census.get_obs(
census, "homo_sapiens",
value_filter="tissue_general == 'brain' and is_primary_data == True",
column_names=["soma_joinid"]
)
n_cells = len(metadata)
print(f"Query will return {n_cells:,} cells")
# If >100k cells, use streaming
query = census["census_data"]["homo_sapiens"].axis_query(
measurement_name="RNA",
obs_query=soma.AxisQuery(
value_filter="tissue_general == 'brain' and is_primary_data == True"
),
var_query=soma.AxisQuery(
value_filter="feature_name in ['FOXP2', 'TBR1', 'SATB2']"
)
)
# Incremental statistics
n_obs, total = 0, 0.0
for batch in query.X("raw").tables():
values = batch["soma_data"].to_numpy()
n_obs += len(values)
total += values.sum()
print(f"Processed {n_obs:,} non-zero entries, mean={total/n_obs:.4f}")
5. Dataset Presence Matrix
Check which datasets measured specific genes (not all genes are in all datasets).
import cellxgene_census
with cellxgene_census.open_soma() as census:
presence = cellxgene_census.get_presence_matrix(
census,
"homo_sapiens",
var_value_filter="feature_name in ['CD4', 'CD8A', 'PTPRC']"
)
print(f"Presence matrix shape: {presence.shape}")
# (n_datasets, n_genes) — True if gene measured in dataset
6. PyTorch ML Integration
Train models directly on Census data using the experimental dataloader.
from cellxgene_census.experimental.ml import experiment_dataloader
import cellxgene_census
with cellxgene_census.open_soma() as census:
dataloader = experiment_dataloader(
census["census_data"]["homo_sapiens"],
measurement_name="RNA",
X_name="raw",
obs_value_filter="tissue_general == 'liver' and is_primary_data == True",
obs_column_names=["cell_type"],
batch_size=128,
shuffle=True,
)
for batch in dataloader:
X = batch["X"] # Gene expression tensor
labels = batch["obs"] # Cell metadata
print(f"Batch X shape: {X.shape}, labels: {list(labels.columns)}")
break # Show first batch only
Key Concepts
Census Data Model
The Census is organized as a SOMA (Stack of Matrices, Annotated) collection:
census/
├── census_info/
│ ├── summary # Total cell counts
│ └── datasets # Dataset metadata
└── census_data/
├── homo_sapiens/
│ └── ms_RNA/
│ ├── obs # Cell metadata (61M+ rows)
│ ├── var # Gene metadata (~60k rows)
│ └── X/raw # Expression matrix (sparse)
└── mus_musculus/
└── ...
Key Metadata Fields
| Field | Type | Description | Example Values |
|---|---|---|---|
cell_type |
str | Cell Ontology label | "B cell", "neuron", "macrophage" |
tissue_general |
str | Coarse tissue grouping | "brain", "lung", "blood" |
tissue |
str | Specific tissue | "prefrontal cortex", "alveolar tissue" |
disease |
str | Disease state | "normal", "COVID-19", "lung adenocarcinoma" |
assay |
str | Sequencing assay | "10x 3' v3", "Smart-seq2" |
is_primary_data |
bool | True = unique cell | Always filter True |
donor_id |
str | Donor identifier | Used for batch effects |
tissue_general vs tissue
Use tissue_general for broad cross-tissue analyses and tissue for specific tissue queries:
# Broad: all immune system cells
obs_value_filter = "tissue_general == 'immune system'"
# Specific: only PBMCs
obs_value_filter = "tissue == 'peripheral blood mononuclear cell'"
Common Workflows
Workflow 1: Cross-Tissue Cell Type Comparison
Goal: Compare macrophage gene expression across tissues.
import cellxgene_census
import scanpy as sc
with cellxgene_census.open_soma() as census:
adata = cellxgene_census.get_anndata(
census=census,
organism="Homo sapiens",
obs_value_filter=(
"cell_type == 'macrophage' and "
"tissue_general in ['lung', 'liver', 'brain'] and "
"is_primary_data == True"
),
obs_column_names=["cell_type", "tissue_general", "donor_id", "disease"],
)
print(f"Macrophages: {adata.n_obs} cells from {adata.obs['tissue_general'].nunique()} tissues")
# Standard scanpy analysis
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
sc.pp.pca(adata, n_comps=50)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
# Differential expression across tissues
sc.tl.rank_genes_groups(adata, groupby="tissue_general")
sc.pl.umap(adata, color=["tissue_general", "disease"])
Workflow 2: Disease-Associated Gene Expression
Goal: Compare marker gene expression between COVID-19 and healthy controls.
- Query metadata to identify available cell types in COVID-19 data (Core API module 2)
- Retrieve expression data for selected cell types and marker genes (Core API module 3)
- Compute mean expression per cell type per condition
- Visualize with scanpy
dotplotormatrixplot
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
organism |
get_anndata, get_obs |
— | "Homo sapiens", "Mus musculus" |
Species selection |
census_version |
open_soma |
latest stable | Date string "YYYY-MM-DD" |
Pin to specific data release |
obs_value_filter |
get_anndata, get_obs |
None | SOMA filter expression | Cell-level filtering |
var_value_filter |
get_anndata, get_var |
None | SOMA filter expression | Gene-level filtering |
obs_column_names |
get_anndata, get_obs |
all columns | list of field names | Reduces data transfer |
batch_size |
experiment_dataloader |
128 | 32–512 | PyTorch batch size |
shuffle |
experiment_dataloader |
False | True/False | Randomize training order |
Best Practices
-
Always filter
is_primary_data == True: Without this filter, duplicate cells across datasets inflate counts and bias analyses. -
Estimate query size before loading: Call
get_obs()withcolumn_names=["soma_joinid"]to count cells before downloading expression data. Use out-of-core processing for >100k cells. -
Pin
census_versionfor reproducibility: The default "latest stable" changes periodically. Always specify the version for published analyses. -
Select only needed metadata columns: Passing
obs_column_namesreduces data transfer and memory usage significantly for large queries. -
Use
tissue_generalfor cross-tissue analyses: Thetissuefield has hundreds of specific values;tissue_generalprovides ~30 coarse groupings suitable for comparative analyses. -
Anti-pattern — querying all genes when you need a few: Specify
var_value_filterto retrieve only genes of interest. Downloading the full ~60k gene matrix for 3 marker genes wastes bandwidth and memory.
Common Recipes
Recipe: Multi-Tissue Dataset Summary
import cellxgene_census
import pandas as pd
with cellxgene_census.open_soma() as census:
metadata = cellxgene_census.get_obs(
census, "homo_sapiens",
value_filter="is_primary_data == True",
column_names=["tissue_general", "cell_type", "disease"]
)
summary = metadata.groupby("tissue_general").agg(
n_cells=("cell_type", "size"),
n_cell_types=("cell_type", "nunique"),
n_diseases=("disease", "nunique"),
).sort_values("n_cells", ascending=False)
print(summary.head(10))
Recipe: Export Census Subset to h5ad
import cellxgene_census
with cellxgene_census.open_soma() as census:
adata = cellxgene_census.get_anndata(
census=census,
organism="Homo sapiens",
obs_value_filter="tissue_general == 'heart' and is_primary_data == True",
obs_column_names=["cell_type", "disease", "donor_id", "assay"],
)
adata.write_h5ad("heart_cells.h5ad")
print(f"Saved {adata.n_obs} cells to heart_cells.h5ad")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
MemoryError on get_anndata() |
Query returns too many cells | Check count with get_obs() first; use out-of-core axis_query() for >100k cells |
| Duplicate cells in results | Missing is_primary_data == True filter |
Add is_primary_data == True to all obs_value_filter queries |
| Gene not found | Wrong gene name or gene not in Census | Check spelling (case-sensitive); try Ensembl ID via feature_id; verify with get_presence_matrix() |
ConnectionError / timeout |
Census backend temporarily unavailable | Retry after 1-2 minutes; pin a specific census_version for reliability |
| Version inconsistencies | Using default "latest" across sessions | Always specify census_version in production code |
| Slow query performance | Downloading all metadata columns | Specify only needed columns via obs_column_names |
ImportError: cellxgene_census |
Package not installed | pip install cellxgene-census (note the hyphen) |
Related Skills
- scanpy-scrna-seq — downstream analysis of Census data (clustering, DEG, visualization)
- anndata-data-structure — manipulating AnnData objects returned by Census queries
- esm-protein-language-model — protein embeddings from sequences; complementary to Census gene expression data
References
- CELLxGENE Census documentation — official API reference
- CELLxGENE Discover — web browser for Census data
- TileDB-SOMA — underlying data access layer
- CZI (2023) "CZ CELLxGENE Discover: A single-cell data platform for scalable exploration, analysis and modeling of aggregated data" — bioRxiv
skills/genomics-bioinformatics/single-cell/harmony-batch-correction/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill harmony-batch-correction -g -y
SKILL.md
Frontmatter
{
"name": "harmony-batch-correction",
"license": "MIT",
"description": "Harmony batch correction for scRNA-seq and other omics. Removes batch effects from PCA embeddings while preserving biology. Run after PCA, before UMAP. Scales to millions of cells. Python (harmonypy, scanpy) and R (Seurat)."
}
Harmony Batch Correction
Overview
Harmony is a fast, scalable algorithm for batch integration in single-cell data. It takes a PCA embedding (cells × PCs) as input and returns a corrected embedding from which batch effects have been regressed out via iterative soft-clustering and per-cluster linear regression. The corrected embedding is then used to compute neighbors, UMAP, and downstream clustering — the raw count matrix is never modified. Harmony works for single-cell RNA-seq, ATAC-seq, and other omics modalities where a PCA-like embedding is available.
When to Use
- Integrating scRNA-seq datasets from different samples, donors, sequencing runs, or experimental batches that should contain the same cell types
- Removing technical variation (library preparation protocol, 10x chemistry version, sequencing depth, sequencing platform) while preserving biological differences between cell types and conditions
- Performing fast, scalable batch correction on datasets with millions of cells where deep generative model training would be prohibitively slow
- Correcting for multiple confounding variables simultaneously (batch, donor, sequencing platform, tissue processing protocol)
- Preparing a corrected embedding for UMAP visualization, Leiden clustering, or label transfer without modifying the gene expression count matrix
- Use scVI/scvi-tools instead when you need probabilistic batch correction with a variational autoencoder (deep learning), differential expression with uncertainty estimates, or multi-modal integration (RNA + protein)
- Use BBKNN instead when you want graph-based integration that avoids constructing a corrected embedding altogether and directly builds a cross-batch nearest-neighbor graph
- Use Seurat Integration / CCA (R) instead when you are already in a Seurat workflow and prefer anchor-based integration methods
Prerequisites
- Python packages:
harmonypy,scanpy>=1.10,leidenalg,igraph,anndata,pandas,matplotlib - R packages (optional):
harmony,Seurat>=4.0(for R workflow in Step 7) - Data requirements: AnnData object with raw counts, batch/sample metadata in
adata.obs, and PCA embedding already computed (adata.obsm["X_pca"]) - Environment: Python 3.9+; 8 GB RAM sufficient for up to ~500k cells; linear memory scaling
pip install harmonypy "scanpy[leiden]" anndata pandas matplotlib
# R installation (for Step 7 / Seurat integration)
install.packages("harmony")
# If using Seurat:
install.packages("Seurat")
Quick Start
Minimal pipeline — load preprocessed data, run Harmony via scanpy, produce UMAP:
import scanpy as sc
# Load preprocessed AnnData (counts normalized, HVGs selected, PCA run)
adata = sc.read_h5ad("preprocessed.h5ad") # must have adata.obs["batch"]
# Run Harmony batch correction (corrects adata.obsm["X_pca"] → "X_pca_harmony")
sc.external.pp.harmony_integrate(adata, key="batch")
# Build neighborhood graph on corrected embedding, then UMAP
sc.pp.neighbors(adata, use_rep="X_pca_harmony")
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.5)
# Visualize
sc.pl.umap(adata, color=["batch", "leiden"], ncols=2)
print(f"Clusters: {adata.obs['leiden'].nunique()} | Cells: {adata.n_obs}")
Workflow
Step 1: Quality Control and Preprocessing
Filter, normalize, select highly variable genes (HVGs), and run PCA. Harmony is applied to the PCA embedding produced here.
import scanpy as sc
import numpy as np
sc.settings.set_figure_params(dpi=80, facecolor="white")
# Load multi-batch data — adata.obs must contain a batch column
adata = sc.read_h5ad("multi_batch_counts.h5ad")
# Alternatively, concatenate multiple AnnData objects:
# adata = sc.concat([adata1, adata2, adata3], label="batch",
# keys=["batch1", "batch2", "batch3"])
print(f"Loaded: {adata.n_obs} cells × {adata.n_vars} genes")
print(f"Batches: {adata.obs['batch'].value_counts().to_dict()}")
# QC: annotate mitochondrial genes and filter low-quality cells
adata.var["mt"] = adata.var_names.str.startswith("MT-")
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt"], inplace=True)
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_cells(adata, max_genes=6000)
sc.pp.filter_genes(adata, min_cells=3)
adata = adata[adata.obs["pct_counts_mt"] < 20].copy()
print(f"After QC: {adata.n_obs} cells × {adata.n_vars} genes")
# Normalize and log-transform (store raw counts first)
adata.layers["counts"] = adata.X.copy()
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
# Select HVGs and compute PCA
sc.pp.highly_variable_genes(adata, n_top_genes=3000, batch_key="batch")
sc.pp.pca(adata, n_comps=50, use_highly_variable=True)
print(f"PCA shape: {adata.obsm['X_pca'].shape}") # (n_cells, 50)
Step 2: Run Harmony via Scanpy Integration
The scanpy wrapper calls harmonypy under the hood and stores the corrected embedding in adata.obsm["X_pca_harmony"].
import scanpy as sc
# Run Harmony — corrects adata.obsm["X_pca"] in-place and stores result as "X_pca_harmony"
sc.external.pp.harmony_integrate(
adata,
key="batch", # obs column with batch labels
basis="X_pca", # source embedding (default)
adjusted_basis="X_pca_harmony", # destination key (default)
max_iter_harmony=20, # maximum Harmony iterations (default 10; increase for complex batches)
theta=2.0, # diversity penalty per batch variable (default 2)
sigma=0.1, # width of soft-clustering Gaussian kernel
random_state=42,
)
print(f"Corrected embedding: {adata.obsm['X_pca_harmony'].shape}")
# Expected: (n_cells, 50) — same shape as X_pca
Step 3: Run Harmony via harmonypy Directly
Use the lower-level harmonypy API when you need finer control, access to the HarmonyObject, or integration outside of scanpy.
import harmonypy
import pandas as pd
import numpy as np
# Extract PCA embedding and metadata
pca_embeddings = adata.obsm["X_pca"] # numpy array (n_cells, n_PCs)
meta_data = adata.obs[["batch", "donor"]].copy() # DataFrame with batch variables
# Run Harmony
ho = harmonypy.run_harmony(
pca_embeddings,
meta_data,
vars_use=["batch"], # list of columns to correct for
theta=2.0, # diversity penalty strength (per variable)
sigma=0.1, # soft-clustering bandwidth
nclust=None, # number of clusters; None → auto (min(100, n_cells/30))
tau=0, # protection against over-clustering small clusters
block_size=0.05, # fraction of cells per mini-batch
max_iter_harmony=20,
max_iter_kmeans=20,
epsilon_cluster=1e-5,
epsilon_harmony=1e-4,
random_state=42,
verbose=True,
)
corrected = ho.Z_corr.T # (n_cells, n_PCs) corrected embedding
adata.obsm["X_pca_harmony"] = corrected
print(f"Harmony converged. Corrected embedding shape: {corrected.shape}")
Step 4: Compute Neighbors and UMAP on Harmony Embedding
Always use use_rep="X_pca_harmony" so that downstream graph construction is based on the corrected embedding, not the original PCA.
import scanpy as sc
# Build k-nearest neighbor graph using corrected embedding
sc.pp.neighbors(
adata,
n_neighbors=15, # number of neighbors (15-30 typical for scRNA-seq)
n_pcs=None, # use all PCs in the embedding
use_rep="X_pca_harmony", # IMPORTANT: use corrected embedding
random_state=42,
)
# Compute UMAP for visualization
sc.tl.umap(adata, min_dist=0.3, spread=1.0, random_state=42)
print(f"UMAP computed: {adata.obsm['X_umap'].shape}") # (n_cells, 2)
Step 5: Leiden Clustering on Harmony-Corrected Graph
Clustering is performed on the neighbor graph built from the Harmony-corrected embedding in Step 4.
import scanpy as sc
# Run Leiden community detection at multiple resolutions
for resolution in [0.3, 0.5, 0.8]:
sc.tl.leiden(adata, resolution=resolution, key_added=f"leiden_{resolution}")
# Select final resolution
adata.obs["leiden"] = adata.obs["leiden_0.5"]
print(f"Clusters at resolution 0.5: {adata.obs['leiden'].nunique()}")
print(adata.obs["leiden"].value_counts().head())
# Find marker genes per cluster
sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon", n_genes=50)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5, groupby="leiden")
Step 6: Evaluate Batch Correction
Plot UMAP colored by batch and by cell type / cluster to visually confirm batch effects are removed while biological structure is preserved.
import scanpy as sc
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Before correction: color by batch using uncorrected PCA-based UMAP
sc.pp.neighbors(adata, use_rep="X_pca", key_added="uncorrected_neighbors", random_state=42)
sc.tl.umap(adata, neighbors_key="uncorrected_neighbors", random_state=42)
adata.obsm["X_umap_uncorrected"] = adata.obsm["X_umap"].copy()
# Restore Harmony UMAP
sc.pp.neighbors(adata, use_rep="X_pca_harmony", random_state=42)
sc.tl.umap(adata, random_state=42)
# Panel 1: Corrected UMAP colored by batch
sc.pl.umap(adata, color="batch", title="Harmony: colored by batch",
ax=axes[0], show=False)
# Panel 2: Corrected UMAP colored by leiden cluster
sc.pl.umap(adata, color="leiden", title="Harmony: Leiden clusters",
ax=axes[1], show=False)
# Panel 3: Uncorrected UMAP colored by batch (for comparison)
sc.pl.embedding(adata, basis="X_umap_uncorrected", color="batch",
title="Uncorrected PCA: batch separation",
ax=axes[2], show=False)
plt.tight_layout()
plt.savefig("harmony_batch_evaluation.png", dpi=150, bbox_inches="tight")
plt.show()
print("Batch evaluation figure saved to harmony_batch_evaluation.png")
Step 7: R Integration with Seurat
For users working in R, Harmony integrates directly with Seurat via RunHarmony().
library(Seurat)
library(harmony)
# Load Seurat object with batch metadata in metadata column "batch"
seurat_obj <- readRDS("multi_batch_seurat.rds")
# Ensure PCA is computed
seurat_obj <- NormalizeData(seurat_obj)
seurat_obj <- FindVariableFeatures(seurat_obj, nfeatures = 3000)
seurat_obj <- ScaleData(seurat_obj)
seurat_obj <- RunPCA(seurat_obj, npcs = 50)
# Run Harmony — corrects the "pca" reduction and adds "harmony" reduction
seurat_obj <- RunHarmony(
seurat_obj,
group.by.vars = "batch", # metadata column(s) to correct for
dims.use = 1:30, # number of PCs to use
theta = 2, # diversity penalty
sigma = 0.1, # soft-clustering bandwidth
max.iter.harmony = 20,
plot_convergence = TRUE, # plot objective function convergence
verbose = TRUE
)
# Build neighbor graph and UMAP using Harmony embedding
seurat_obj <- FindNeighbors(seurat_obj, reduction = "harmony", dims = 1:30)
seurat_obj <- FindClusters(seurat_obj, resolution = 0.5)
seurat_obj <- RunUMAP(seurat_obj, reduction = "harmony", dims = 1:30)
# Visualize
DimPlot(seurat_obj, reduction = "umap", group.by = "batch")
DimPlot(seurat_obj, reduction = "umap", group.by = "seurat_clusters")
cat(sprintf("Clusters: %d | Cells: %d\n",
length(unique(seurat_obj$seurat_clusters)),
ncol(seurat_obj)))
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
theta |
2.0 |
0–10 |
Diversity penalty per batch variable. Higher values force more mixing; set to 0 to disable penalty. Increase to 4–6 for very uneven batch sizes. |
sigma |
0.1 |
0.01–0.5 |
Bandwidth of the soft-clustering Gaussian kernel. Smaller values → sharper cluster assignments; larger values → smoother. Rarely needs changing. |
nclust |
None (auto) |
5–200 |
Number of soft clusters used for correction. Auto-set to min(100, n_cells/30). Increase for datasets with many rare cell types. |
max_iter_harmony |
10 |
5–50 |
Maximum Harmony iterations. Increase to 20–30 for datasets with strong or complex batch effects where convergence is slow. |
tau |
0 |
0–10 |
Overclustering protection. Positive values penalize very small clusters; use tau=5 when tiny populations are over-splitting. |
n_neighbors (scanpy) |
15 |
5–50 |
Number of nearest neighbors for the post-Harmony graph. Increase for larger datasets (>100k cells) to stabilize clusters. |
block_size |
0.05 |
0.01–0.1 |
Fraction of cells per mini-batch for the K-means step. Smaller values are faster but less stable; default is usually appropriate. |
Key Concepts
How Harmony Works
Harmony correction proceeds in three repeated phases until convergence:
- Soft K-means assignment: Each cell is softly assigned to
Kclusters based on Euclidean distance in PC space, weighted by the batch-diversity penaltytheta. - Per-cluster linear regression: Within each cluster, a linear correction is estimated for each batch variable and applied to the cluster's cells.
- Convergence check: The objective function (mixture model likelihood + diversity penalty) is evaluated; iterations stop when improvement <
epsilon_harmony.
The corrected embedding lives in the same PC space as the input PCA but with batch-specific directions subtracted. The raw count matrix (adata.X) and all gene-level information are untouched.
PCA Before Harmony
Harmony assumes the input PCA was computed with batch_key awareness. In scanpy, always pass batch_key="batch" to sc.pp.highly_variable_genes() before PCA so that HVGs are selected independently per batch and then intersected, preventing batch-specific HVGs from dominating the embedding.
Embedding Not Counts
Harmony corrects the embedding, not the count matrix. Differential expression analysis should still use the raw or normalized counts (stored in adata.layers["counts"] or adata.raw), not the corrected PCs.
Common Recipes
Recipe: Multi-Variable Correction (Batch + Donor + Platform)
Correct for multiple confounding variables simultaneously by passing a list to vars_use. Each variable gets its own diversity penalty theta.
import harmonypy
import pandas as pd
# Prepare metadata with multiple batch variables
meta_data = adata.obs[["batch", "donor", "platform"]].copy()
# Run Harmony correcting for all three variables
ho = harmonypy.run_harmony(
adata.obsm["X_pca"],
meta_data,
vars_use=["batch", "donor", "platform"], # correct for all three
theta=[2.0, 1.0, 2.0], # per-variable diversity penalty
# theta can also be a scalar (same for all variables)
max_iter_harmony=30,
random_state=42,
verbose=True,
)
adata.obsm["X_pca_harmony"] = ho.Z_corr.T
print(f"Multi-variable correction complete. Shape: {adata.obsm['X_pca_harmony'].shape}")
# Verify batch mixing improved for all variables
import scanpy as sc
sc.pp.neighbors(adata, use_rep="X_pca_harmony")
sc.tl.umap(adata, random_state=42)
sc.pl.umap(adata, color=["batch", "donor", "platform"], ncols=3)
Recipe: Diagnose Over-Correction with Marker Genes
If Harmony over-corrects (merges biologically distinct populations), canonical marker genes will lose cell-type specificity on the UMAP. Check this before downstream analysis.
import scanpy as sc
import matplotlib.pyplot as plt
# Define known canonical marker genes for your tissue
marker_genes = {
"T cells": ["CD3D", "CD3E", "TRAC"],
"B cells": ["MS4A1", "CD79A", "CD19"],
"NK cells": ["GNLY", "NKG7", "KLRD1"],
"Monocytes": ["LYZ", "CD14", "CST3"],
"Dendritic cells": ["FCER1A", "CLEC10A"],
}
# Plot canonical markers on the Harmony-corrected UMAP
all_markers = [g for genes in marker_genes.values() for g in genes
if g in adata.var_names]
sc.pl.umap(
adata,
color=all_markers[:6], # plot first 6 markers
ncols=3,
vmax="p99", # clip color scale at 99th percentile
frameon=False,
save="_marker_genes.png",
)
# If markers look diffuse or don't separate cell types cleanly,
# reduce theta (e.g., from 2.0 to 1.0) or reduce max_iter_harmony
print("If marker gene expression is diffuse across clusters, reduce theta.")
print("If batch effects remain visible, increase theta or max_iter_harmony.")
Recipe: Save and Reload Harmony-Corrected AnnData
Save the corrected object with all embeddings for downstream analysis without re-running Harmony.
import scanpy as sc
# Save with all embeddings
adata.write_h5ad("harmony_corrected.h5ad", compression="gzip")
print(f"Saved to harmony_corrected.h5ad ({adata.n_obs} cells)")
# Reload and verify
adata_loaded = sc.read_h5ad("harmony_corrected.h5ad")
print(f"Loaded. Keys in obsm: {list(adata_loaded.obsm.keys())}")
# Expected: ['X_pca', 'X_pca_harmony', 'X_umap']
Expected Outputs
| Output | Type | Description |
|---|---|---|
adata.obsm["X_pca_harmony"] |
np.ndarray (cells × PCs) |
Harmony-corrected PCA embedding; input for neighbors/UMAP/clustering |
adata.obsm["X_umap"] |
np.ndarray (cells × 2) |
2D UMAP coordinates from corrected embedding |
adata.obs["leiden"] |
pd.Categorical |
Leiden cluster labels |
adata.uns["neighbors"] |
dict |
KNN graph metadata (connectivities, distances) |
adata.uns["rank_genes_groups"] |
dict |
Per-cluster marker gene statistics (scores, p-values, log fold changes) |
harmony_batch_evaluation.png |
PNG figure | Side-by-side UMAP panels: batch labels, cluster labels, uncorrected comparison |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Harmony does not converge (max iterations reached) | Strong batch effects or too few iterations | Increase max_iter_harmony to 30–50; check that PCA was computed with batch_key HVG selection |
| Batches still separate on UMAP after correction | theta too low or too few PCs |
Increase theta to 3–4; use more PCs (40–50); ensure use_rep="X_pca_harmony" is set in sc.pp.neighbors() |
| Over-correction: biologically distinct populations merge | theta too high or too many iterations |
Reduce theta to 1.0; reduce max_iter_harmony; verify marker gene expression is cell-type-specific |
KeyError: 'X_pca_harmony' in sc.pp.neighbors() |
Step 2 (harmony_integrate) not run yet | Run sc.external.pp.harmony_integrate(adata, key="batch") before calling sc.pp.neighbors() |
AttributeError or import error for sc.external.pp.harmony_integrate |
harmonypy not installed |
Run pip install harmonypy; verify import harmonypy succeeds |
ValueError: vars_use not in meta_data columns |
Batch column name mismatch | Check adata.obs.columns and confirm the column name passed to key= exists |
| Memory error on large datasets (>1M cells) | Full PCA matrix loaded into memory | Use harmonypy directly with chunked PCA; consider subsampling to 200k cells for parameter tuning first |
| Clusters appear identical before and after correction | PCA was not computed with HVGs selected per batch | Re-run sc.pp.highly_variable_genes(adata, batch_key="batch") and sc.pp.pca() before Harmony |
References
- Harmony paper — Korsunsky et al., Nature Methods 2019 — original algorithm, benchmarks against MNN, Seurat, BBKNN, Scanorama
- harmonypy (Python) — GitHub — Python port by Kamil Slowikowski
- harmony (R) — GitHub — original R implementation by Immunogenomics Lab
- Harmony documentation — Broad Institute — tutorials and parameter guidance
- Scanpy external API — harmony_integrate — scanpy wrapper documentation
- Single-cell best practices — batch correction chapter — benchmarks and when to use each method
skills/genomics-bioinformatics/single-cell/popv-cell-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill popv-cell-annotation -g -y
SKILL.md
Frontmatter
{
"name": "popv-cell-annotation",
"license": "BSD-3-Clause",
"description": "Consensus cell type annotation: runs 10+ algorithms (KNN-Harmony\/BBKNN\/Scanorama\/scVI, CellTypist, ONCLASS, Random Forest, SCANVI, SVM, XGBoost) on a labeled reference and transfers labels via majority voting. Outputs per-method labels, consensus, agreement score. Use when single-method annotation is insufficient or you need ensemble uncertainty for novel states."
}
popV Multi-Method Cell Type Transfer
Overview
popV (Population Voting for single-cell annotation) annotates a query scRNA-seq dataset by running 10+ independent classification algorithms against a labeled reference atlas and aggregating results via majority voting. Each method produces its own label; the final popv_prediction is the consensus across all methods, and the popv_agreement score quantifies how many methods agree. This ensemble strategy is robust to individual method failures on unusual datasets and provides a principled uncertainty estimate: low agreement highlights novel cell states or annotation gaps.
When to Use
- Annotating a query dataset by transferring labels from a well-curated reference atlas when you want a consensus rather than a single model's judgment
- Identifying novel or ambiguous cell states as cells where methods disagree (low
popv_agreementscore) - Benchmarking annotation reliability by comparing per-method labels to detect systematic disagreements
- Annotating large atlas datasets (>100k cells) where batch effects between reference and query are substantial
- Producing annotation for downstream analyses that require high-confidence labels (clinical data, regulatory submissions)
- Use CellTypist (celltypist-cell-annotation) instead when speed matters and a pre-trained model matches your tissue; popV is slower because it trains multiple models on your reference
- Use scANVI (scvi-tools-single-cell) instead when you need a single probabilistic deep generative model with formal uncertainty quantification and do not require the ensemble
Prerequisites
- Python packages:
popv>=0.6,scanpy>=1.9,anndata,scvi-tools>=1.0,harmonypy,bbknn,celltypist - Data requirements: Two AnnData objects — a labeled reference (
adata_ref) with cell type labels inobs, and an unlabeled query (adata_query). Both must be from the same species and have overlapping gene sets. Raw counts inadata.X(popV applies its own normalization internally) - Environment: Python 3.9+; GPU recommended for scVI/SCANVI methods (falls back to CPU); 32 GB RAM recommended for >200k reference cells
pip install popv scvi-tools harmonypy bbknn celltypist
Quick Start
Minimal pipeline from labeled reference and unlabeled query to annotated result:
import popv
import scanpy as sc
# Load reference (labeled) and query (unlabeled) AnnData objects
adata_ref = sc.read_h5ad("reference_atlas.h5ad") # adata_ref.obs["cell_type"] exists
adata_query = sc.read_h5ad("query_dataset.h5ad")
# Prepare combined object with popV preprocessing
adata = popv.preprocessing.Process_Query(
adata_ref,
adata_query,
ref_labels_key="cell_type",
ref_batch_key="batch",
query_batch_key="batch",
unknown_celltype_label="unknown",
save_path_trained_models="./popv_models/",
n_epochs_unsupervised=50,
)
# Run all annotation methods
popv.annotation.annotate_data(adata)
# Inspect consensus results for query cells
query_mask = adata.obs["_dataset"] == "query"
print(adata[query_mask].obs[["popv_prediction", "popv_agreement"]].head(10))
Core API
Module 1: Reference and Query Data Setup
Both AnnData objects must share a gene space and have required metadata columns. popV will subset to the intersection of genes automatically.
import anndata as ad
import scanpy as sc
import numpy as np
# Reference: must have cell type labels and (optionally) batch metadata
adata_ref = sc.read_h5ad("reference_atlas.h5ad")
print(f"Reference: {adata_ref.n_obs} cells x {adata_ref.n_vars} genes")
print(f"Cell types: {adata_ref.obs['cell_type'].nunique()} unique labels")
print(f"Reference cell type counts:\n{adata_ref.obs['cell_type'].value_counts().head(10)}")
# Query: no labels required; batch metadata optional
adata_query = sc.read_h5ad("query_dataset.h5ad")
print(f"\nQuery: {adata_query.n_obs} cells x {adata_query.n_vars} genes")
# Check gene overlap (popV will handle subsetting but >70% overlap is recommended)
shared_genes = adata_ref.var_names.intersection(adata_query.var_names)
pct_shared = len(shared_genes) / adata_ref.n_vars
print(f"\nShared genes: {len(shared_genes)} ({pct_shared:.1%} of reference genes)")
if pct_shared < 0.5:
print("WARNING: <50% gene overlap — annotation quality may be reduced")
# Verify required fields before popV setup
assert "cell_type" in adata_ref.obs.columns, "Reference needs cell type labels"
# Add batch column if absent (popV requires it even for single-batch data)
if "batch" not in adata_ref.obs.columns:
adata_ref.obs["batch"] = "ref_batch"
if "batch" not in adata_query.obs.columns:
adata_query.obs["batch"] = "query_batch"
print("Reference obs columns:", adata_ref.obs.columns.tolist())
print("Query obs columns: ", adata_query.obs.columns.tolist())
Module 2: POPV Object Creation (Process_Query)
Process_Query combines reference and query, normalizes counts, selects HVGs, and prepares the joint embedding needed by all annotation methods.
import popv
# Create processed combined AnnData
adata = popv.preprocessing.Process_Query(
adata_ref,
adata_query,
ref_labels_key="cell_type", # obs column with reference labels
ref_batch_key="batch", # obs column with reference batch info
query_batch_key="batch", # obs column with query batch info
unknown_celltype_label="unknown",# label to use for query cells before annotation
save_path_trained_models="./popv_models/", # directory for scVI/SCANVI model checkpoints
n_epochs_unsupervised=50, # scVI training epochs (increase to 100–200 for large datasets)
n_epochs_semisupervised=20, # scANVI fine-tuning epochs
use_gpu=True, # GPU for scVI/SCANVI (falls back to CPU if unavailable)
hvg=4000, # number of highly variable genes to use
)
print(f"Combined object: {adata.n_obs} cells x {adata.n_vars} genes")
print(f"Dataset labels: {adata.obs['_dataset'].value_counts().to_dict()}")
# Expected: {'ref': N_ref, 'query': N_query}
Module 3: Running the Method Ensemble
annotate_data runs all selected methods sequentially and adds per-method label columns plus the consensus to adata.obs.
import popv
# Run annotation with default set of methods
popv.annotation.annotate_data(
adata,
methods=[
"knn_harmony", # KNN on Harmony-corrected embedding
"knn_bbknn", # KNN on BBKNN cross-batch graph
"knn_scvi", # KNN on scVI latent space
"scanvi_popv", # Semi-supervised scANVI label transfer
"celltypist_popv",# CellTypist logistic regression
"rf", # Random Forest on HVG expression
"xgboost", # XGBoost classifier
"svm", # Support Vector Machine
"onclass", # ONCLASS (ontology-guided)
],
)
# Inspect per-method result columns (all end in "_popv")
query_mask = adata.obs["_dataset"] == "query"
popv_cols = adata.obs.filter(like="_popv").columns.tolist()
print(f"Per-method columns: {popv_cols}")
print(adata[query_mask].obs[popv_cols + ["popv_prediction", "popv_agreement"]].head(10))
Module 4: Consensus Results and Agreement Scoring
popv_prediction is the majority-vote consensus; popv_agreement is the fraction of methods that agreed on the winning label.
import pandas as pd
query_mask = adata.obs["_dataset"] == "query"
query_obs = adata[query_mask].obs.copy()
# Consensus label distribution
print("Consensus cell type distribution:")
print(query_obs["popv_prediction"].value_counts().head(15))
# Agreement score statistics
print(f"\npopv_agreement statistics:")
print(query_obs["popv_agreement"].describe())
# agreement = 1.0 → all methods agree; agreement = 0.2 → only 2/10 methods agree
# Cells with high confidence (>80% method agreement)
high_conf = query_obs["popv_agreement"] >= 0.8
print(f"\nHigh-confidence cells (agreement >= 0.8): {high_conf.sum()} ({high_conf.mean():.1%})")
# Cells with low confidence — candidate novel states or annotation gaps
low_conf = query_obs["popv_agreement"] < 0.5
print(f"Low-confidence cells (agreement < 0.5): {low_conf.sum()} ({low_conf.mean():.1%})")
Module 5: Visualization
popV provides built-in UMAP and heatmap visualization of per-method agreement and consensus labels.
import popv
import scanpy as sc
import matplotlib.pyplot as plt
# Compute UMAP on the joint reference+query embedding (if not already present)
if "X_umap" not in adata.obsm:
sc.tl.umap(adata)
# popV built-in visualization: UMAP panel showing consensus + agreement
popv.visualization.predict_celltypes_umap(
adata,
save="popv_annotation_umap.png",
)
print("Saved popv_annotation_umap.png")
# Custom UMAP panels
fig, axes = plt.subplots(1, 3, figsize=(21, 6))
sc.pl.umap(adata, color="popv_prediction", ax=axes[0],
title="popV Consensus", legend_loc="on data",
legend_fontsize=6, show=False)
sc.pl.umap(adata, color="popv_agreement", ax=axes[1],
cmap="RdYlGn", vmin=0, vmax=1,
title="Method Agreement Score", show=False)
sc.pl.umap(adata, color="_dataset", ax=axes[2],
title="Reference vs Query", show=False)
plt.tight_layout()
plt.savefig("popv_custom_umap.png", dpi=150, bbox_inches="tight")
print("Saved popv_custom_umap.png")
Key Concepts
Method Ensemble and Majority Voting
popV runs each method independently; the final prediction is determined by plurality vote across all methods. The popv_agreement score equals the fraction of methods that voted for the winning label (e.g., 0.7 = 7/10 methods agreed). This design has several properties:
- Robustness: if one method fails or produces outlier labels, the consensus is unaffected if the remaining methods agree
- Uncertainty signal: low agreement does not mean the annotation is wrong — it often flags biologically interesting cells (transitional states, rare populations) that differ from all reference cell types
- Method independence: KNN-based methods depend on the embedding quality; tree-based methods (RF, XGBoost) work directly on expression; SVM works in feature space; CellTypist uses a separate logistic regression. Together they span multiple algorithmic families
Method Comparison
| Method | Batch Correction | Speed | Best For |
|---|---|---|---|
knn_harmony |
Harmony | Fast | Moderate batch effects, large datasets |
knn_bbknn |
BBKNN | Fast | Diverse multi-tissue references |
knn_scanorama |
Scanorama | Fast | Multiple heterogeneous batches |
knn_scvi |
scVI VAE | Medium | Complex batch effects, probabilistic embedding |
scanvi_popv |
scVI+labels | Slow | Semi-supervised; most accurate when reference is clean |
celltypist_popv |
None (logistic) | Fast | Immune cells; works well without batch correction |
rf |
None | Medium | Balanced class distributions; interpretable feature importance |
xgboost |
None | Medium | High-confidence predictions on well-separated cell types |
svm |
None | Medium | High-dimensional gene expression; linear boundaries |
onclass |
None | Medium | Ontology-aware; handles unseen cell types via CL ontology |
ONCLASS and Ontology-Aware Annotation
ONCLASS uses the Cell Ontology (CL) to represent cell types as nodes in a knowledge graph and predict unseen cell types by propagating similarity through the ontology. Unlike other methods, ONCLASS can predict a cell type that was not present in the training reference if it is ontologically adjacent to known types. Enable it by including "onclass" in the methods list.
Reference Quality Requirements
popV annotation quality scales directly with reference quality:
- Minimum cell count per type: 50–100 cells per label; rare types with <20 cells may be missed by KNN methods
- Balanced representation: highly imbalanced references (one type is 80% of cells) cause tree methods to be biased toward the majority class
- Label granularity: coarse labels (10 types) annotate reliably; fine-grained labels (100+ types) require a larger, matched reference
Common Workflows
Workflow 1: Standard Reference-Query Annotation
Goal: Annotate an unlabeled query dataset using a curated reference atlas end-to-end.
import popv
import scanpy as sc
import pandas as pd
# 1. Load data
adata_ref = sc.read_h5ad("reference_atlas.h5ad") # has obs["cell_type"] and obs["batch"]
adata_query = sc.read_h5ad("query_dataset.h5ad") # no cell type labels
if "batch" not in adata_query.obs.columns:
adata_query.obs["batch"] = "query"
# 2. Preprocess: build joint normalized object
adata = popv.preprocessing.Process_Query(
adata_ref,
adata_query,
ref_labels_key="cell_type",
ref_batch_key="batch",
query_batch_key="batch",
unknown_celltype_label="unknown",
save_path_trained_models="./popv_models/",
n_epochs_unsupervised=100,
n_epochs_semisupervised=30,
use_gpu=True,
hvg=4000,
)
print(f"Prepared: {adata.n_obs} total cells")
# 3. Run ensemble annotation
popv.annotation.annotate_data(adata)
# 4. Extract query results
query_mask = adata.obs["_dataset"] == "query"
query_annotations = adata[query_mask].obs[[
"popv_prediction", "popv_agreement",
"knn_harmony_popv", "scanvi_popv", "rf_popv", "xgboost_popv"
]].copy()
# 5. Transfer back to original query object
adata_query.obs = adata_query.obs.join(
query_annotations, how="left"
)
print(f"Annotated {query_mask.sum()} query cells")
print(query_annotations["popv_prediction"].value_counts().head(10))
# 6. Save annotated query
adata_query.write_h5ad("annotated_query.h5ad", compression="gzip")
query_annotations.to_csv("popv_annotations.csv")
print("Saved annotated_query.h5ad and popv_annotations.csv")
Workflow 2: Confidence Filtering and Novel Cell State Detection
Goal: Separate high-confidence annotations from ambiguous cells; flag candidate novel or transitional states for manual review.
import popv
import scanpy as sc
import pandas as pd
import matplotlib.pyplot as plt
# Assume adata has been annotated (as in Workflow 1)
query_mask = adata.obs["_dataset"] == "query"
query_obs = adata[query_mask].obs.copy()
# Tier cells by agreement score
bins = [0.0, 0.5, 0.8, 1.01]
labels = ["low (<0.5)", "medium (0.5–0.8)", "high (≥0.8)"]
query_obs["confidence_tier"] = pd.cut(
query_obs["popv_agreement"], bins=bins, labels=labels, right=False
)
print("Cells per confidence tier:")
print(query_obs["confidence_tier"].value_counts())
# High-confidence subset: use popv_prediction directly
high_conf_mask = query_obs["popv_agreement"] >= 0.8
print(f"\nHigh-confidence annotations ({high_conf_mask.mean():.1%} of query cells):")
print(query_obs[high_conf_mask]["popv_prediction"].value_counts().head(10))
# Low-confidence subset: inspect per-method disagreement
low_conf = query_obs[query_obs["popv_agreement"] < 0.5]
popv_method_cols = [c for c in query_obs.columns if c.endswith("_popv") and
c not in ("popv_prediction", "popv_agreement")]
print(f"\nLow-confidence cells sample (showing per-method labels):")
print(low_conf[popv_method_cols + ["popv_prediction"]].head(10).to_string())
# Visualize agreement distribution
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
query_obs["popv_agreement"].hist(bins=20, ax=axes[0], color="steelblue", edgecolor="white")
axes[0].axvline(0.8, color="red", linestyle="--", label="High-confidence threshold")
axes[0].set_xlabel("Method Agreement Score")
axes[0].set_ylabel("Cell Count")
axes[0].set_title("popV Agreement Distribution")
axes[0].legend()
query_obs["confidence_tier"].value_counts().plot.bar(ax=axes[1], color="steelblue")
axes[1].set_title("Cells by Confidence Tier")
axes[1].set_xlabel("Confidence Tier")
axes[1].set_ylabel("Cell Count")
plt.tight_layout()
plt.savefig("popv_confidence_distribution.png", dpi=150, bbox_inches="tight")
print("Saved popv_confidence_distribution.png")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
ref_labels_key |
Process_Query | — | Any obs column |
Column in adata_ref.obs containing training cell type labels |
n_epochs_unsupervised |
Process_Query | 50 |
20–500 |
scVI training epochs; increase for better embedding on large/complex datasets |
n_epochs_semisupervised |
Process_Query | 20 |
10–100 |
scANVI fine-tuning epochs on top of scVI |
hvg |
Process_Query | 4000 |
2000–8000 |
Highly variable genes used for embedding and KNN methods |
use_gpu |
Process_Query | True |
True, False |
GPU acceleration for scVI/SCANVI; falls back to CPU automatically if no GPU |
methods |
annotate_data | all | List of method names | Subset of methods to run; excluding slow methods (scanvi, onclass) speeds up pipeline |
unknown_celltype_label |
Process_Query | "unknown" |
Any string | Label assigned to query cells before annotation; used to separate reference labels from query |
popv_agreement |
(output) | — | 0.0–1.0 |
Fraction of methods agreeing on consensus label; >=0.8 recommended for high confidence |
Best Practices
-
Check gene overlap before running: popV performs best with >70% gene overlap between reference and query. If overlap is <50%, annotation quality degrades significantly — consider using a different reference or imputing missing genes.
shared = adata_ref.var_names.intersection(adata_query.var_names) print(f"Gene overlap: {len(shared) / adata_ref.n_vars:.1%}") -
Use raw counts as input: pass raw (un-normalized) counts in
adata.XtoProcess_Query. popV internally applies its own normalization. Pre-normalized data can distort the scVI/SCANVI latent space. -
Match reference granularity to query biology: if your query contains subtypes not in the reference, no method will correctly assign them — they will appear as low-agreement cells. Either add them to the reference or accept that the consensus will assign the nearest parent type.
-
Exclude slow methods when speed matters:
scanvi_popvandonclassare the slowest. For a quick first-pass, run onlyknn_harmony,knn_bbknn,rf,xgboost, andcelltypist_popv.popv.annotation.annotate_data(adata, methods=["knn_harmony", "knn_bbknn", "rf", "xgboost", "celltypist_popv"]) -
Save trained models for repeated queries:
Process_Querystores scVI/SCANVI models insave_path_trained_models. Reuse these when annotating additional query batches against the same reference to avoid retraining.
Common Recipes
Recipe: Subset to High-Confidence Annotations Only
When to use: downstream analyses (DE, trajectory) require clean labels; exclude ambiguous cells.
import scanpy as sc
# Annotate as in Workflow 1 first
query_mask = adata.obs["_dataset"] == "query"
adata_query_annotated = adata[query_mask].copy()
# Keep only high-confidence cells
high_conf = adata_query_annotated[adata_query_annotated.obs["popv_agreement"] >= 0.8].copy()
print(f"High-confidence cells: {high_conf.n_obs} / {adata_query_annotated.n_obs} "
f"({high_conf.n_obs/adata_query_annotated.n_obs:.1%})")
print(high_conf.obs["popv_prediction"].value_counts())
# Recompute UMAP on high-confidence subset for visualization
sc.pp.neighbors(high_conf, use_rep="X_scVI") # use scVI embedding stored by popV
sc.tl.umap(high_conf)
sc.pl.umap(high_conf, color="popv_prediction", save="_high_conf_celltypes.png")
Recipe: Per-Method Label Comparison Heatmap
When to use: understanding where methods disagree to identify systematic biases or novel populations.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
query_mask = adata.obs["_dataset"] == "query"
query_obs = adata[query_mask].obs.copy()
# Collect per-method columns
method_cols = [c for c in query_obs.columns
if c.endswith("_popv") and c not in ("popv_prediction", "popv_agreement")]
# Cross-tabulate two key methods
ct = pd.crosstab(
query_obs["knn_harmony_popv"],
query_obs["scanvi_popv"],
margins=False,
)
# Normalize rows
ct_norm = ct.div(ct.sum(axis=1), axis=0)
plt.figure(figsize=(12, 10))
sns.heatmap(ct_norm, cmap="Blues", vmin=0, vmax=1,
xticklabels=True, yticklabels=True,
cbar_kws={"label": "Fraction of cells"})
plt.title("knn_harmony vs scanvi label agreement")
plt.xlabel("SCANVI label")
plt.ylabel("KNN-Harmony label")
plt.tight_layout()
plt.savefig("popv_method_agreement_heatmap.png", dpi=150)
print("Saved popv_method_agreement_heatmap.png")
Recipe: Fast Annotation Without Deep Learning Methods
When to use: quick annotation without GPU or when scVI/SCANVI training is prohibitively slow (>500k cells).
import popv
# Process without training deep generative models (scVI not needed for KNN-Harmony)
adata = popv.preprocessing.Process_Query(
adata_ref,
adata_query,
ref_labels_key="cell_type",
ref_batch_key="batch",
query_batch_key="batch",
unknown_celltype_label="unknown",
save_path_trained_models="./popv_models/",
n_epochs_unsupervised=0, # skip scVI training
n_epochs_semisupervised=0, # skip scANVI training
use_gpu=False,
hvg=3000,
)
# Run only fast non-DL methods
popv.annotation.annotate_data(
adata,
methods=["knn_harmony", "knn_bbknn", "knn_scanorama", "rf", "xgboost", "svm", "celltypist_popv"],
)
query_mask = adata.obs["_dataset"] == "query"
print(adata[query_mask].obs[["popv_prediction", "popv_agreement"]].describe())
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
KeyError: ref_labels_key not in adata_ref.obs |
Reference lacks a cell type column | Verify the column name: print(adata_ref.obs.columns.tolist()); update ref_labels_key accordingly |
| Gene space mismatch error | Reference and query have very few shared genes | Check adata_ref.var_names.intersection(adata_query.var_names); if <50% overlap, use a different reference or match gene panels |
| CUDA out-of-memory for scVI/SCANVI | GPU VRAM insufficient for batch size | Set use_gpu=False or reduce n_epochs_unsupervised; scVI falls back to CPU automatically on most systems |
onclass_popv failures on small datasets |
ONCLASS requires sufficient label coverage | Remove "onclass" from the methods list when reference has <10 cell types or <500 cells per type |
| Very slow annotation (>2 hours) | scVI/SCANVI training on large reference | Subsample reference to 50k cells per type; exclude "scanvi_popv" and "onclass" from methods |
| All cells receive same consensus label | Reference highly imbalanced toward one type | Balance reference by subsampling the dominant type or upsampling rare types before running popV |
popv_agreement is 0 for many cells |
Many methods returning different labels | Inspect per-method columns; consider whether reference covers the query biology; add methods or retrain with a better reference |
Related Skills
- celltypist-cell-annotation — single-model annotation with pre-trained logistic regression; faster but lacks ensemble uncertainty
- scanpy-scrna-seq — preprocessing pipeline (QC, normalization, clustering) that produces AnnData inputs for popV
- scvi-tools-single-cell — scANVI for probabilistic label transfer with a single deep generative model; use when you prefer a formal variational framework over ensemble voting
- harmony-batch-correction — Harmony embedding used by
knn_harmonymethod internally; understand it to tune popV's KNN-based methods
References
- GitHub: YosefLab/popV — official source code, installation instructions, and example notebooks
- popV documentation — API reference and tutorials
- Ergen et al., bioRxiv 2023 — "Population-level integration of single-cell datasets enables multi-scale analysis across samples", original popV preprint
- ONCLASS paper — Wang et al., Nature Methods 2021 — ontology-aware cell type classification underlying the ONCLASS method in popV
skills/genomics-bioinformatics/single-cell/scanpy-scrna-seq/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scanpy-scrna-seq -g -y
SKILL.md
Frontmatter
{
"name": "scanpy-scrna-seq",
"license": "CC-BY-4.0",
"description": "scRNA-seq with Scanpy: QC, normalization, HVG selection, PCA, neighborhood graph, UMAP\/t-SNE, Leiden clustering, markers, cell annotation, trajectory inference. Standard scRNA-seq exploration."
}
Scanpy Single-Cell RNA-seq Analysis
Overview
Scanpy is a scalable Python toolkit for analyzing single-cell RNA-seq data built on the AnnData format. This skill covers the end-to-end standard workflow: quality control, normalization, highly variable gene selection, dimensionality reduction, clustering, marker gene identification, and cell type annotation. It produces annotated datasets and publication-quality visualizations.
When to Use
- Analyzing single-cell RNA-seq count matrices (10X Genomics, h5ad, CSV, loom)
- Performing quality control filtering on scRNA-seq datasets (mitochondrial %, gene counts)
- Running dimensionality reduction: PCA, UMAP, t-SNE
- Identifying cell clusters via Leiden community detection
- Finding differentially expressed marker genes per cluster (Wilcoxon, t-test, logistic regression)
- Annotating cell types from known marker gene panels
- Conducting trajectory inference and pseudotime analysis (PAGA, diffusion pseudotime)
- Generating publication-quality single-cell plots (dot plots, heatmaps, stacked violins)
- Comparing gene expression across experimental conditions within cell types
- Use Seurat (R/Bioconductor) instead for scRNA-seq analysis in an existing R workflow or when Seurat-specific assay types are required
Prerequisites
- Python packages:
scanpy>=1.10,leidenalg,igraph,anndata - Data requirements: Gene expression count matrix (cells x genes). Common formats: 10X Cell Ranger output (
filtered_feature_bc_matrix/),.h5ad,.h5,.csv - Environment: Python 3.9+; 16GB+ RAM recommended for >50k cells
pip install "scanpy[leiden]" anndata
Workflow
Step 1: Setup and Data Loading
Configure scanpy settings and read the count matrix into an AnnData object. Ensure gene names are unique.
import scanpy as sc
import numpy as np
import pandas as pd
# Configure settings
sc.settings.verbosity = 3 # errors=0, warnings=1, info=2, hints=3
sc.settings.set_figure_params(dpi=80, facecolor="white")
# Load data — pick the reader matching your format
adata = sc.read_10x_mtx(
"path/to/filtered_feature_bc_matrix/",
var_names="gene_symbols",
cache=True,
)
# Alternatives:
# adata = sc.read_h5ad("data.h5ad")
# adata = sc.read_10x_h5("data.h5")
adata.var_names_make_unique()
print(f"Loaded: {adata.n_obs} cells x {adata.n_vars} genes")
print(f"Sparsity: {1 - adata.X.nnz / (adata.n_obs * adata.n_vars):.1%}")
Step 2: Quality Control
Annotate mitochondrial/ribosomal genes, compute QC metrics, visualize distributions, and filter low-quality cells.
# Annotate gene groups
adata.var["mt"] = adata.var_names.str.startswith("MT-") # human; use "mt-" for mouse
adata.var["ribo"] = adata.var_names.str.startswith(("RPS", "RPL"))
# Calculate QC metrics
sc.pp.calculate_qc_metrics(
adata, qc_vars=["mt", "ribo"], percent_top=None, log1p=False, inplace=True
)
# Visualize QC distributions
sc.pl.violin(
adata,
["n_genes_by_counts", "total_counts", "pct_counts_mt"],
jitter=0.4,
multi_panel=True,
)
sc.pl.scatter(adata, x="total_counts", y="pct_counts_mt")
sc.pl.scatter(adata, x="total_counts", y="n_genes_by_counts")
# Filter — adjust thresholds based on the QC plots above
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)
adata = adata[adata.obs.n_genes_by_counts < 5000, :] # remove potential doublets
adata = adata[adata.obs.pct_counts_mt < 20, :] # remove dying cells
print(f"After QC: {adata.n_obs} cells x {adata.n_vars} genes")
Step 3: Normalization and Feature Selection
Normalize library sizes, log-transform, and identify highly variable genes (HVGs). Store raw counts for later differential expression.
# Preserve raw counts in a layer
adata.layers["counts"] = adata.X.copy()
# Normalize to 10,000 counts per cell, then log-transform
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
# Save normalized data as .raw for visualization
adata.raw = adata
# Identify highly variable genes
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3", layer="counts")
sc.pl.highly_variable_genes(adata)
# Subset to HVGs
adata = adata[:, adata.var.highly_variable]
print(f"HVG subset: {adata.n_obs} cells x {adata.n_vars} genes")
Step 4: Scaling and Regression
Regress out confounders and scale gene expression values. This prepares data for PCA.
# Regress out unwanted sources of variation
sc.pp.regress_out(adata, ["total_counts", "pct_counts_mt"])
# Scale to unit variance, clip extreme values
sc.pp.scale(adata, max_value=10)
print(f"Scaled matrix: mean={adata.X.mean():.4f}, std={adata.X.std():.4f}")
Step 5: Dimensionality Reduction
Compute PCA, inspect the variance ratio elbow plot, then build a neighborhood graph and embed with UMAP.
# PCA
sc.tl.pca(adata, svd_solver="arpack", n_comps=50)
sc.pl.pca_variance_ratio(adata, log=True, n_pcs=50)
# Determine n_pcs from elbow plot (typically 30-50)
n_pcs = 40
# Compute k-nearest neighbor graph
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=n_pcs)
# UMAP embedding
sc.tl.umap(adata)
sc.pl.umap(adata, color=["n_genes_by_counts", "total_counts", "pct_counts_mt"])
print(f"UMAP shape: {adata.obsm['X_umap'].shape}")
Step 6: Clustering
Run Leiden clustering at multiple resolutions and select the best granularity by visual inspection.
# Test multiple resolutions
for res in [0.3, 0.5, 0.8, 1.0, 1.2]:
sc.tl.leiden(adata, resolution=res, key_added=f"leiden_res{res}", flavor="igraph", n_iterations=2)
# Compare resolutions side-by-side
sc.pl.umap(
adata,
color=["leiden_res0.3", "leiden_res0.5", "leiden_res0.8", "leiden_res1.0"],
ncols=2,
legend_loc="on data",
)
# Pick final resolution based on biological plausibility
adata.obs["leiden"] = adata.obs["leiden_res0.5"]
n_clusters = adata.obs["leiden"].nunique()
print(f"Selected resolution=0.5: {n_clusters} clusters")
Step 7: Marker Gene Identification
Find differentially expressed genes per cluster using Wilcoxon rank-sum test on raw counts.
# Rank genes per cluster
sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon", use_raw=True)
# Visualization
sc.pl.rank_genes_groups(adata, n_genes=20, sharey=False)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5)
sc.pl.rank_genes_groups_heatmap(adata, n_genes=10, use_raw=True, show_gene_labels=True)
# Extract as DataFrame
markers_df = sc.get.rank_genes_groups_df(adata, group=None)
top_markers = markers_df[markers_df["pvals_adj"] < 0.05].groupby("group").head(10)
print(f"Significant markers: {len(top_markers)} genes across {top_markers['group'].nunique()} clusters")
print(top_markers[["group", "names", "logfoldchanges", "pvals_adj"]].head(15))
Step 8: Cell Type Annotation and Export
Assign cell types based on canonical marker genes, visualize, and save all results.
# Define canonical markers (example: human PBMC)
marker_genes = {
"CD4 T cells": ["CD3D", "CD3E", "IL7R"],
"CD8 T cells": ["CD8A", "CD8B", "GZMK"],
"B cells": ["MS4A1", "CD79A", "CD79B"],
"NK cells": ["NKG7", "GNLY", "KLRD1"],
"CD14+ Monocytes": ["CD14", "LYZ", "S100A9"],
"FCGR3A+ Monocytes":["FCGR3A", "MS4A7"],
"Dendritic cells": ["FCER1A", "CST3"],
"Platelets": ["PPBP", "PF4"],
}
# Dot plot — rows = clusters, columns = markers
sc.pl.dotplot(adata, var_names=marker_genes, groupby="leiden", use_raw=True)
# Map clusters → cell types (adjust based on your dot plot)
cluster_to_celltype = {
"0": "CD4 T cells",
"1": "CD14+ Monocytes",
"2": "B cells",
"3": "CD8 T cells",
"4": "NK cells",
"5": "FCGR3A+ Monocytes",
"6": "Dendritic cells",
"7": "Platelets",
}
adata.obs["cell_type"] = adata.obs["leiden"].map(cluster_to_celltype).fillna("Unknown")
sc.pl.umap(adata, color="cell_type", legend_loc="on data", frameon=False)
# Save results
adata.write("results/annotated_data.h5ad", compression="gzip")
adata.obs.to_csv("results/cell_metadata.csv")
markers_df.to_csv("results/marker_genes.csv", index=False)
print(f"Saved: {adata.n_obs} cells with {adata.obs['cell_type'].nunique()} cell types")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
min_genes (filter_cells) |
200 |
100-500 |
Minimum genes detected per cell; lower keeps more cells |
min_cells (filter_genes) |
3 |
3-10 |
Minimum cells expressing a gene; higher is stricter |
pct_counts_mt threshold |
20 |
5-30 |
Max mitochondrial %; tissue-dependent (5% for PBMCs, 20% for tumors) |
n_top_genes (HVG) |
2000 |
1000-5000 |
Number of highly variable genes; more captures subtle variation |
flavor (HVG) |
"seurat_v3" |
"seurat", "seurat_v3", "cell_ranger" |
HVG detection algorithm |
n_pcs (neighbors) |
40 |
20-50 |
Number of PCs for neighbor graph; check elbow plot |
n_neighbors |
15 |
5-50 |
k for k-NN graph; lower emphasizes local structure |
resolution (leiden) |
0.5 |
0.1-2.0 |
Clustering granularity; higher → more clusters |
method (rank_genes) |
"wilcoxon" |
"wilcoxon", "t-test", "logreg" |
DE test; Wilcoxon recommended for publication |
target_sum (normalize) |
1e4 |
1e4-1e5 |
Target library size per cell |
Common Recipes
Recipe: Batch Correction with ComBat
When to use: multiple samples/batches with visible batch effects on the UMAP.
# Requires 'batch' column in adata.obs
sc.pp.combat(adata, key="batch")
# Re-run PCA, neighbors, UMAP, clustering after correction
sc.tl.pca(adata, svd_solver="arpack")
sc.pp.neighbors(adata, n_pcs=40)
sc.tl.umap(adata)
sc.pl.umap(adata, color=["batch", "leiden"])
Recipe: Trajectory Inference with PAGA + Diffusion Pseudotime
When to use: cells follow a developmental continuum rather than discrete clusters.
# PAGA graph abstraction
sc.tl.paga(adata, groups="leiden")
sc.pl.paga(adata, color="leiden", threshold=0.03)
# Reinitialize UMAP with PAGA layout
sc.tl.umap(adata, init_pos="paga")
# Diffusion pseudotime — set root cell
adata.uns["iroot"] = np.flatnonzero(adata.obs["leiden"] == "0")[0]
sc.tl.diffmap(adata)
sc.tl.dpt(adata)
sc.pl.umap(adata, color=["dpt_pseudotime", "leiden"])
Recipe: Publication-Quality Figures
When to use: generating figures for manuscripts or presentations.
sc.settings.set_figure_params(dpi=300, frameon=False, figsize=(5, 5))
# UMAP with custom palette
sc.pl.umap(
adata, color="cell_type",
palette="Set2",
legend_loc="on data",
legend_fontsize=10,
legend_fontoutline=2,
title="",
save="_celltype_publication.pdf",
)
# Stacked violin plot of top markers
flat_markers = [g for genes in marker_genes.values() for g in genes[:2]]
sc.pl.stacked_violin(
adata, var_names=flat_markers, groupby="cell_type",
use_raw=True, swap_axes=True,
save="_markers_violin.pdf",
)
Recipe: Differential Expression Between Conditions
When to use: comparing treated vs control within a specific cell type.
# Subset to cell type of interest
adata_t = adata[adata.obs["cell_type"] == "CD4 T cells"].copy()
# DE between conditions
sc.tl.rank_genes_groups(adata_t, groupby="condition", groups=["treated"], reference="control", method="wilcoxon", use_raw=True)
sc.pl.rank_genes_groups_volcano(adata_t, groups=["treated"])
de_results = sc.get.rank_genes_groups_df(adata_t, group="treated")
sig_genes = de_results[de_results["pvals_adj"] < 0.05]
print(f"Significant DE genes: {len(sig_genes)}")
Expected Outputs
results/annotated_data.h5ad— Full AnnData with embeddings (PCA, UMAP), cluster labels, cell type annotations, and raw counts layerresults/cell_metadata.csv— Per-cell table: barcode, cluster, cell type, QC metrics (n_genes, total_counts, pct_mt)results/marker_genes.csv— DE genes per cluster: gene name, log fold change, adjusted p-value- QC figures: violin plots (n_genes, total_counts, pct_mt), scatter plots
- Embedding figures: UMAP colored by cluster, cell type, QC metrics, pseudotime
- Marker figures: dot plot, heatmap, stacked violin of canonical markers
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ModuleNotFoundError: leidenalg |
Leiden package not installed | pip install leidenalg igraph |
MemoryError during PCA |
Dense matrix exceeds RAM | Use sc.pp.pca(adata, chunked=True, chunk_size=20000) |
| UMAP shows single blob | Over-filtering or too few HVGs | Relax QC thresholds; increase n_top_genes to 3000-5000 |
| Too many/few clusters | Resolution mismatch | Sweep resolution from 0.1 to 2.0 and compare UMAPs |
KeyError: 'MT-' not found |
Wrong species prefix | Use mt- (lowercase) for mouse, MT- for human |
| Batch effects dominate UMAP | Uncorrected technical variation | Apply ComBat recipe or use Harmony/scVI for integration |
adata.raw is None error |
Forgot to save raw before subsetting | Set adata.raw = adata after normalization, before HVG subset |
| Marker genes non-specific | Resolution too low merging cell types | Increase resolution or use logistic_regression method |
Slow regress_out |
Large cell count | Skip regress_out; use sc.pp.scale() alone (often sufficient) |
ValueError in rank_genes_groups |
Cluster with too few cells | Remove clusters with <10 cells before DE: adata = adata[adata.obs.groupby('leiden').filter(lambda x: len(x)>=10).index] |
Bundled Resources
This skill includes reference files for deeper lookup. Read these on demand when the main SKILL.md needs more detail.
references/api_reference.md
Quick-lookup table of all scanpy functions organized by module (sc.pp., sc.tl., sc.pl.*), with full signatures, common parameters, and AnnData structure reference. Use when: you need the exact function name or parameter for a specific operation.
references/plotting_guide.md
Comprehensive visualization guide: QC plots, UMAP styling, marker gene plots (dot plot, heatmap, stacked violin), trajectory plots, multi-panel figures, publication customization, and color palette recommendations. Use when: creating figures for publications or presentations.
references/standard_workflow.md
Detailed step-by-step reference for each pipeline stage with decision points, parameter rationale, and alternative approaches (MAD-based filtering, scran normalization, automated annotation). Use when: performing a full analysis from scratch and needing deeper guidance than the Workflow section above.
References
- Scanpy documentation — Official API reference and tutorials (BSD-3)
- Scanpy PBMC3k tutorial — Canonical walkthrough (BSD-3)
- Galaxy Training: Clustering 3K PBMCs with Scanpy — Step-by-step tutorial (CC-BY)
- Luecken & Theis (2019) — "Current best practices in single-cell RNA-seq analysis: a tutorial", Mol Syst Biol
- Heumos et al. (2023) — "Best practices for single-cell analysis across modalities", Nat Rev Genet
- scverse ecosystem — Related tools: anndata, scvi-tools, squidpy, cellrank
skills/genomics-bioinformatics/single-cell/scvi-tools-single-cell/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scvi-tools-single-cell -g -y
SKILL.md
Frontmatter
{
"name": "scvi-tools-single-cell",
"license": "BSD-3-Clause",
"description": "Deep generative models for single-cell omics: probabilistic batch correction (scVI), semi-supervised annotation (scANVI), CITE-seq RNA+protein (totalVI), transfer learning (scARCHES), and DE with uncertainty. Unified setup→train→extract API on AnnData. Use harmony-batch-correction for fast linear correction without deep learning; muon for multi-modal MuData workflows."
}
scvi-tools — Single-Cell Deep Generative Models
Overview
scvi-tools is a probabilistic modeling framework for single-cell genomics built on PyTorch. It implements variational autoencoders (VAEs) that learn low-dimensional latent representations of cells while explicitly modeling batch effects, count noise distributions, and multi-modal data. All models share a unified API: setup_anndata() to register data, instantiate the model, train(), then extract latent representations, normalized expression, or differential expression results. Models operate on raw count data in AnnData format and return statistically grounded outputs with uncertainty estimates.
When to Use
- Integrating multiple scRNA-seq batches or studies with probabilistic batch correction that preserves biological variation
- Performing differential expression with uncertainty quantification and composite hypotheses (not just fold-change thresholding)
- Annotating cell types via semi-supervised transfer learning from a partially-labeled reference (scANVI)
- Jointly modeling CITE-seq protein and RNA data to obtain denoised protein estimates and joint embeddings (totalVI)
- Adapting a pretrained model to a new query dataset without full retraining (scARCHES transfer learning)
- Deconvolving spatial transcriptomics spots into cell type proportions using a matched scRNA-seq reference (DestVI)
- Detecting doublets in scRNA-seq data as a QC preprocessing step (Solo)
- Use harmony-batch-correction instead when you need fast linear batch correction (seconds vs minutes) without deep learning overhead
- For multi-modal MuData workflows (joint RNA+ATAC Multiome, combined modality objects), use muon instead
- For standard clustering and visualization without batch effects or probabilistic DE, use scanpy-scrna-seq
Prerequisites
- Python packages:
scvi-tools>=1.1,scanpy,anndata - Data requirements: AnnData (
.h5ad) with raw counts — not log-normalized. Store counts inadata.layers["counts"]ifadata.Xhas been normalized. - Hardware: CPU works for <50k cells; GPU (8GB+ VRAM) recommended for larger datasets. Training time: 5–30 minutes on GPU for 100k cells.
pip install scvi-tools scanpy
# GPU acceleration (recommended for >50k cells)
pip install "scvi-tools[cuda12]" # or scvi-tools[cuda11]
Quick Start
Minimal scVI batch integration on a built-in example dataset:
import scvi
import scanpy as sc
# Load example data with batch labels
adata = scvi.data.heart_cell_atlas_subsampled()
# Preprocessing: filter genes, select HVGs (subset to save training time)
sc.pp.filter_genes(adata, min_counts=3)
adata.layers["counts"] = adata.X.copy() # preserve raw counts
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000, batch_key="cell_source", subset=True)
# Register data, train, extract
scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="cell_source")
model = scvi.model.SCVI(adata, n_latent=30)
model.train(max_epochs=200, early_stopping=True)
adata.obsm["X_scVI"] = model.get_latent_representation()
sc.pp.neighbors(adata, use_rep="X_scVI")
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.5)
sc.pl.umap(adata, color=["cell_source", "leiden"])
print(f"Latent shape: {adata.obsm['X_scVI'].shape}")
# Latent shape: (14000, 30)
Core API
1. Data Registration (setup_anndata)
All models share the same registration pattern. Call setup_anndata() on the model class before instantiation to tell scvi-tools where to find counts, batch labels, and covariates.
import scvi
# Minimal: counts in a layer, batch column in obs
scvi.model.SCVI.setup_anndata(
adata,
layer="counts", # Key in adata.layers with raw counts; None = adata.X
batch_key="batch", # Column in adata.obs for technical batch
)
# Extended: additional categorical and continuous covariates
scvi.model.SCVI.setup_anndata(
adata,
layer="counts",
batch_key="batch",
categorical_covariate_keys=["donor", "protocol"], # Discrete biological/technical vars
continuous_covariate_keys=["percent_mito", "log_n_counts"], # Continuous covariates
)
# Inspect registered summary
print(adata.uns["_scvi"]["summary_stats"])
# {'n_vars': 2000, 'n_cells': 45000, 'n_batch': 6, 'n_extra_categorical_covs': 2, ...}
2. scVI — Batch Correction and Integration
The core unsupervised model. Learns a batch-corrected latent space and a denoised expression layer. Starting point for any multi-batch scRNA-seq analysis.
import scvi
model = scvi.model.SCVI(
adata,
n_latent=30, # Latent space dimensions; 10–50 typical
n_layers=2, # Hidden layers in encoder/decoder; 1–3
n_hidden=128, # Neurons per hidden layer; 64–256
gene_likelihood="zinb", # "zinb" (zero-inflated NB), "nb", or "poisson"
dispersion="gene", # "gene" or "gene-batch" for batch-specific dispersion
)
model.train(max_epochs=200, early_stopping=True)
# Extract results
latent = model.get_latent_representation() # ndarray (n_cells, n_latent)
normalized = model.get_normalized_expression(
library_size=1e4, n_samples=25, return_mean=True
)
print(f"Latent: {latent.shape}")
print(f"Denoised expression: {normalized.shape}")
# Latent: (45000, 30)
# Denoised expression: (45000, 2000)
adata.obsm["X_scVI"] = latent
adata.layers["scvi_normalized"] = normalized
3. scANVI — Semi-Supervised Cell Annotation
Extends scVI with label supervision for cell type transfer learning. Accepts partially labeled data (unannotated cells labeled "Unknown") and predicts cell types for unlabeled cells.
import scvi
# Register with cell type labels; mark unannotated cells as "Unknown"
scvi.model.SCANVI.setup_anndata(
adata,
layer="counts",
batch_key="batch",
labels_key="cell_type", # Column in adata.obs with known labels
unlabeled_category="Unknown", # Sentinel value for unannotated cells
)
# Recommended: initialize from a pretrained scVI model
scvi_model = scvi.model.SCVI(adata, n_latent=30)
scvi_model.train(max_epochs=200, early_stopping=True)
model = scvi.model.SCANVI.from_scvi_model(scvi_model, unlabeled_category="Unknown")
model.train(max_epochs=20) # Short fine-tuning on top of scVI
# Predict cell types
labels_pred = model.predict() # Hard labels (str per cell)
probs = model.predict(soft=True) # DataFrame: probability per cell type
confidence = probs.max(axis=1)
adata.obs["scANVI_pred"] = labels_pred
adata.obs["scANVI_confidence"] = confidence
print(f"Median confidence: {confidence.median():.3f}")
print(f"High-confidence cells (>0.7): {(confidence > 0.7).sum()}")
4. totalVI — CITE-seq RNA + Protein
Joint probabilistic model for CITE-seq data. Denoises both RNA and protein counts and estimates protein foreground probability (signal vs background).
import scvi
# Protein counts must be in adata.obsm (not adata.X or layers)
# adata.obsm["protein_expression"] shape: (n_cells, n_proteins)
scvi.model.TOTALVI.setup_anndata(
adata,
layer="counts",
batch_key="batch",
protein_expression_obsm_key="protein_expression",
)
model = scvi.model.TOTALVI(adata, latent_distribution="normal")
model.train(max_epochs=200, early_stopping=True)
# Extract joint latent space (RNA + protein)
latent = model.get_latent_representation()
# Denoised RNA and protein separately
rna_norm, protein_norm = model.get_normalized_expression(
n_samples=25, return_mean=True
)
# Protein foreground probability (probability that signal > background)
foreground_prob = model.get_protein_foreground_probability(n_samples=25, return_mean=True)
print(f"RNA normalized: {rna_norm.shape}")
print(f"Protein normalized: {protein_norm.shape}")
print(f"Foreground prob: {foreground_prob.shape}") # (n_cells, n_proteins)
adata.obsm["X_totalVI"] = latent
5. Differential Expression
Probabilistic DE using the generative model rather than raw counts. Supports composite hypotheses testing whether effect size exceeds a biologically meaningful threshold (delta).
# DE between two cell type groups
de_df = model.differential_expression(
groupby="cell_type",
group1="CD4 T", # Numerator group
group2="CD8 T", # Denominator group; None = rest of cells
mode="change", # "change": composite |LFC| > delta; "vanilla": any LFC
delta=0.25, # Minimum |LFC| to be considered DE
fdr_target=0.05, # FDR level for calling significance
all_stats=True, # Include mean expression per group
)
# Filter significant DE genes
sig = de_df[de_df["is_de_fdr_0.05"] & (de_df["lfc_mean"].abs() > 0.5)]
sig_sorted = sig.sort_values("lfc_mean", ascending=False)
print(f"Total DE genes (FDR<5%, |LFC|>0.5): {len(sig)}")
print(sig_sorted[["lfc_mean", "bayes_factor", "proba_de"]].head(10))
# One-vs-rest DE across all clusters (generates a dict of DataFrames)
de_all = {}
for ct in adata.obs["cell_type"].unique():
de_all[ct] = model.differential_expression(
idx1=[adata.obs["cell_type"] == ct], # Boolean index
mode="change",
delta=0.25,
)
sig_n = de_all[ct]["is_de_fdr_0.05"].sum()
print(f" {ct}: {sig_n} DE genes vs rest")
6. scARCHES — Query-to-Reference Transfer Learning
Adapts a pretrained reference model to a new query dataset without re-training from scratch. Preserves the reference embedding structure and maps query cells into the same latent space.
import scvi
# --- Reference training (done once, save the model) ---
scvi.model.SCVI.setup_anndata(ref_adata, layer="counts", batch_key="batch")
ref_model = scvi.model.SCVI(ref_adata, n_latent=30)
ref_model.train(max_epochs=200, early_stopping=True)
ref_model.save("./reference_model/", overwrite=True)
# Reference annotation (use scANVI for label transfer)
scvi.model.SCANVI.setup_anndata(
ref_adata, layer="counts", batch_key="batch",
labels_key="cell_type", unlabeled_category="Unknown",
)
ref_scanvi = scvi.model.SCANVI.from_scvi_model(ref_model, unlabeled_category="Unknown")
ref_scanvi.train(max_epochs=20)
ref_scanvi.save("./reference_scanvi/", overwrite=True)
# --- Query mapping (new dataset, minimal training) ---
ref_scanvi = scvi.model.SCANVI.load("./reference_scanvi/", adata=ref_adata)
# Prepare query: must share gene set with reference
query_adata.obs["cell_type"] = "Unknown" # All query cells are unlabeled
query_model = scvi.model.SCANVI.load_query_data(query_adata, ref_scanvi)
query_model.train(
max_epochs=100,
plan_kwargs={"weight_decay": 0.0}, # Critical: prevents catastrophic forgetting
)
# Map query into reference latent space
query_latent = query_model.get_latent_representation(query_adata)
query_labels = query_model.predict(query_adata)
print(f"Query latent: {query_latent.shape}")
print(f"Query label predictions: {query_labels[:5]}")
7. Downstream Analysis on Latent Space
After extracting the latent representation, use scanpy for UMAP, clustering, and marker genes — with the batch-corrected embedding as input.
import scanpy as sc
# UMAP and clustering on scVI latent representation
adata.obsm["X_scVI"] = model.get_latent_representation()
sc.pp.neighbors(
adata,
use_rep="X_scVI", # Use scVI latent (not PCA)
n_neighbors=30,
n_pcs=None, # n_pcs ignored when use_rep is set
)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.5)
# Marker genes using raw counts or normalized expression
# Option A: scanpy Wilcoxon on log-normalized expression
sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon", n_genes=25)
# Option B: scVI probabilistic DE per cluster (more accurate)
markers = {}
for cluster in adata.obs["leiden"].unique():
de = model.differential_expression(
idx1=[adata.obs["leiden"] == cluster],
mode="change", delta=0.25,
)
markers[cluster] = de[de["is_de_fdr_0.05"]].sort_values("lfc_mean", ascending=False)
# Visualization
sc.pl.umap(adata, color=["leiden", "batch", "cell_type"], ncols=3)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5, groupby="leiden")
Key Concepts
Unified API Pattern
All scvi-tools models follow the same 4-step workflow:
1. ModelClass.setup_anndata(adata, ...) → Register layers, batch keys, covariates
2. model = ModelClass(adata, ...) → Set architecture hyperparameters
3. model.train(max_epochs=..., ...) → Fit model (GPU auto-detected)
4. model.get_*() → Extract results: latent, normalized, DE
Model Selection Guide
| Data | Model | Core Feature | When to Use |
|---|---|---|---|
| scRNA-seq | scVI | Batch correction, denoising | Default for any multi-batch scRNA-seq |
| scRNA-seq (partial labels) | scANVI | Cell type transfer | Have reference labels; want to annotate query |
| CITE-seq (RNA+protein) | totalVI | Joint RNA+protein | 10x CITE-seq, REAP-seq |
| Reference → query | scARCHES | Transfer learning | Map new data to existing atlas |
| Spatial | DestVI | Spot deconvolution | 10x Visium, Slide-seq |
| scRNA-seq QC | Solo | Doublet detection | Pre-analysis QC step |
Raw Counts Requirement
scvi-tools models learn count distributions directly from raw data. Log-normalized input produces incorrect results. Always preserve raw counts before normalization:
# Correct workflow: save counts, then normalize for scanpy steps
adata.layers["counts"] = adata.X.copy() # Save raw before any transformation
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
# Then: scvi.model.SCVI.setup_anndata(adata, layer="counts", ...)
Common Workflows
Workflow 1: Multi-Batch scRNA-seq Integration
Goal: Integrate multiple scRNA-seq datasets, remove batch effects, identify cell clusters, and find marker genes.
import scvi
import scanpy as sc
# Load and concatenate datasets
adata1 = sc.read_h5ad("dataset1.h5ad")
adata2 = sc.read_h5ad("dataset2.h5ad")
adata3 = sc.read_h5ad("dataset3.h5ad")
adata = sc.concat(
[adata1, adata2, adata3],
label="batch",
keys=["dataset1", "dataset2", "dataset3"],
)
# Preprocessing: preserve raw counts, select HVGs per batch
adata.layers["counts"] = adata.X.copy()
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(
adata,
n_top_genes=4000,
batch_key="batch", # Select HVGs consistently across batches
subset=True,
)
print(f"HVGs selected: {adata.n_vars}")
# scVI integration
scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="batch")
model = scvi.model.SCVI(adata, n_latent=30, n_layers=2)
model.train(max_epochs=300, early_stopping=True, early_stopping_patience=15)
print(f"Training history: {model.history['elbo_train'].tail()}")
# Batch-corrected analysis
adata.obsm["X_scVI"] = model.get_latent_representation()
adata.layers["scvi_norm"] = model.get_normalized_expression(n_samples=25, return_mean=True)
sc.pp.neighbors(adata, use_rep="X_scVI", n_neighbors=30)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.5)
# Verify batch mixing (batches should overlap on UMAP)
sc.pl.umap(adata, color=["batch", "leiden"], save="_batch_integration.png")
model.save("./scvi_model/", overwrite=True)
print(f"Clusters: {adata.obs['leiden'].nunique()}")
Workflow 2: CITE-seq totalVI Pipeline
Goal: Model CITE-seq RNA + protein data jointly, obtain denoised protein estimates, and generate a joint embedding for annotation.
import scvi
import scanpy as sc
import pandas as pd
# Load CITE-seq AnnData (RNA in X, protein counts in obsm)
adata = sc.read_h5ad("citeseq_data.h5ad")
# Expected structure:
# adata.X or adata.layers["counts"] : RNA raw counts (n_cells, n_genes)
# adata.obsm["protein_expression"] : Protein raw counts (n_cells, n_proteins)
# Preprocessing
adata.layers["counts"] = adata.X.copy()
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=4000, batch_key="batch", subset=True)
# totalVI setup and training
scvi.model.TOTALVI.setup_anndata(
adata,
layer="counts",
batch_key="batch",
protein_expression_obsm_key="protein_expression",
)
model = scvi.model.TOTALVI(adata, latent_distribution="normal")
model.train(max_epochs=200, early_stopping=True)
# Extract joint embedding and denoised values
adata.obsm["X_totalVI"] = model.get_latent_representation()
rna_norm, protein_norm = model.get_normalized_expression(n_samples=25, return_mean=True)
foreground = model.get_protein_foreground_probability(n_samples=25, return_mean=True)
adata.layers["rna_denoised"] = rna_norm
protein_df = pd.DataFrame(
protein_norm,
index=adata.obs_names,
columns=adata.uns["protein_names"],
)
protein_df.to_csv("denoised_protein_expression.csv")
print(f"Protein foreground: min={foreground.min():.3f}, max={foreground.max():.3f}")
# Joint clustering on RNA + protein latent
sc.pp.neighbors(adata, use_rep="X_totalVI", n_neighbors=30)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.8)
# Protein-guided annotation (use denoised protein for clean signal)
protein_markers = {"B cell": "CD19", "T cell": "CD3E", "Monocyte": "CD14"}
for cell_type, marker in protein_markers.items():
if marker in protein_df.columns:
adata.obs[f"denoised_{marker}"] = protein_df[marker].values
sc.pl.umap(adata, color=["leiden"] + [f"denoised_{m}" for m in protein_markers.values()])
Key Parameters
| Parameter | Model / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
n_latent |
All models | 10 |
10–50 |
Latent space dimensionality; 20–30 typical for most datasets |
n_layers |
All models | 1 |
1–3 |
Depth of encoder/decoder networks |
n_hidden |
All models | 128 |
64–256 |
Width of each hidden layer |
gene_likelihood |
scVI, scANVI | "zinb" |
"zinb", "nb", "poisson" |
Count noise distribution; "nb" faster if sparsity is low |
max_epochs |
train() |
400 |
50–1000 |
Training iterations (ELBO steps) |
early_stopping |
train() |
False |
True/False |
Halt when validation ELBO plateaus |
early_stopping_patience |
train() |
45 |
5–100 |
Epochs to wait before stopping |
batch_size |
train() |
128 |
64–512 |
Mini-batch size; reduce if OOM |
lr |
train() |
1e-3 |
1e-4–1e-2 |
Initial learning rate |
delta |
differential_expression() |
0.25 |
0.1–1.0 |
Min |
n_samples |
get_normalized_expression() |
1 |
1–100 |
Posterior samples; ≥25 for stable estimates |
Best Practices
-
Always store raw counts before normalization: Run
adata.layers["counts"] = adata.X.copy()immediately after loading data, before anynormalize_totalorlog1pcall. Passing log-normalized data silently produces incorrect latent spaces. -
Select highly variable genes before training: Use
sc.pp.highly_variable_genes(n_top_genes=2000–4000, batch_key="batch")to reduce training time and model noise. Including all genes rarely improves results and significantly slows training. -
Register all known batch variables: If data has multiple technical sources (sequencing plate, donor, protocol), include them as
batch_keyorcategorical_covariate_keys. Unregistered batch effects contaminate the latent space and downstream DE. -
Use the scVI → scANVI two-step pipeline: Initializing scANVI with
from_scvi_model()after training scVI is faster, more stable, and produces better embeddings than training scANVI from scratch. Always train scVI first when using scANVI. -
Save trained models immediately:
model.save("./model_dir/")persists the full model. Reloading withSCVI.load()is seconds vs. minutes of retraining. Models are typically 10–50 MB. -
Use
n_latent=20–30as starting point: Values below 10 lose resolution; above 50 tend to overfit without added biological insight. Increase to 50 only for very heterogeneous atlases (>500k cells, many cell types). -
Filter low-confidence scANVI predictions: Cells where
predict(soft=True).max(axis=1) < 0.7are ambiguous; treat them as"Unknown"rather than accepting the argmax label.
Common Recipes
Recipe: Doublet Detection with Solo
When to use: Remove doublets before integration or DE to avoid spurious clusters.
import scvi
scvi.model.SCVI.setup_anndata(adata, layer="counts")
vae = scvi.model.SCVI(adata, n_latent=20)
vae.train(max_epochs=100)
solo = scvi.external.SOLO.from_scvi_model(vae)
solo.train(max_epochs=200)
doublet_preds = solo.predict() # DataFrame: {"singlet": prob, "doublet": prob}
adata.obs["doublet_score"] = doublet_preds["doublet"].values
adata.obs["is_doublet"] = doublet_preds["prediction"].values
n_doublets = (adata.obs["is_doublet"] == "doublet").sum()
print(f"Detected {n_doublets} doublets ({n_doublets / adata.n_obs:.1%})")
adata = adata[adata.obs["is_doublet"] == "singlet"].copy()
print(f"Cells after doublet removal: {adata.n_obs}")
Recipe: Spatial Deconvolution with DestVI
When to use: Estimate cell type proportions in spatial transcriptomics spots using a matched scRNA-seq reference.
import scvi
# Step 1: Train CondSCVI on reference single-cell data
scvi.model.CondSCVI.setup_anndata(sc_adata, layer="counts", labels_key="cell_type")
sc_model = scvi.model.CondSCVI(sc_adata, weight_obs=False)
sc_model.train(max_epochs=200)
# Step 2: Deconvolve spatial spots
scvi.model.DestVI.setup_anndata(st_adata, layer="counts")
st_model = scvi.model.DestVI.from_rna_model(st_adata, sc_model)
st_model.train(max_epochs=2500)
proportions = st_model.get_proportions() # DataFrame (n_spots, n_cell_types)
st_adata.obsm["cell_type_proportions"] = proportions.values
print(f"Proportions per spot: {proportions.shape}")
print(proportions.head())
Recipe: Extract Denoised Expression for Imputation
When to use: Obtain smooth, denoised expression values for visualization or downstream machine learning when raw sparse counts are too noisy.
import scvi
scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="batch")
model = scvi.model.SCVI(adata, n_latent=30)
model.train(max_epochs=200, early_stopping=True)
# n_samples >= 25 gives stable posterior mean; increase to 100 for publication
denoised = model.get_normalized_expression(
n_samples=50,
library_size="latent", # "latent" uses learned library size; int for fixed normalization
return_mean=True,
)
adata.layers["denoised"] = denoised
print(f"Denoised layer added: {denoised.shape}")
# Use adata.layers["denoised"] for heatmaps, pseudotime, or ML features
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: adata must contain raw counts |
Log-normalized data passed instead of raw counts | Save raw before normalizing: adata.layers["counts"] = adata.X.copy() then setup_anndata(layer="counts") |
| Training loss oscillates without decreasing | Learning rate too high or very heterogeneous data | Try lr=1e-4; check that adata.X and the registered layer are not sparse with negatives |
| CUDA out of memory | Dataset too large for GPU VRAM | Reduce batch_size=64, n_hidden=64; subset to fewer HVGs; use CPU for small datasets |
| Poor batch integration (batches separate on UMAP) | Batch key not registered, or too few epochs | Verify batch_key column exists in adata.obs; increase max_epochs; add early_stopping=True |
| scANVI predicts same label for all cells | Too few labeled cells per type or learning rate issue | Need ≥50 labeled cells per type; use from_scvi_model() workflow; check unlabeled_category matches exactly |
load() fails with KeyError or shape mismatch |
AnnData var_names differ from training data |
Ensure query adata.var_names exactly matches training data; do not subset genes after training |
| Slow training on CPU for large datasets | Large dataset without GPU acceleration | Install scvi-tools[cuda12]; pass accelerator="gpu" to train(); or subsample to 50k cells for prototyping |
scvi.model.SCANVI.load_query_data shape error |
Query and reference have different gene sets | Align genes: query_adata = query_adata[:, ref_adata.var_names] before calling load_query_data |
Related Skills
- scanpy-scrna-seq — standard scRNA-seq QC, clustering, marker genes; use scVI latent as input to
sc.pp.neighbors(use_rep="X_scVI") - anndata-data-structure — create, subset, concatenate, and persist AnnData objects required by scvi-tools
- harmony-batch-correction — fast linear batch correction alternative; use when deep learning overhead is not justified
- cellxgene-census — download reference atlases for scANVI transfer learning and scARCHES query-to-reference mapping
- muon-multiomics-singlecell — multi-modal single-cell analysis with MuData; complement to totalVI for Multiome workflows
References
- scvi-tools documentation — official API reference, tutorials, and model guides
- scvi-tools GitHub — source code, issue tracker, and changelog
- Lopez et al. (2018) "Deep generative modeling for single-cell transcriptomics" — Nature Methods 15:1053–1058 — original scVI paper
- Xu et al. (2021) "Probabilistic harmonization and annotation of single-cell transcriptomics data with deep generative models" — Molecular Systems Biology 17:e9620 — scANVI paper
- Gayoso et al. (2022) "A Python library for probabilistic analysis of single-cell omics data" — Nature Biotechnology 40:163–166 — scvi-tools library paper
skills/genomics-bioinformatics/single-cell/single-cell-annotation-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill single-cell-annotation-guide -g -y
SKILL.md
Frontmatter
{
"name": "single-cell-annotation-guide",
"license": "CC-BY-4.0",
"description": "Decision framework for manual marker-based, automated (CellTypist), and reference-based (popV) cell type annotation in scRNA-seq. Three-tier strategy: Tier 1 manual markers, Tier 2 CellTypist, Tier 3 popV ensemble transfer. Use when planning or troubleshooting annotation."
}
Single-Cell RNA-seq Cell Type Annotation Guide
Overview
Cell type annotation is the process of assigning biological identities to computationally defined clusters in single-cell RNA-seq data. It is one of the most consequential analytical decisions in a scRNA-seq project: annotation errors propagate into downstream analyses of differential expression, trajectory inference, and cell-cell communication. This guide presents a three-tier decision strategy — manual marker-based annotation first, automated reference-free classification second, and ensemble reference-based label transfer third — and explains when each approach is most appropriate.
The guide synthesizes community know-how on CellTypist (Dominguez Conde et al., Science 2022), popV (Luecken et al., Nature Methods 2024), and classical marker-based approaches, following standards established by the Human Cell Atlas project.
The three tiers represent a progression from effort-intensive but transparent (manual) to efficient and scalable (automated). They are not mutually exclusive: best practice is to run automated annotation first to generate hypotheses and then validate with manual marker inspection. For high-stakes biological claims — rare cell types, novel disease states, clinical applications — all three tiers should be used in parallel and discordant results resolved explicitly before publication.
Key Concepts
Cell Type Markers and Cluster Identity
A cell type marker is a gene whose expression distinguishes one cell population from all others in a dataset. Canonical markers are those validated across many studies and tissues — for example, CD3E for T cells, CD19 and MS4A1 (CD20) for B cells, CD14 for classical monocytes, and EPCAM for epithelial cells. Effective markers fulfill three criteria: they are highly expressed in the target cell type (high sensitivity), they are absent or very low in all other cell types (high specificity), and their identity is confirmed by at least two independent markers.
Clusters produced by algorithms such as Leiden or Louvain represent groups of transcriptionally similar cells; they do not inherently correspond to biological cell types. A single true cell type may appear as multiple clusters if the resolution parameter is too high (overclustering), and biologically distinct cell types may be merged if resolution is too low. Annotation quality depends on both the quality of the clustering and the quality of the evidence used to assign identities.
Reference Atlases and Label Transfer
Reference atlases are large, curated scRNA-seq datasets with expert-validated cell type labels that serve as a "ground truth" for annotation of new query datasets. Prominent examples include the Human Cell Atlas (HCA), the Human Lung Cell Atlas (HLCA, 2.4M cells), Tabula Sapiens (500K cells across 28 tissues), and the Human BM Atlas for bone marrow. Label transfer is the computational process of projecting query cells onto the reference space and assigning labels based on proximity.
Label transfer quality depends critically on biological match between query and reference. Adult tissue query data annotated with a fetal reference will produce systematic errors for cell types that differ between developmental stages. Similarly, a blood-derived reference will poorly annotate tumor-infiltrating immune cells, which have altered transcriptional programs compared to their circulating counterparts.
Annotation Confidence and Validation
Automated annotation tools produce confidence scores (probability or agreement rate) that quantify the certainty of each cell's label. These scores should never be ignored. Cells with low confidence scores may represent novel cell types absent from the reference, doublets (two cells captured together), or transitional states along a differentiation continuum.
Validation refers to verifying that automated labels are consistent with independent biological evidence — typically canonical marker gene expression. Even when an automated method reports high confidence, a few minutes spent visualizing marker genes in a UMAP or dotplot is essential to catch systematic misannotations, especially for rare or disease-specific cell populations absent from training data.
Doublets and Technical Artifacts
Doublets are droplets containing two or more cells, which appear as a single cell with an anomalously high gene count and mixed transcriptional identity. Annotating doublets before removing them produces spurious "cell types" that combine markers from two real populations (e.g., an apparent NK-B cell hybrid). Tools such as Scrublet and DoubletFinder should be applied before annotation; doublets should be marked and excluded from the annotation workflow.
Batch effects — systematic technical differences between samples processed at different times, with different reagents, or on different platforms — can mimic cell type differences. If one batch contains more stressed cells (higher stress gene expression), they may cluster separately and be misannotated as a distinct cell type. Batch correction with Harmony, scVI, or BBKNN should be applied before annotation when multiple batches are present.
Cell Ontology and Annotation Hierarchies
Cell ontologies are formal vocabularies that define cell type names, synonyms, and parent-child relationships. The Cell Ontology (CL), maintained by the OBO Foundry, is the standard used by the Human Cell Atlas and most major atlases. Using ontology-compliant cell type names (e.g., "CD8-positive, alpha-beta T cell" rather than "CD8 T cell") enables cross-dataset comparison and interoperability. CellTypist and popV return labels that map to the Cell Ontology when tissue-matched models are used.
Annotation hierarchies reflect that cell identities exist at multiple levels of granularity. At the coarsest level, cells are classified as broad lineages (immune, epithelial, stromal). At intermediate granularity, they are classified as cell types (T cell, B cell, macrophage). At the finest granularity, they are classified as cell states or subtypes (exhausted CD8 T cell, tissue-resident memory T cell, M1 macrophage). Choosing the right granularity for an annotation depends on the biological question and the depth of sequencing: underpowered studies should not attempt fine-grained annotation.
Marker Gene Evidence Categories
Marker gene evidence is not all equally reliable. Evidence categories, from most to least reliable, are:
- Canonical published markers: Genes with decades of protein-level validation, e.g., CD3 protein expression for T cells, CD19 for B cells.
- Computationally derived cluster-specific markers: Genes found by differential expression between clusters in your dataset using
sc.tl.rank_genes_groups. These are hypothesis-generating and require biological interpretation. - Cross-validated markers: Genes that are canonical and also appear as top markers from
rank_genes_groupsin your dataset. This is the strongest evidence — it means the marker's biology is reflected in your actual data. - Single-study markers: Genes described as markers in a single publication but not yet widely replicated. These should be treated as supporting evidence, not definitive.
When building a marker panel for annotation, prioritize cross-validated markers (Category 3) and use canonical published markers (Category 1) as anchors.
Decision Framework
When planning a cell type annotation strategy, choose the tier based on dataset characteristics and scientific context:
Is the tissue and cell type composition well-characterized?
├── YES, standard tissue with clear markers (blood, lung, gut, brain)
│ ├── Dataset < 5,000 cells OR small team experiment
│ │ └── Tier 1: Manual marker-based annotation
│ │ (dotplot + violin + UMAP visualization)
│ └── Dataset ≥ 5,000 cells OR high-throughput study
│ ├── Standard tissue with pre-trained atlas available
│ │ └── Tier 2: CellTypist automated annotation
│ │ (select tissue-matched model, use majority_voting)
│ └── Need cross-atlas integration or rare cell types
│ └── Tier 3: popV ensemble label transfer
│ (requires reference AnnData + tissue label)
└── NO, novel tissue, disease state, or poorly characterized system
├── Reference atlas exists for related tissue
│ └── Tier 3: popV ensemble label transfer
│ (multiple methods + consensus = more robust)
└── No good reference exists
└── Tier 1: Manual annotation (cautious, exploratory)
+ Tier 2: CellTypist as validation
(report as "putative" cell types, note limitations)
| Scenario | Recommended Approach | Rationale |
|---|---|---|
| Small dataset (<5,000 cells), well-characterized tissue | Tier 1: Manual markers | Automated tools provide marginal advantage; manual review is faster and more transparent |
| Large dataset (>50,000 cells), blood or immune tissue | Tier 2: CellTypist with Immune_All_High model | CellTypist has the most complete immune training data; scales without added complexity |
| Large dataset, lung or airway tissue | Tier 2: CellTypist with Human_Lung_Atlas model | HLCA-trained model provides granular lung-specific annotation |
| Novel disease condition or patient-derived samples | Tier 3: popV with tissue-matched reference | Ensemble consensus is more robust to distribution shift; produces a method-agreement score |
| Integration with published atlas (e.g., HLCA, HBA) | Tier 3: popV | Designed for atlas-query integration; maintains label ontology compatibility |
| Rare cell types (<1% of dataset) | Tier 3: popV + manual inspection | Rare populations benefit from multi-method ensemble; manual inspection catches misannotation |
| Fetal or developmental tissue | Tier 1 + Tier 3 with fetal reference | Fetal cell types are distinct from adult; use a matched fetal reference (Tabula Sapiens fetal) |
| Mixed or uncertain tissue composition | Tier 2 (CellTypist) then Tier 1 validation | CellTypist provides a quick hypothesis; validate each predicted type with markers |
| Benchmark or methods comparison study | All three tiers in parallel | Triangulating annotation from multiple strategies increases credibility |
Best Practices
-
Remove doublets before annotation: Scrublet or DoubletFinder should be applied as part of QC, before any clustering or annotation step. Doublets score high on gene count and UMI metrics. Annotating them produces hybrid "cell types" that confuse downstream analyses and inflate the apparent cell type diversity. Apply doublet detection, mark predicted doublets in
adata.obs, and exclude them before proceeding to annotation. -
Visualize canonical markers after any automated annotation: Automated tools, including CellTypist and popV, can be wrong. After obtaining automated labels, always inspect canonical marker expression using a dotplot (
sc.pl.dotplot), violin plot (sc.pl.violin), or feature plot on UMAP (sc.pl.umap). If a population labeled "B cell" does not express MS4A1 and CD19, the label is suspect. This step takes 15–30 minutes and catches the most common annotation errors. -
Use multiple independent methods and compare consensus: No single annotation method is universally superior. Comparing at least two methods (e.g., CellTypist and manual markers, or CellTypist and popV) builds confidence in labels where methods agree and flags cells requiring deeper inspection where they disagree. popV's built-in method-agreement score (number of methods that agree divided by total methods) directly quantifies this; a score below 0.5 suggests the cell may be a novel or ambiguous type.
-
Select reference atlases that match tissue, species, and developmental stage: The most common cause of poor label transfer is biological mismatch between query and reference. An adult lung query dataset should be annotated with an adult lung reference (HLCA), not a pan-tissue reference trained on different proportions of cell types. If the reference uses different developmental stage, health status, or sequencing technology, label transfer accuracy drops significantly. When no perfectly matched reference exists, use popV's ensemble, which is more tolerant of imperfect matches than single-method transfer.
-
Check annotation confidence scores and flag low-confidence cells: Both CellTypist (confidence score, 0–1) and popV (popv_score, fraction of methods agreeing) provide per-cell confidence metrics. Cells with CellTypist confidence below 0.5 or popV score below 0.5 should be flagged for manual review rather than automatically assigned a label. These cells often represent rare populations, transitional states, or doublets that passed the initial QC filter. Publishing results without confidence thresholds obscures annotation uncertainty.
-
Correct for batch effects before reference-based annotation: When query data contains multiple batches or samples, integrate them (Harmony, scVI, or BBKNN) before label transfer. Uncorrected batch effects create spurious cluster structure that cross-sample annotation methods cannot distinguish from true biology. Run batch correction → compute joint UMAP → apply annotation, not annotation → batch correction.
-
Use tissue-specific resolution when constructing annotation hierarchy: Cell type granularity should match the biological question. For a study of broad immune composition, major lineage labels (T cell, B cell, myeloid, NK) may suffice. For studies of T cell function, sub-type resolution (CD4 naive, CD4 Treg, CD8 effector, CD8 exhausted) is needed. Use Leiden clustering at multiple resolutions and annotate at the granularity appropriate for the question — not the maximum possible granularity.
-
Iterate between clustering resolution and annotation: Overclustering (too many clusters) creates redundant splits within a single cell type; underclustering merges distinct cell types. After initial annotation, if two clusters receive the same label from multiple methods, consider merging them. If a labeled cluster is heterogeneous on marker inspection, increase resolution and re-cluster. This iteration is normal and should be documented in the methods section.
Common Pitfalls
-
Annotating with a single marker gene per cell type: Assigning "T cell" based solely on CD3E expression will misannotate cells that briefly co-express CD3E during differentiation, doublets of CD3E+ T cells with other cell types, or low-quality cells with ambient RNA contamination.
- How to avoid: Always require at least two to three canonical markers to be co-expressed in the same cluster before assigning a label. Use a dotplot showing all markers simultaneously across all clusters; the combination of positive and negative markers (marker genes expressed versus absent) is more informative than any single marker alone.
-
Skipping doublet removal before annotation: Doublets containing, for example, a T cell and a B cell will express both CD3E and CD19. They form their own cluster and may be annotated as an unusual "co-expressing" cell type that does not exist biologically.
- How to avoid: Run Scrublet (
scrub.scrub_doublets()) or DoubletFinder immediately after QC filtering, before any normalization or clustering. Set the expected doublet rate to the multiplet rate reported by the 10x Genomics cell ranger summary (typically 1–4% per thousand cells loaded). Filter cells withpredicted_doublet == Truefromadata.obsbefore proceeding.
- How to avoid: Run Scrublet (
-
Using an atlas from a mismatched tissue or developmental stage: Annotating adult colon cells with a fetal intestine reference, or annotating diseased tissue with a healthy tissue reference, produces systematic label transfer errors that can affect all clusters in the dataset.
- How to avoid: Before selecting a reference, check its tissue of origin, developmental stage (fetal vs. adult), health status (normal vs. diseased), and species. Use the CellTypist model table (
celltypist.models.models_description()) or the popV reference documentation to confirm match. If no matched reference exists, use Tier 1 manual annotation as the primary approach and flag the uncertainty.
- How to avoid: Before selecting a reference, check its tissue of origin, developmental stage (fetal vs. adult), health status (normal vs. diseased), and species. Use the CellTypist model table (
-
Ignoring batch effects that mimic cell type differences: A dataset with two batches, one processed fresh and one processed after a freeze-thaw cycle, may show stress-responsive gene up-regulation in the freeze-thaw batch. These cells cluster separately and can be mistaken for a "stressed" or "activated" cell subtype.
- How to avoid: Plot the UMAP colored by batch/sample before annotation. If cells segregate by batch rather than by biology, apply Harmony, scVI, or BBKNN batch correction before annotation. Check that canonical markers — not batch-related genes — drive cluster identity.
-
Overclustering before annotation: Running Leiden at resolution 3.0 on a dataset of 20,000 cells may produce 80 clusters, many of which represent the same cell type at different transcriptional states or are simply noise-driven splits. Annotating all 80 clusters is laborious and produces an illusion of biological diversity.
- How to avoid: Start with a moderate Leiden resolution (0.5–1.0) and annotate broad lineages first. Apply CellTypist or popV at this coarser level. Only increase resolution for clusters of biological interest where finer distinctions matter. Use cluster-level consistency metrics — such as the silhouette score or within-cluster marker enrichment — to assess whether additional splits are biologically meaningful.
-
Not checking for cell-cycle state as a confounding factor: Rapidly dividing cells (in S or G2/M phase) can cluster separately from resting cells of the same type because proliferation-related genes (MKI67, TOP2A, PCNA) drive cluster identity rather than lineage markers.
- How to avoid: Score each cell for S-phase and G2M-phase signatures using
sc.tl.score_genes_cell_cyclewith established gene lists. Visualize cell cycle scores on UMAP. If a cluster is primarily defined by proliferation genes, label it as "[Cell Type] proliferating" rather than treating it as a distinct lineage. Optionally, regress out cell cycle scores if you want clusters to reflect lineage rather than cell state.
- How to avoid: Score each cell for S-phase and G2M-phase signatures using
-
Failing to validate low-frequency automated predictions with marker evidence: Some automated annotation tools produce plausible-sounding labels for every cell, even for rare populations with only 50–200 cells. Without marker validation, a CellTypist label of "plasmablast" for a small cluster cannot be trusted.
- How to avoid: For any cluster containing fewer than 500 cells, perform manual marker validation regardless of automated confidence scores. Examine at least three canonical positive markers and confirm that two to three negative markers are absent. For very rare populations (< 0.5% of cells), consider whether the biological question requires this level of granularity before reporting.
Workflow
-
Quality control and preprocessing:
- Filter cells by minimum gene count, maximum gene count, and percent mitochondrial reads.
- Remove ambient RNA using SoupX or CellBender if droplet-based data.
- Normalize to 10,000 counts per cell and log1p-transform (
sc.pp.normalize_total,sc.pp.log1p). - Identify highly variable genes (
sc.pp.highly_variable_genes). - Run PCA on the HVG-subset expression matrix.
- Decision point: If multiple batches are present, apply batch correction (Harmony, scVI, BBKNN) to the PCA embedding before computing the neighbor graph.
-
Doublet detection:
- Run Scrublet on each sample independently before merging (not on the merged object).
- Set expected doublet rate from the 10x cell ranger summary or the empirical rule of 0.8% per 1,000 cells loaded.
- Store predicted doublet scores in
adata.obs["doublet_score"]and boolean flags inadata.obs["predicted_doublet"]. - Remove predicted doublets before clustering and annotation.
-
Clustering:
- Compute a k-nearest neighbor graph (
sc.pp.neighbors, k=15–30). - Run UMAP for visualization (
sc.tl.umap). - Apply Leiden clustering at an initial resolution of 0.5–1.0.
- Decision point: If clusters fewer than expected for the tissue, increase resolution. If more clusters than anticipated, consider that overclustering has occurred.
- Compute a k-nearest neighbor graph (
-
Tier 1 — Manual marker-based annotation:
- Create a dotplot with top candidate markers for each expected cell type.
- For each cluster, identify the marker gene set with highest dot size (fraction expressing) and dot color (mean expression).
- Assign a provisional label based on co-expression of at least two canonical markers.
- Flag clusters with ambiguous or mixed marker profiles for further scrutiny.
-
Tier 2 — CellTypist automated annotation (if applicable):
- Select the model matching the tissue from
celltypist.models.models_description(). - Ensure input AnnData is log1p-normalized to 10,000 counts before running.
- Run
celltypist.annotate(adata, model=model, majority_voting=True). - Store results in
adata.obs["celltypist_cell_type"]andadata.obs["celltypist_conf_score"]. - Flag cells with confidence score below 0.5 for manual review.
- Decision point: If > 20% of cells are low-confidence, consider whether a better-matched model exists or switch to Tier 3.
- Select the model matching the tissue from
-
Tier 3 — popV ensemble label transfer (if applicable):
- Prepare a reference AnnData with column
cell_typecontaining validated labels. - Ensure both query and reference contain raw count matrices in
adata.X. - Run
popv.process_query(query_adata, ref_adata, ...)andpopv.annotate_data(query_adata). - Store consensus labels in
adata.obs["popv_prediction"]and agreement scores inadata.obs["popv_score"]. - Inspect the per-method label breakdown for cells with low agreement scores.
- Prepare a reference AnnData with column
-
Cross-validation and refinement:
- Compare Tier 1, Tier 2, and Tier 3 labels per cluster using a confusion matrix or sankey plot.
- For clusters where all methods agree, assign the consensus label with high confidence.
- For clusters where methods disagree, perform targeted marker visualization and assign a label based on biological reasoning.
- Document discordant clusters and the evidence used to resolve them.
-
Final annotation and export:
- Write final cell type labels to
adata.obs["cell_type"]. - Write annotation confidence tier to
adata.obs["annotation_method"](manual/celltypist/popv/manual_refined). - Export the annotated object as
.h5adfor downstream analysis. - Prepare a supplementary table listing each cell type, the markers used to validate it, the annotation method, and the number of cells.
- Write final cell type labels to
Protocol Guidelines
-
Canonical marker reference for common tissue types: For blood and immune tissue, use CD3E/CD3D (T cells), CD4/FOXP3 (CD4 T/Tregs), CD8A (CD8 T), NKG7/GNLY (NK), CD19/MS4A1 (B cells), MZB1/JCHAIN (plasma), CD14/S100A8 (classical monocytes), FCGR3A (non-classical monocytes), FCER1A/CST3 (conventional dendritic cells), CLEC4C/IL3RA (plasmacytoid dendritic cells). For lung, add EPCAM/KRT18 (epithelial), PECAM1/VWF (endothelial), COL1A1/DCN (fibroblast), and SFTPC/SFTPB (alveolar type II epithelial).
-
CellTypist model selection: Match the model to the tissue. For immune datasets use
Immune_All_High_ResolutionorImmune_All_Low_Resolution. For lung useHuman_Lung_Atlas. For gut useHuman_Colon_Cell_AtlasorCells_of_the_Human_Intestine. For fetal useFetal_Human_Pancreasor the appropriate fetal model. Runcelltypist.models.models_description()for the full list with tissue metadata. -
popV reference selection: The Human Lung Cell Atlas (HLCA), Tabula Sapiens, and the Human BM Atlas are the most commonly used references. Download them as AnnData from CELLxGENE Census or Zenodo. Subset the reference to the tissue matching the query before running popV to reduce computation time and improve transfer specificity.
-
Reporting annotation in publications: Methods sections should specify: (a) software and version for each annotation tool used, (b) which markers were used for manual validation, (c) which CellTypist model was used, (d) which reference atlas was used for label transfer, (e) how discordant labels between methods were resolved, and (f) the confidence threshold applied to filter uncertain annotations. Failing to report these details makes annotation results non-reproducible.
-
Marker gene reference table for common cell types:
| Cell Type | Positive Markers | Negative Markers |
|---|---|---|
| CD4 T cell | CD3E, CD4, IL7R | CD8A, CD19, CD14 |
| CD8 T cell | CD3E, CD8A, GZMK | CD4, CD19, CD14 |
| NK cell | NKG7, GNLY, KLRB1 | CD3E, CD19, CD14 |
| B cell | CD19, MS4A1, CD79A | CD3E, CD14, NKG7 |
| Plasma cell | MZB1, JCHAIN, IGHG1 | CD19, CD3E, CD14 |
| Classical monocyte | CD14, S100A8, LYZ | CD3E, CD19, FCGR3A |
| Non-classical monocyte | FCGR3A, CX3CR1, LST1 | CD14, CD3E, CD19 |
| Conventional DC | FCER1A, CST3, CD1C | CD3E, CD19, CD14 |
| Plasmacytoid DC | CLEC4C, IL3RA, JCHAIN | CD3E, CD19, CD14 |
| Alveolar macrophage | MARCO, FABP4, PPARG | CD14, CD3E, CD19 |
| AT2 epithelial (lung) | SFTPC, SFTPB, LAMP3 | CD45/PTPRC, VWF |
| Endothelial cell | PECAM1, VWF, CDH5 | EPCAM, CD45/PTPRC |
| Fibroblast | COL1A1, DCN, PDGFRA | EPCAM, CD45/PTPRC |
| Epithelial cell | EPCAM, KRT18, CDH1 | CD45/PTPRC, VWF |
-
Tools for annotation QC and inter-rater reliability: When multiple annotators independently assign labels to the same dataset, measure agreement using Cohen's kappa or Fleiss' kappa. Agreement above 0.8 indicates high consistency; agreement below 0.6 suggests the markers or clustering resolution used are insufficient to distinguish the cell types in question. The scArches and scPhere frameworks provide additional tools for mapping query cells onto reference manifolds and quantifying mapping quality.
-
Ambient RNA decontamination before annotation: In droplet-based scRNA-seq, ambient RNA from lysed cells contaminates all droplets. This means cells that do not express a marker gene may appear to express it at low levels due to contamination. Tools such as SoupX or CellBender estimate and remove ambient RNA contamination. Running decontamination before annotation reduces false-positive marker signals that can cause misannotation, particularly for rare cell types whose canonical markers appear as low-level "contamination" in neighboring clusters.
-
Cell type composition as a sanity check: After annotation, the expected proportion of each cell type should be biologically plausible. For a peripheral blood mononuclear cell (PBMC) dataset, expect 50–70% T cells, 10–15% B cells, 10–20% monocytes, and 5–10% NK cells. If annotation yields 90% monocytes in a PBMC sample, the annotation is almost certainly wrong. Compare proportions against published reference ranges for the tissue and experimental condition.
Further Reading
- CellTypist documentation and model zoo — Official documentation, pre-trained model list with tissue annotations, and tutorials for automated cell type annotation with majority voting
- popV GitHub repository — Installation, usage examples, and reference to the Luecken et al. 2024 Nature Methods benchmarking paper
- Human Cell Atlas Data Portal — Curated single-cell reference atlases for dozens of tissues across development and disease; source of reference data for label transfer
- Dominguez Conde et al. Science 2022 — CellTypist paper describing the pan-immune atlas and the logistic regression classifier; cite when using CellTypist
- Luecken et al. Nature Methods 2024 — popV benchmarking study comparing 10 annotation methods across 8 datasets; provides empirical guidance on method selection
- Scanpy documentation: Clustering and annotation — Official Scanpy tutorial for the full QC→preprocessing→clustering→annotation workflow
- Best Practices for Single-Cell Analysis (Heumos et al., Nature Reviews Genetics 2023) — Community best-practices review covering all steps of scRNA-seq analysis including annotation; recommended as a primary reference for study design
Related Skills
scanpy-scrna-seq— Full scRNA-seq analysis pipeline in Scanpy covering QC, normalization, clustering, and basic marker-based annotation; use as the computational foundation before applying this guide's annotation strategycelltypist-cell-annotation— Tier 2 automated annotation tool using pre-trained logistic regression models for 100+ cell types across blood, lung, gut, brain, and other tissuespopv-cell-annotation— Tier 3 ensemble label transfer tool using 10+ methods with majority voting; use for robust annotation of novel or heterogeneous datasetsscvi-tools-single-cell— Deep generative models for batch correction (scVI) and semi-supervised annotation (scANVI); use scANVI as an alternative to popV for joint integration and annotationharmony-batch-correction— Batch correction for PCA embeddings; apply before annotation when integrating samples from multiple donors or processing batchesanndata-data-structure— Core data container for all scRNA-seq analysis; required for understanding how annotations are stored and propagated in AnnData objectscellxgene-census— Source of large curated reference atlases (Human Cell Atlas, Tabula Sapiens) for use as label transfer references in popV workflowsmuon-multiomics-singlecell— Multi-modal single-cell analysis; annotation of multi-omics data (RNA+ATAC, CITE-seq) requires considering the additional modalities as supplementary evidencemofaplus-multi-omics— Multi-omics factor analysis; latent factors from MOFA+ can inform cell state annotation when transcriptional signatures are not sufficientcellchat-cell-communication— Downstream analysis of annotated single-cell data; ligand-receptor interaction inference depends entirely on having accurate cell type labels from annotation
skills/genomics-bioinformatics/variant/bcftools-variant-manipulation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill bcftools-variant-manipulation -g -y
SKILL.md
Frontmatter
{
"name": "bcftools-variant-manipulation",
"license": "MIT",
"description": "CLI for VCF\/BCF: filter, merge, annotate, query, normalize, compute stats. Core post-variant-calling: quality filtering, multi-sample merging, rsID annotation, genotype extraction. Samtools companion in HTSlib. Use GATK for complex indel realignment during calling; use VCFtools for population genetics stats."
}
bcftools — VCF/BCF Variant Manipulation Toolkit
Overview
bcftools is the standard command-line toolkit for processing VCF (Variant Call Format) and BCF (Binary Call Format) files in the HTSlib ecosystem. It covers the complete post-variant-calling workflow: format conversion, quality filtering, variant normalization, multi-sample merging, annotation with external databases, genotype extraction, and QC statistics. bcftools uses streaming by design — most commands read from stdin and write to stdout, making it ideal for memory-efficient pipelines on large cohorts.
When to Use
- Filtering variants by quality (QUAL, DP, AF) after variant calling
- Merging VCF files from multiple samples into a joint call set
- Adding rsIDs or gene annotations to variant calls
- Extracting specific fields (genotypes, allele depths) as tabular output
- Normalizing indel representations and splitting multi-allelic records
- Calling variants from pileup output (mpileup + call)
- Computing per-sample and overall VCF QC statistics
- Use
GATK HaplotypeCallerinstead when calling variants with local realignment in human samples - Use
VCFtoolsinstead for population genetics statistics (Fst, LD, Hardy-Weinberg) - Use
bcftoolsin the HTSlib pipeline; usepicardfor duplicate-marking and library metrics
Prerequisites
- Installation: bcftools 1.17+ (part of HTSlib suite with samtools)
- Input requirements: VCF or BGzipped+tabix-indexed VCF (
.vcf.gz + .vcf.gz.tbi) for region queries - Companion tools:
samtoolsfor BAM processing;tabixfor VCF indexing
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v bcftoolsfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run bcftoolsrather than barebcftools.
# Bioconda (recommended — installs HTSlib suite)
conda install -c bioconda bcftools
# Homebrew (macOS)
brew install bcftools
# Verify
bcftools --version | head -1
# bcftools 1.20
# Index a VCF for region queries
bcftools index -t variants.vcf.gz # creates .tbi
bcftools index -c variants.vcf.gz # creates .csi (for chromosomes > 512 Mb)
Quick Start
# Typical post-calling workflow: normalize → filter → annotate → extract
bcftools norm -d any -f reference.fa variants.vcf.gz \
| bcftools filter -i 'QUAL>20 && DP>10' \
| bcftools annotate -a dbSNP.vcf.gz -c ID \
| bcftools view -O z -o final.vcf.gz
# Index the output
bcftools index -t final.vcf.gz
# Count variants at each stage
bcftools stats final.vcf.gz | grep "^SN"
Core API
Module 1: VCF/BCF I/O and Format Conversion
Convert between text VCF and binary BCF; compress and index for random access.
# VCF → compressed BCF (fastest format for piping)
bcftools view -O b -o variants.bcf variants.vcf
# BCF → VCF (for human-readable output)
bcftools view -O v -o variants.vcf variants.bcf
# VCF → bgzipped + indexed (standard archive format)
bcftools view -O z -W -o variants.vcf.gz variants.vcf
# -W automatically creates .tbi index after writing
# Extract specific samples
bcftools view -s sample1,sample2 -O z -o subset.vcf.gz variants.vcf.gz
# Exclude samples (prefix with ^)
bcftools view -s ^outlier_sample -O z -o cleaned.vcf.gz variants.vcf.gz
# Extract by region (fast; requires index)
bcftools view -r chr1:1000000-2000000 variants.vcf.gz -O v -o chr1_region.vcf
# Streaming pipeline: no intermediate files
samtools mpileup -Ou input.bam | bcftools call -m -Oz -o calls.vcf.gz
Module 2: Variant Filtering
Apply quality thresholds and FLAG-based filters to retain high-confidence calls.
# Expression-based filter (include)
bcftools filter -i 'QUAL>20 && DP>10' variants.vcf.gz -O z -o filtered.vcf.gz
# Expression-based filter (exclude)
bcftools filter -e 'QUAL<10 || DP<5' variants.vcf.gz -O v -o filtered.vcf
# Soft filter: mark but keep (sets FILTER field to label)
bcftools filter -s LowQual -e 'QUAL<20' variants.vcf.gz -O z -o soft_filtered.vcf.gz
# Variants with QUAL<20 get FILTER="LowQual"; others get FILTER=PASS
# Keep only PASS variants
bcftools view -f PASS variants.vcf.gz -O z -o pass_only.vcf.gz
# SNP-only output
bcftools view --type snps variants.vcf.gz -O z -o snps.vcf.gz
# Indel-only output
bcftools view --type indels variants.vcf.gz -O z -o indels.vcf.gz
# Filter by allele frequency and depth
bcftools filter -i 'AF>0.1 && DP>20 && MQ>40' variants.vcf.gz -O z -o confident.vcf.gz
# Remove SNPs within 3 bp of indels
bcftools filter --SnpGap 3 variants.vcf.gz -O z -o gapfiltered.vcf.gz
Module 3: VCF Query and Extraction
Transform VCF content into tabular text for downstream analysis.
# Extract chrom, position, ref, alt, quality
bcftools query -f '%CHROM\t%POS\t%REF\t%ALT\t%QUAL\n' variants.vcf.gz > variants.txt
# With header row (-H adds #-prefixed column names)
bcftools query -H -f '%CHROM\t%POS\t%REF\t%ALT\t%QUAL\n' variants.vcf.gz > variants.tsv
# Per-sample genotypes and allele depths
bcftools query -f '[%SAMPLE\t%GT\t%AD\n]' variants.vcf.gz > genotypes.txt
# Output: sample1 0/1 25,18 (ref_depth,alt_depth)
# Rare variants (AF < 1%)
bcftools query -i 'AF<0.01' -f '%CHROM\t%POS\t%REF\t%ALT\t%AF\n' \
variants.vcf.gz > rare_variants.txt
# Count variants per chromosome
bcftools query -f '%CHROM\n' variants.vcf.gz | sort | uniq -c | sort -rn
# Extract genotype matrix across all samples
bcftools query -f '%CHROM:%POS\t[%GT\t]\n' -H variants.vcf.gz > genotype_matrix.tsv
Module 4: Multi-file Operations
Combine VCF files from multiple samples (merge) or chromosomes (concat).
# Merge: join VCFs from DIFFERENT sample sets (same variants)
bcftools merge sample1.vcf.gz sample2.vcf.gz sample3.vcf.gz \
-O z -o cohort.vcf.gz
# Merge with auto-indexing and threading
bcftools merge -O b -W --threads 4 sample*.vcf.gz > cohort.bcf
# Concat: join VCFs from SAME sample set (different chromosomes or batches)
bcftools concat chr1.vcf.gz chr2.vcf.gz chr3.vcf.gz -O z -o full.vcf.gz
# Concat with overlap handling (from batched calling)
bcftools concat -a --threads 4 batch*.vcf.gz -O z -o concat.vcf.gz
# Pipeline: merge → filter → normalize
bcftools merge sample1.vcf.gz sample2.vcf.gz \
| bcftools filter -i 'QUAL>20' \
| bcftools norm -d any -f genome.fa \
| bcftools view -O z -o merged_clean.vcf.gz
bcftools index -t merged_clean.vcf.gz
# Extract genotype matrix from merged cohort
bcftools merge cohort*.vcf.gz | bcftools query -f '[%GT\t]\n' > gt_matrix.tsv
Module 5: Variant Annotation
Add identifiers, gene annotations, or external data to VCF records.
# Add rsIDs from dbSNP
bcftools annotate -a dbSNP.vcf.gz -c ID variants.vcf.gz -O z -o rsid_annotated.vcf.gz
# Annotate with BED file (adds gene names)
bcftools annotate -a genes.bed.gz \
-h <(echo '##INFO=<ID=GENE,Number=1,Type=String,Description="Gene name">') \
-c CHROM,FROM,TO,GENE \
variants.vcf.gz -O z -o gene_annotated.vcf.gz
# Remove unwanted INFO fields
bcftools annotate -x INFO/AC,INFO/AN,INFO/MQ variants.vcf.gz -O v -o stripped.vcf
# Normalize: left-align indels, split multi-allelic records
bcftools norm -f reference.fa variants.vcf.gz -O v -o normalized.vcf
# Split multi-allelic sites into separate records
bcftools norm -m -any variants.vcf.gz -O v -o split.vcf
# Deduplicate overlapping records
bcftools norm -d any variants.vcf.gz -O z -o deduped.vcf.gz
# Full normalize pipeline
bcftools norm -m -any variants.vcf.gz | \
bcftools norm -d any -f reference.fa | \
bcftools view -O z -o normalized_split.vcf.gz
Module 6: Statistics and QC
Generate summary metrics and per-sample variant counts.
# Full VCF statistics report
bcftools stats variants.vcf.gz > qc.stats.txt
# Extract Summary Numbers section only
grep "^SN" qc.stats.txt | cut -f3,4
# number of records: 45231
# number of SNPs: 38941
# number of indels: 6290
# ...
# Per-sample stats (PSC = per-sample counts)
bcftools stats -s - variants.vcf.gz | grep "^PSC" > per_sample.txt
# cols: id sample hom_RR het hom_AA ts tv indel missing singleton
# Transition/transversion ratio (genome-wide QC)
bcftools stats variants.vcf.gz | grep "Ts/Tv"
# Ts/Tv ratio: 2.06 (healthy WGS; <1.8 or >2.2 suggests quality issues)
# Check for sample contamination (F-statistic per sample)
bcftools stats -s - variants.vcf.gz | grep "^PSC" | awk '{print $2, $9}'
# sample F_missing (high = poor sample quality)
# Variant calling (mpileup → call pipeline)
samtools mpileup -Ou -f genome.fa *.bam | bcftools call -m -v -Oz -o calls.vcf.gz
bcftools stats calls.vcf.gz | grep "^SN"
Key Concepts
Output Format Flags
-O v → VCF text (uncompressed) default for human inspection
-O z → bgzipped VCF (.vcf.gz) standard for archiving
-O b → binary BCF (compressed) fastest for piping
-O u → binary BCF (uncompressed) fastest output (no compression)
Rule: Use -O b or -O u for intermediate pipeline steps (no I/O overhead). Use -O z for files you will store or index with tabix.
Filter Expression Syntax
Expressions use INFO and FORMAT fields with comparison operators:
# INFO fields (one value per variant)
QUAL>20 # quality score
DP>10 # total depth
AF<0.05 # allele frequency
MQ>40 # mapping quality
# FORMAT fields (per-sample; use [] to iterate)
GT=="1/1" # homozygous alternate
AD[1]>5 # alt allele depth > 5
GQ>20 # genotype quality
# Combined
QUAL>20 && DP>10 && AF>0.01
(GT=="0/0" || GT=="1/1") && GQ>30
Common Workflows
Workflow 1: Variant QC and Filtering Pipeline
Goal: Normalize, filter, and annotate a raw variant call set for downstream analysis.
#!/bin/bash
VCF="raw_calls.vcf.gz"
REF="reference.fa"
DBSNP="dbSNP_hg38.vcf.gz"
FINAL="variants_filtered_annotated.vcf.gz"
# 1. Normalize: left-align indels, split multi-allelic, deduplicate
bcftools norm -m -any $VCF \
| bcftools norm -d any -f $REF \
| bcftools view -O z -o normalized.vcf.gz
bcftools index -t normalized.vcf.gz
# 2. Filter by quality and depth
bcftools filter -i 'QUAL>20 && DP>10' normalized.vcf.gz \
| bcftools filter --SnpGap 3 \
| bcftools view -f PASS -O z -o filtered.vcf.gz
bcftools index -t filtered.vcf.gz
# 3. Annotate with rsIDs
bcftools annotate -a $DBSNP -c ID filtered.vcf.gz -O z -o $FINAL
bcftools index -t $FINAL
# Report variant counts
echo "Final variant count:"
bcftools stats $FINAL | grep "number of records"
Workflow 2: Multi-sample Cohort Merging and Genotype Extraction
Goal: Merge per-sample VCFs into a cohort VCF; extract a genotype matrix for GWAS.
#!/bin/bash
SAMPLES=(sample1 sample2 sample3 sample4 sample5)
# 1. Ensure all VCFs are indexed
for s in "${SAMPLES[@]}"; do
bcftools index -t ${s}.vcf.gz
done
# 2. Merge into cohort VCF (only sites present in ALL samples: -m none)
bcftools merge -m none "${SAMPLES[@]/%/.vcf.gz}" \
-O z --threads 8 -o cohort.vcf.gz
bcftools index -t cohort.vcf.gz
# 3. Filter: PASS, SNPs only, MAF > 1%
bcftools view -f PASS --type snps cohort.vcf.gz \
| bcftools filter -i 'AF>0.01 && AF<0.99' \
| bcftools view -O z -o cohort_snps_filtered.vcf.gz
# 4. Extract numeric genotype matrix (for plink/R/Python)
bcftools query -H \
-f '%CHROM\t%POS\t%REF\t%ALT\t[%GT\t]\n' \
cohort_snps_filtered.vcf.gz > genotype_matrix.tsv
echo "Genotype matrix: $(wc -l < genotype_matrix.tsv) variants x ${#SAMPLES[@]} samples"
Key Parameters
| Parameter | Command | Default | Range/Options | Effect |
|---|---|---|---|---|
-O |
Most | v |
v,z,b,u |
Output format: VCF, bgzip-VCF, BCF, uncompressed BCF |
-r |
Most | — | chr:pos-end |
Region filter (requires tabix index) |
-s |
Most | All | sample names | Include specific samples (prefix ^ to exclude) |
-i |
filter | — | expression | Include variants matching expression |
-e |
filter | — | expression | Exclude variants matching expression |
-f |
query | — | format string | Custom output format string |
--threads |
Most | 1 | 1–N | Compression/decompression threads |
-a |
annotate | — | file path | Annotation source (BED, VCF, TSV) |
-m |
norm | none | -any, +any |
Split (−) or join (+) multi-allelic records |
-d |
norm | — | all,any,snps |
Deduplication strategy |
-W |
view | — | flag | Auto-create index after writing |
-v |
call | — | flag | Output variant sites only (skip reference sites) |
Best Practices
-
Index before region queries:
bcftools view -r chr1:...requires a.tbior.csiindex. Always runbcftools index -t output.vcf.gzafter creating any bgzipped VCF. -
Normalize before merging or annotation: Different callers represent the same indel differently. Run
bcftools norm -m -any | bcftools norm -d any -f ref.fabefore merging to prevent duplicate records. -
Use
-O ufor pipeline intermediates: Uncompressed BCF output (-O u) eliminates compression/decompression overhead in multi-step pipes — typically 2-3× faster than-O z. -
Verify Ts/Tv ratio after calling:
bcftools stats variants.vcf.gz | grep Ts/Tv. For human WGS, expect 2.0–2.1; exome 2.5–3.0. Values outside these ranges indicate quality problems. -
Filter before merge for large cohorts: Filtering per-sample VCFs before merging reduces memory and I/O. Apply site-level QC (
QUAL>20 && DP>5) to each sample beforebcftools merge. -
Check chromosome naming consistency: bcftools fails silently if merging
chr1-style with1-style VCFs. Verify withbcftools view -h file.vcf.gz | grep "^##contig".
Common Recipes
Recipe: Count Variants Before and After Filtering
echo "Before:" $(bcftools view -c 1 raw.vcf.gz | wc -l)
echo "PASS only:" $(bcftools view -f PASS raw.vcf.gz | bcftools view -c 1 | wc -l)
echo "SNPs PASS:" $(bcftools view -f PASS --type snps raw.vcf.gz | bcftools view -c 1 | wc -l)
Recipe: Extract Heterozygous Sites for One Sample
bcftools view -s SAMPLE_A variants.vcf.gz \
| bcftools filter -i 'GT="0/1"' \
| bcftools query -f '%CHROM\t%POS\t%REF\t%ALT\n' > het_sites.txt
echo "$(wc -l < het_sites.txt) heterozygous sites"
Recipe: Compare Two VCF Files for Concordance
# Find variants unique to each file and shared
bcftools isec -p isec_dir file1.vcf.gz file2.vcf.gz
ls isec_dir/
# 0000.vcf: private to file1
# 0001.vcf: private to file2
# 0002.vcf: shared (from file1 perspective)
# 0003.vcf: shared (from file2 perspective)
echo "Shared: $(wc -l < isec_dir/0002.vcf) variants"
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Missing index error |
VCF not indexed (needed for -r region queries) |
Run bcftools index -t file.vcf.gz |
[E::vcf_parse_format] parse error |
Malformed VCF FORMAT or INFO field | Validate: bcftools view file.vcf.gz 2>&1 | head -5; check source tool version |
| Empty filter output | Expression too strict or field missing | Test expression: bcftools view -h file.vcf.gz | grep "##INFO=<ID=QUAL" |
| Merge: sample duplication | Duplicate sample names across input VCFs | Rename with bcftools reheader -s new_names.txt sample.vcf.gz before merging |
| Wrong Ts/Tv ratio (<1.8) | Low-quality calls or poor coverage | Apply stricter quality filter (QUAL>30 && DP>15); check alignment quality |
concat fails with overlap |
Overlapping regions in input VCFs | Use -a flag: bcftools concat -a file1.vcf.gz file2.vcf.gz |
| Annotation mismatch | chr naming conflict (chr1 vs 1) | Check: bcftools view -h file.vcf.gz | grep contig; rename with bcftools annotate --rename-chrs |
query returns empty fields |
FORMAT field not populated for sample | Check VCF header: bcftools view -h file.vcf.gz | grep FORMAT |
Related Skills
- samtools-bam-processing — BAM processing that feeds into bcftools variant calling pipeline
- bedtools-genomic-intervals — intersecting VCF variants with genomic features (genes, regions)
- gget-genomic-databases — Ensembl/NCBI queries to annotate variant gene context
References
- bcftools documentation — complete command reference
- GitHub: samtools/bcftools — source code, releases, issue tracker
- Danecek et al. (2021) "Twelve years of SAMtools and BCFtools" — GigaScience 10(2)
- VCF file format specification — field definitions and encoding rules
skills/genomics-bioinformatics/variant/cnvkit-copy-number/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cnvkit-copy-number -g -y
SKILL.md
Frontmatter
{
"name": "cnvkit-copy-number",
"license": "Apache-2.0",
"description": "Detect somatic CNVs from WES\/WGS\/targeted BAMs (CNVkit v0.9.x). Bin coverage in target\/antitarget regions, normalize vs reference, segment with CBS\/HMM, call amps\/dels, scatter\/diagram plots, purity\/ploidy, VCF\/SEG export. CLI plus Python API (cnvlib). Use GATK CNV for deep WGS with population controls; use CNVkit for targeted\/exome where antitarget bins matter."
}
CNVkit Copy Number Analysis
Overview
CNVkit detects somatic copy number variants (CNVs) from whole-exome sequencing (WES), whole-genome sequencing (WGS), or targeted panel BAM files. It calculates read depth in both on-target (capture) bins and off-target (antitarget) bins, corrects for GC bias and library depth, segments the log2 copy ratio profile with circular binary segmentation (CBS) or a hidden Markov model (HMM), and calls amplifications and deletions. CNVkit provides both a CLI (cnvkit.py) and a Python API (cnvlib) for integration into analysis pipelines, and produces scatter plots, chromosome diagrams, heatmaps, and export files in VCF, BED, and SEG formats.
When to Use
- Calling somatic copy number variants from tumor-normal paired exome (WES) or targeted panel sequencing
- Detecting copy number alterations in tumor-only samples using a pooled normal reference
- Running CNV analysis on whole-genome sequencing (WGS) data with the
--method wgsmode - Estimating tumor purity and ploidy for samples where purity is unknown, to interpret copy ratio calls
- Generating SEG format copy number files for GISTIC2, cBioPortal, or IGV visualization
- Identifying focal amplifications (e.g., ERBB2, MYC) or homozygous deletions (e.g., CDKN2A, RB1)
- Use GATK CNV (
gatk DenoiseReadCounts/gatk ModelSegments) instead for deep WGS cohorts with large matched panel-of-normals (PoN); CNVkit is better suited for targeted/exome data - Use Control-FREEC instead when you need allele-frequency-based B-allele fraction modeling alongside CNV calling
Prerequisites
- Software: CNVkit v0.9.x, Python 3.8+, R (for CBS segmentation), samtools
- Python packages:
cnvlib(installed as part of CNVkit),matplotlib,pandas - Input files: sorted, indexed BAM files (tumor ± matched normal); BED file of capture targets; reference genome FASTA; access to R with DNAcopy package for CBS
- Data requirements: minimum ~50× mean target coverage for WES; WGS works at 20-30×
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v cnvkit.pyfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run cnvkit.pyrather than barecnvkit.py.
# Install CNVkit via conda (recommended — handles R/DNAcopy dependency)
conda install -c bioconda cnvkit
# Or via pip (requires R + DNAcopy already installed)
pip install cnvkit
# Verify
cnvkit.py version
# cnvkit 0.9.10
# Install R DNAcopy (for CBS segmentation)
Rscript -e 'if (!requireNamespace("BiocManager")) install.packages("BiocManager"); BiocManager::install("DNAcopy")'
# Index BAM files if not already indexed
samtools index tumor.bam
samtools index normal.bam
Quick Start
# One-command paired tumor/normal CNV analysis (WES)
cnvkit.py batch tumor.bam \
--normal normal.bam \
--targets targets.bed \
--fasta GRCh38.fa \
--output-dir cnvkit_results/ \
--diagram --scatter \
--method hybrid
# Output files in cnvkit_results/:
# tumor.targetcoverage.cnn — target bin coverage
# tumor.antitargetcoverage.cnn — antitarget coverage
# tumor.cnr — copy number ratios
# tumor.cns — segmented copy numbers
# tumor-scatter.png — genome-wide scatter plot
# tumor-diagram.pdf — chromosome diagram
echo "CNV analysis complete"
Workflow
Step 1: Create Copy Number Reference
Build a reference from one or more normal BAM files. This corrects for systematic biases (GC content, mappability) and sets the neutral baseline.
# Option A: Paired normal reference (single matched normal)
cnvkit.py reference normal.targetcoverage.cnn normal.antitargetcoverage.cnn \
--fasta GRCh38.fa \
-o reference_normal.cnn
# Option B: Flat reference (no normal; uses GC/mappability correction only)
# Use when no matched normal is available
cnvkit.py reference \
--targets targets.bed \
--fasta GRCh38.fa \
--output flat_reference.cnn
# Option C: Pooled normal reference from multiple normals (most robust)
cnvkit.py batch \
normal1.bam normal2.bam normal3.bam \
--normal \
--targets targets.bed \
--fasta GRCh38.fa \
--output-reference pooled_reference.cnn \
--output-dir normals_cov/
echo "Reference created: pooled_reference.cnn"
Step 2: Calculate Coverage in Target and Antitarget Bins
Bin the target BED file and compute per-bin read depth for tumor and normal samples.
# First, create accessible bins from the target BED
cnvkit.py target targets.bed \
--annotate refFlat.txt \
--split \
-o targets.split.bed
cnvkit.py antitarget targets.bed \
--access data/access-5k-mappable.hg38.bed \
-o antitargets.bed
# Calculate coverage for tumor sample
cnvkit.py coverage tumor.bam targets.split.bed \
-o tumor.targetcoverage.cnn
cnvkit.py coverage tumor.bam antitargets.bed \
-o tumor.antitargetcoverage.cnn
echo "Coverage files:"
echo " tumor.targetcoverage.cnn"
echo " tumor.antitargetcoverage.cnn"
# Python API equivalent: compute coverage with cnvlib
import cnvlib
# Load and inspect coverage files
target_cov = cnvlib.read("tumor.targetcoverage.cnn")
antitarget_cov = cnvlib.read("tumor.antitargetcoverage.cnn")
print(f"Target bins: {len(target_cov)}")
print(f"Antitarget bins: {len(antitarget_cov)}")
print(f"Mean target depth: {target_cov['depth'].mean():.1f}×")
print(f"Median target depth: {target_cov['depth'].median():.1f}×")
# Check coverage distribution
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 4))
target_cov["depth"].clip(upper=500).hist(bins=50, ax=ax, color="#2c6fad", alpha=0.7)
ax.set_xlabel("Read depth")
ax.set_ylabel("Bin count")
ax.set_title("Target bin coverage distribution")
plt.tight_layout()
plt.savefig("coverage_distribution.png", dpi=150)
print("Saved: coverage_distribution.png")
Step 3: Normalize and Correct Copy Ratios
Normalize tumor coverage against the reference (correcting for GC bias, library depth, and target efficiency).
# Fix sample-level biases relative to the reference
cnvkit.py fix \
tumor.targetcoverage.cnn \
tumor.antitargetcoverage.cnn \
pooled_reference.cnn \
-o tumor.cnr
echo "Copy number ratios: tumor.cnr"
# tumor.cnr columns: chromosome, start, end, gene, log2, depth, weight
# Inspect copy ratio file with cnvlib Python API
import cnvlib
import pandas as pd
cnr = cnvlib.read("tumor.cnr")
print(f"Total bins: {len(cnr)}")
print(f"Chromosomes: {sorted(cnr.chromosome.unique())}")
# Convert to DataFrame and inspect
df = cnr.data
print(f"\nLog2 copy ratio summary:")
print(df["log2"].describe().round(3))
# Flag high-amplitude events
high_amp = df[df["log2"] >= 2.0]
hom_del = df[df["log2"] <= -3.0]
print(f"\nHigh amplitude bins (log2 >= 2.0): {len(high_amp)}")
print(f"Homozygous deletion bins (log2 <= -3.0): {len(hom_del)}")
if not high_amp.empty:
print(high_amp[["chromosome", "start", "end", "gene", "log2"]].head())
Step 4: Segment Copy Number Ratios
Identify contiguous regions of similar copy ratio using CBS (Circular Binary Segmentation) or HMM segmentation.
# CBS segmentation (default; requires R DNAcopy)
cnvkit.py segment tumor.cnr \
-o tumor.cns \
--method cbs
# HMM segmentation (no R required; faster)
cnvkit.py segment tumor.cnr \
-o tumor.hmm.cns \
--method hmm
echo "Segments: tumor.cns"
# tumor.cns columns: chromosome, start, end, gene, log2, cn, depth, p_ttest, weight, probes
# Python API: segment and inspect
import cnvlib
import subprocess
# Run segmentation via Python subprocess (mirrors CLI)
subprocess.run(
["cnvkit.py", "segment", "tumor.cnr", "-o", "tumor.cns", "--method", "cbs"],
check=True
)
# Load and analyze segments
cns = cnvlib.read("tumor.cns")
df_seg = cns.data
print(f"Total segments: {len(df_seg)}")
print(f"\nSegment log2 summary:")
print(df_seg["log2"].describe().round(3))
# Large segments (>5 Mb) with copy gain or loss
large_events = df_seg[
((df_seg["end"] - df_seg["start"]) > 5_000_000) &
(df_seg["log2"].abs() > 0.3)
].copy()
large_events["size_mb"] = (large_events["end"] - large_events["start"]) / 1e6
print(f"\nLarge CNV segments (>5 Mb, |log2|>0.3): {len(large_events)}")
print(large_events[["chromosome", "start", "end", "log2", "size_mb"]].head(8).to_string(index=False))
Step 5: Call CNV States
Assign integer copy number states and classify amplifications and deletions.
# Call with default thresholds (diploid normal)
cnvkit.py call tumor.cns \
-o tumor.call.cns \
--ploidy 2
# Call with tumor purity estimate (if known)
cnvkit.py call tumor.cns \
--purity 0.7 \
--ploidy 2 \
-o tumor.call.purity.cns
echo "Called CNVs: tumor.call.cns"
# Parse called CNV file and classify events
import cnvlib
import pandas as pd
cns_called = cnvlib.read("tumor.call.cns")
df = cns_called.data
# Classify by log2 thresholds (diploid assumed)
# log2 >= 1.0 = high-level amplification (CN >= 4)
# log2 0.2–1.0 = copy gain (CN = 3)
# log2 -1.0–-0.2 = heterozygous deletion (CN = 1)
# log2 <= -3.5 = homozygous deletion (CN = 0)
def classify_cnv(log2):
if log2 >= 1.0: return "AMP"
if log2 >= 0.2: return "GAIN"
if log2 <= -3.5: return "HOMDEL"
if log2 <= -1.0: return "LOSS"
return "NEUTRAL"
df["cnv_class"] = df["log2"].apply(classify_cnv)
print("CNV class counts:")
print(df["cnv_class"].value_counts().to_string())
# Focal amplifications in known oncogenes
oncogenes = ["ERBB2", "MYC", "EGFR", "CCND1", "CDK6", "MDM2", "KRAS"]
focal_amps = df[(df["cnv_class"] == "AMP") &
(df["gene"].str.split(",").apply(
lambda genes: any(g in oncogenes for g in genes)))]
if not focal_amps.empty:
print(f"\nFocal amplifications in oncogenes:")
print(focal_amps[["chromosome", "start", "end", "gene", "log2", "cn"]].to_string(index=False))
Step 6: Visualize CNV Profile
Generate scatter plots and chromosome diagrams to review the copy number landscape.
# Genome-wide scatter plot (CNR bins + segments)
cnvkit.py scatter tumor.cnr \
-s tumor.cns \
-o tumor-scatter.png
# Chromosome diagram (color-coded by CN state)
cnvkit.py diagram tumor.cnr \
-s tumor.cns \
-o tumor-diagram.pdf
# Heatmap across multiple samples
cnvkit.py heatmap sample1.cns sample2.cns sample3.cns \
-o cohort_heatmap.pdf
echo "Plots saved: tumor-scatter.png, tumor-diagram.pdf, cohort_heatmap.pdf"
# Custom scatter plot with matplotlib highlighting specific genes
import cnvlib
import matplotlib.pyplot as plt
import numpy as np
cnr = cnvlib.read("tumor.cnr")
cns = cnvlib.read("tumor.cns")
# Plot chr7 (EGFR locus) in detail
fig, ax = plt.subplots(figsize=(12, 4))
chrom = "chr7"
cnr_chr = cnr.data[cnr.data["chromosome"] == chrom]
cns_chr = cns.data[cns.data["chromosome"] == chrom]
# Bin dots
ax.scatter(cnr_chr["start"], cnr_chr["log2"],
s=3, alpha=0.3, color="#aaa", label="Bins")
# Segment lines
for _, seg in cns_chr.iterrows():
ax.hlines(seg["log2"], seg["start"], seg["end"],
colors=("#d32f2f" if seg["log2"] > 0.3 else
"#1565c0" if seg["log2"] < -0.3 else "#666"),
lw=3, label="_nolegend_")
# Mark EGFR
egfr_start, egfr_end = 55_019_017, 55_211_628
ax.axvspan(egfr_start, egfr_end, alpha=0.15, color="orange")
ax.text((egfr_start + egfr_end) / 2, ax.get_ylim()[1] * 0.85,
"EGFR", ha="center", fontsize=9, color="darkorange")
ax.axhline(0, color="black", lw=0.8, ls="--")
ax.axhline(0.585, color="#d32f2f", lw=0.5, ls=":") # gain threshold
ax.axhline(-1.0, color="#1565c0", lw=0.5, ls=":") # loss threshold
ax.set_xlabel("Chromosome 7 position (bp)")
ax.set_ylabel("Log2 copy ratio")
ax.set_title(f"CNV Profile — {chrom}")
plt.tight_layout()
plt.savefig("chr7_cnv_scatter.png", dpi=150, bbox_inches="tight")
print("Saved: chr7_cnv_scatter.png")
Step 7: Estimate Tumor Purity and Ploidy
Use CNVkit's purity/ploidy estimation to interpret absolute copy numbers.
# Estimate purity and ploidy from the segmented CNV profile
cnvkit.py call tumor.cns \
--purity auto \
--ploidy 2 \
--method clonal \
-o tumor.call.auto.cns \
--center median
# Print purity/ploidy estimate embedded in header
head -5 tumor.call.auto.cns
# Python API: purity/ploidy estimation with cnvlib
import cnvlib
from cnvlib import segmetrics
cns = cnvlib.read("tumor.cns")
cnr = cnvlib.read("tumor.cnr")
# Compute segment-level statistics
cns_with_stats = segmetrics.do_segmetrics(cnr, cns,
location_stats=["mean", "median"],
spread_stats=["stdev"])
# Export stats to CSV for review
df = cns_with_stats.data
df.to_csv("tumor_segment_stats.csv", index=False)
print(f"Segment stats saved: tumor_segment_stats.csv")
print(f"Segments: {len(df)}")
print(df[["chromosome", "start", "end", "log2", "cn", "mean", "stdev"]].head(8).to_string(index=False))
Step 8: Export to VCF, BED, and SEG Formats
Export CNV calls for downstream tools (GISTIC2, cBioPortal, IGV, clinical reporting).
# Export to VCF (for clinical variant databases)
cnvkit.py export vcf tumor.call.cns \
-o tumor.cnv.vcf
# Export to SEG format (for GISTIC2 and cBioPortal)
cnvkit.py export seg tumor.call.cns \
-o tumor.seg
# Export to BED format (for bedtools/IGV)
cnvkit.py export bed tumor.call.cns \
-o tumor.cnv.bed
echo "Exported:"
echo " tumor.cnv.vcf — VCF format"
echo " tumor.seg — SEG format (GISTIC2 / cBioPortal)"
echo " tumor.cnv.bed — BED format (IGV / bedtools)"
# Parse and summarize SEG file
import pandas as pd
seg = pd.read_csv("tumor.seg", sep="\t",
names=["sample", "chrom", "start", "end", "n_probes", "log2"],
comment="#")
print(f"SEG file: {len(seg)} segments")
print(seg.head())
# Count amplifications and deletions by chromosome arm
amp_count = (seg["log2"] >= 0.585).sum()
del_count = (seg["log2"] <= -1.0).sum()
print(f"\nAmplifications (log2 >= 0.585): {amp_count}")
print(f"Deletions (log2 <= -1.0): {del_count}")
seg.to_csv("tumor_seg_annotated.csv", index=False)
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
--method (batch/coverage) |
"hybrid" |
"hybrid", "wgs", "amplicon" |
Sequencing method; selects target binning strategy |
--segment-method (segment) |
"cbs" |
"cbs", "hmm", "haar", "none" |
Segmentation algorithm; CBS requires R DNAcopy |
--ploidy (call) |
2 |
1–6 |
Assumed baseline ploidy for absolute CN calling |
--purity (call) |
1.0 |
0.1–1.0 or "auto" |
Tumor cell fraction; corrects log2 ratios for admixed normal |
--target-avg-size (target) |
200 (WES) |
50–500 bp |
Desired mean target bin size after splitting |
--antitarget-avg-size |
150000 |
10000–500000 bp |
Antitarget bin size (larger = fewer bins, less noise) |
--drop-low-coverage (segment) |
off | flag | Drop bins with depth < 5× before segmentation |
-p / --processes (coverage) |
1 |
1–CPU count |
Parallel processes for coverage calculation |
--scatter (batch) |
off | flag | Automatically generate scatter plot |
--diagram (batch) |
off | flag | Automatically generate chromosome diagram |
Common Recipes
Recipe: Tumor-Only Analysis with Flat Reference
When to use: No matched normal is available; use a flat GC/mappability-corrected reference.
# Step 1: Create flat reference
cnvkit.py reference \
--targets targets.bed \
--fasta GRCh38.fa \
--output flat_reference.cnn
# Step 2: Run batch on tumor-only
cnvkit.py batch tumor.bam \
--reference flat_reference.cnn \
--output-dir tumor_only_results/
echo "Tumor-only results: tumor_only_results/"
Recipe: WGS Mode for Whole-Genome Data
When to use: Input is whole-genome sequencing (no capture BED needed).
# WGS mode uses a uniform genome-wide bin grid
cnvkit.py batch tumor_wgs.bam \
--normal normal_wgs.bam \
--method wgs \
--fasta GRCh38.fa \
--output-dir wgs_results/ \
--scatter
echo "WGS CNV analysis complete: wgs_results/"
Recipe: Snakemake Integration for Multi-Sample Cohort
When to use: Automate CNVkit for a cohort of paired tumor/normal samples.
# Snakefile — CNVkit batch pipeline
configfile: "config.yaml"
SAMPLES = config["tumor_samples"] # list of sample names
GENOME = config["genome_fasta"]
TARGETS = config["targets_bed"]
POOLED = config["pooled_reference"] # pre-built pooled normal reference
rule all:
input:
expand("cnvkit/{sample}.call.cns", sample=SAMPLES),
expand("cnvkit/{sample}-scatter.png", sample=SAMPLES),
rule cnvkit_batch:
input:
tumor = "bam/{sample}.tumor.bam",
ref = POOLED,
output:
cnr = "cnvkit/{sample}.cnr",
cns = "cnvkit/{sample}.cns",
call = "cnvkit/{sample}.call.cns",
scatter = "cnvkit/{sample}-scatter.png",
threads: 4
shell:
"""
cnvkit.py batch {input.tumor} \
--reference {input.ref} \
--targets {TARGETS} \
--fasta {GENOME} \
--output-dir cnvkit/ \
--scatter --processes {threads}
cnvkit.py call cnvkit/{wildcards.sample}.cns \
--ploidy 2 -o {output.call}
"""
Recipe: Export Gene-Level CNV Table
When to use: Summarize copy number status for a panel of cancer genes from a called CNS file.
import cnvlib
import pandas as pd
cns = cnvlib.read("tumor.call.cns")
df = cns.data
# Cancer genes of interest
cancer_genes = ["ERBB2", "MYC", "EGFR", "CDKN2A", "RB1", "TP53",
"KRAS", "PTEN", "BRCA1", "BRCA2", "MDM2", "CDK4"]
rows = []
for gene in cancer_genes:
hits = df[df["gene"].str.contains(gene, na=False)]
if hits.empty:
rows.append({"gene": gene, "log2_mean": float("nan"), "cn": "n/a", "status": "not_detected"})
else:
best = hits.loc[hits["log2"].abs().idxmax()]
status = ("AMP" if best["log2"] >= 1.0 else
"GAIN" if best["log2"] >= 0.2 else
"HOMDEL" if best["log2"] <= -3.5 else
"LOSS" if best["log2"] <= -1.0 else "NEUTRAL")
rows.append({"gene": gene, "log2_mean": round(best["log2"], 3),
"cn": best.get("cn", "?"), "status": status})
result_df = pd.DataFrame(rows)
print(result_df.to_string(index=False))
result_df.to_csv("cancer_gene_cnv_summary.csv", index=False)
print("\nSaved: cancer_gene_cnv_summary.csv")
Expected Outputs
| Output File | Format | Description |
|---|---|---|
tumor.targetcoverage.cnn |
CNN | Per-bin target region read depth and log2 coverage |
tumor.antitargetcoverage.cnn |
CNN | Per-bin antitarget region coverage (off-target reads) |
tumor.cnr |
CNR | Normalized log2 copy ratio per bin (GC/depth corrected) |
tumor.cns |
CNS | Segmented copy ratios (CBS/HMM output); one row per segment |
tumor.call.cns |
CNS | Called copy number states with integer CN and purity adjustment |
tumor-scatter.png |
PNG | Genome-wide scatter plot of bins + segment overlays |
tumor-diagram.pdf |
Chromosome arm diagram color-coded by CN state | |
tumor.seg |
SEG | GISTIC2/cBioPortal-format segment file |
tumor.cnv.vcf |
VCF | VCF-format CNV calls for clinical databases |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| High noise in antitarget bins | Low off-target coverage (<0.1× mean) | Increase --antitarget-avg-size to 500kb; use --drop-low-coverage to exclude low bins before segmentation |
CBS segmentation fails with Error in DNAcopy |
R DNAcopy not installed or incompatible | Install via BiocManager::install("DNAcopy"); alternatively use --method hmm (no R required) |
| All segments near 0 (no CNVs detected) | Low tumor purity (<20%) or shallow coverage | Verify purity with cnvkit.py call --purity auto; check target coverage depth with cnvkit.py coverage |
| Wavy GC bias across chromosomes | GC normalization failed or flat reference used with biased tumor | Rebuild reference with matched normal BAMs; check cnvkit.py reference includes --fasta for GC correction |
| Many very short segments (over-segmentation) | CBS threshold too sensitive | Add --threshold 0.2 or --smooth-cbs to reduce false segment boundaries; increase minimum segment size |
ImportError: No module named cnvlib |
CNVkit not installed in active environment | Activate the correct conda env: conda activate cnvkit_env; verify cnvkit.py version |
| Chromosome naming mismatch | BAM uses 1 but reference uses chr1 or vice versa |
Ensure BAM, BED, and FASTA all use the same chromosome naming convention |
References
- CNVkit documentation — full CLI and API reference, algorithm details, and tutorials
- Talevich E et al. (2016) PLoS Comput Biol 12(4): e1004873 — CNVkit original paper with algorithm description and benchmarks
- CNVkit GitHub: etal/cnvkit — source code, issue tracker, and example datasets
- UCSC Genome Browser data files — refFlat and access BED files used in CNVkit preprocessing
skills/genomics-bioinformatics/variant/gatk-variant-calling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gatk-variant-calling -g -y
SKILL.md
Frontmatter
{
"name": "gatk-variant-calling",
"license": "BSD-3-Clause",
"description": "GATK Best Practices for germline SNP\/indel calling from WGS\/WES BAMs. Per-sample HaplotypeCaller GVCFs, GenomicsDBImport, GenotypeGVCFs joint calling, VQSR or hard filters. Requires BWA-MEM2-aligned, markdup, BQSR BAMs. Use DeepVariant for a faster DL alternative; GATK is the NIH\/ENCODE standard."
}
GATK — Germline Variant Calling Pipeline
Overview
GATK (Genome Analysis Toolkit) implements the GATK Best Practices workflow for calling SNPs and indels from Illumina WGS and WES data. The pipeline runs HaplotypeCaller per sample (producing GVCF files), consolidates GVCFs with GenomicsDBImport, performs joint genotyping with GenotypeGVCFs, and filters variants with VQSR (Variant Quality Score Recalibration) or hard filters. GATK requires BWA-MEM2-aligned, duplicate-marked, and base quality score recalibrated (BQSR) BAM files as input. It integrates with Picard tools, samtools, and bcftools for pre- and post-processing. The GATK4 workflow is the NIH/ENCODE standard for germline variant calling in research and clinical genomics.
When to Use
- Calling germline SNPs and indels from WGS or WES samples for population genetics or clinical variant analysis
- Running joint genotyping across multiple samples for cohort-scale studies (families, case-control)
- Applying base quality score recalibration (BQSR) to improve variant calling accuracy before HaplotypeCaller
- Generating GVCF files for scalable cohort expansion: add new samples without reprocessing existing ones
- Producing variant call sets for downstream annotation with Ensembl VEP, ANNOVAR, or SnpEff
- Use DeepVariant (Google) instead for a faster deep-learning approach with comparable accuracy
- Use bcftools call instead for rapid variant calling without assembly-based local realignment
Prerequisites
- Software: GATK4, Java 17+, samtools, BWA-MEM2
- Reference files: genome FASTA + known variants VCF (dbSNP, 1000G, Mills indels)
- Input: duplicate-marked, sorted BAM with
@RGread group headers (from BWA-MEM2)
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v gatkfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run gatkrather than baregatk.
# Install GATK4
wget https://github.com/broadinstitute/gatk/releases/download/4.6.0.0/gatk-4.6.0.0.zip
unzip gatk-4.6.0.0.zip
export GATK="$PWD/gatk-4.6.0.0/gatk"
# Or with conda
conda install -c bioconda gatk4
# Verify
gatk --version
# GATK v4.6.0.0
# Download GATK resource bundle files (GRCh38)
# From gs://gcp-public-data--broad-references/hg38/v0/ (requires gsutil or Broad FTP)
Quick Start
GENOME="GRCh38.fa"
DBSNP="dbsnp_146.hg38.vcf.gz"
# Run HaplotypeCaller in GVCF mode
gatk HaplotypeCaller \
-R $GENOME \
-I sample1.markdup.bam \
-O sample1.g.vcf.gz \
-ERC GVCF \
--dbsnp $DBSNP \
--native-pair-hmm-threads 4
echo "GVCF: sample1.g.vcf.gz"
Workflow
Step 1: Base Quality Score Recalibration (BQSR)
Correct systematic errors in base quality scores before variant calling.
GENOME="GRCh38.fa"
KNOWN_SITES="dbsnp_146.hg38.vcf.gz Mills_and_1000G_gold_standard.indels.hg38.vcf.gz"
KNOWN_FLAGS=$(printf -- '--known-sites %s ' $KNOWN_SITES)
# Step 1a: Build recalibration table
gatk BaseRecalibrator \
-R $GENOME \
-I sample1.markdup.bam \
$KNOWN_FLAGS \
-O sample1.recal.table
# Step 1b: Apply recalibration
gatk ApplyBQSR \
-R $GENOME \
-I sample1.markdup.bam \
--bqsr-recal-file sample1.recal.table \
-O sample1.bqsr.bam
echo "BQSR BAM: sample1.bqsr.bam"
samtools flagstat sample1.bqsr.bam | head -3
Step 2: Call Variants with HaplotypeCaller (GVCF Mode)
Run per-sample variant calling, producing an intermediate GVCF for joint genotyping.
# HaplotypeCaller in GVCF mode (recommended for cohort analysis)
gatk HaplotypeCaller \
-R GRCh38.fa \
-I sample1.bqsr.bam \
-O gvcfs/sample1.g.vcf.gz \
-ERC GVCF \
--dbsnp dbsnp_146.hg38.vcf.gz \
--native-pair-hmm-threads 4
# For WES: specify target intervals
# gatk HaplotypeCaller ... -L exome_targets.interval_list --interval-padding 100
echo "GVCF: gvcfs/sample1.g.vcf.gz"
zcat gvcfs/sample1.g.vcf.gz | grep -v "^#" | wc -l
Step 3: Consolidate GVCFs with GenomicsDBImport
Merge per-sample GVCFs for efficient joint genotyping.
# Create sample map file: sample_name\tpath_to_gvcf
printf "ctrl_1\tgvcfs/ctrl_1.g.vcf.gz\n" > sample_map.txt
printf "ctrl_2\tgvcfs/ctrl_2.g.vcf.gz\n" >> sample_map.txt
printf "treat_1\tgvcfs/treat_1.g.vcf.gz\n" >> sample_map.txt
printf "treat_2\tgvcfs/treat_2.g.vcf.gz\n" >> sample_map.txt
# Import GVCFs into GenomicsDB for each chromosome
for CHR in chr1 chr2 chr3; do
gatk GenomicsDBImport \
--sample-name-map sample_map.txt \
--genomicsdb-workspace-path genomicsdb/${CHR} \
-L $CHR \
--reader-threads 4
done
echo "GenomicsDB created for $(ls genomicsdb/ | wc -l) chromosomes"
Step 4: Joint Genotyping with GenotypeGVCFs
Genotype all samples simultaneously across the GenomicsDB.
# Joint genotype all samples
mkdir -p vcfs
for CHR in chr1 chr2 chr3; do
gatk GenotypeGVCFs \
-R GRCh38.fa \
-V gendb://genomicsdb/${CHR} \
--dbsnp dbsnp_146.hg38.vcf.gz \
-O vcfs/cohort_${CHR}.vcf.gz
done
# Merge per-chromosome VCFs
gatk MergeVcfs \
$(ls vcfs/cohort_chr*.vcf.gz | sed 's/^/-I /') \
-O vcfs/cohort_all.vcf.gz
echo "Joint genotyping complete: vcfs/cohort_all.vcf.gz"
gatk CountVariants -V vcfs/cohort_all.vcf.gz
Step 5: Variant Filtration (Hard Filters)
Apply hard filters for small cohorts where VQSR is underpowered.
# Separate SNPs and indels
gatk SelectVariants -V vcfs/cohort_all.vcf.gz --select-type-to-include SNP -O vcfs/snps.vcf.gz
gatk SelectVariants -V vcfs/cohort_all.vcf.gz --select-type-to-include INDEL -O vcfs/indels.vcf.gz
# Apply hard filters: SNPs
gatk VariantFiltration \
-V vcfs/snps.vcf.gz \
--filter-expression "QD < 2.0" --filter-name "QD2" \
--filter-expression "FS > 60.0" --filter-name "FS60" \
--filter-expression "MQ < 40.0" --filter-name "MQ40" \
--filter-expression "MQRankSum < -12.5" --filter-name "MQRankSum-12.5" \
-O vcfs/snps_filtered.vcf.gz
# Apply hard filters: Indels
gatk VariantFiltration \
-V vcfs/indels.vcf.gz \
--filter-expression "QD < 2.0" --filter-name "QD2" \
--filter-expression "FS > 200.0" --filter-name "FS200" \
--filter-expression "ReadPosRankSum < -20.0" --filter-name "ReadPosRankSum-20" \
-O vcfs/indels_filtered.vcf.gz
# Merge filtered SNPs + indels
gatk MergeVcfs -I vcfs/snps_filtered.vcf.gz -I vcfs/indels_filtered.vcf.gz \
-O vcfs/cohort_filtered.vcf.gz
echo "PASS variants: $(bcftools view -f PASS vcfs/cohort_filtered.vcf.gz | grep -v '^#' | wc -l)"
Step 6: Parse VCF Results with Python
Extract variants, annotate with gene info, and prepare a DataFrame.
import subprocess
import pandas as pd
import io
# Use bcftools query to extract fields from filtered VCF
result = subprocess.run(
["bcftools", "query",
"-f", "%CHROM\t%POS\t%ID\t%REF\t%ALT\t%QUAL\t%FILTER\t%INFO/QD\t%INFO/FS\n",
"-i", "FILTER='PASS'",
"vcfs/cohort_filtered.vcf.gz"],
capture_output=True, text=True
)
cols = ["CHR", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "QD", "FS"]
df = pd.read_csv(io.StringIO(result.stdout), sep="\t", names=cols)
df["QUAL"] = pd.to_numeric(df["QUAL"], errors="coerce")
df["QD"] = pd.to_numeric(df["QD"], errors="coerce")
print(f"PASS variants: {len(df)}")
print(f"SNPs: {(df['REF'].str.len() == 1) & (df['ALT'].str.len() == 1)).sum()}")
print(f"Indels: {((df['REF'].str.len() > 1) | (df['ALT'].str.len() > 1)).sum()}")
print(df.head())
df.to_csv("pass_variants.tsv", sep="\t", index=False)
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-ERC |
NONE |
GVCF, BP_RESOLUTION |
Emit reference confidence mode; GVCF for cohort workflows |
--native-pair-hmm-threads |
4 |
1–32 | Threads for pair-HMM in HaplotypeCaller (most CPU-intensive step) |
-L |
whole genome | interval file or chr | Restrict calling to intervals (exome targets, BED regions) |
--dbsnp |
— | VCF path | dbSNP VCF for rsID annotation in output |
--stand-call-conf |
30 |
0–100 | Min genotype quality score to emit a variant call |
-G |
StandardAnnotation |
annotation group | Annotation modules to apply (StandardHCAnnotation for HaplotypeCaller) |
--sample-name-map |
— | TSV file | Sample-to-GVCF mapping for GenomicsDBImport |
--reader-threads |
1 |
1–16 | Threads for GenomicsDBImport reading |
--filter-expression |
— | JEXL expression | Hard filter expression (e.g., "QD < 2.0") |
--java-options |
— | -Xmx4g |
Java heap size; use -Xmx16g for large genomes |
Common Recipes
Recipe 1: Single-Sample Variant Calling (No Cohort)
# For a single sample, skip GenomicsDBImport and call directly
gatk HaplotypeCaller \
-R GRCh38.fa \
-I sample.bqsr.bam \
-O sample.vcf.gz \
--dbsnp dbsnp_146.hg38.vcf.gz \
--native-pair-hmm-threads 8
# Hard filter the single-sample VCF
gatk VariantFiltration \
-V sample.vcf.gz \
--filter-expression "QD < 2.0" --filter-name "QD2" \
--filter-expression "FS > 60.0" --filter-name "FS60" \
-O sample_filtered.vcf.gz
echo "PASS variants: $(bcftools view -f PASS sample_filtered.vcf.gz | grep -v '^#' | wc -l)"
Recipe 2: Run Full Pipeline with Snakemake
# Snakefile — GATK BQSR + HaplotypeCaller
configfile: "config.yaml"
SAMPLES = config["samples"]
GENOME = config["genome"]
DBSNP = config["dbsnp"]
KNOWN = config["known_sites"]
rule all:
input: expand("gvcfs/{sample}.g.vcf.gz", sample=SAMPLES)
rule bqsr:
input: bam="markdup/{sample}.markdup.bam"
output: bam="bqsr/{sample}.bqsr.bam"
shell:
"""
gatk BaseRecalibrator -R {GENOME} -I {input.bam} \
--known-sites {KNOWN} -O bqsr/{wildcards.sample}.recal.table &&
gatk ApplyBQSR -R {GENOME} -I {input.bam} \
--bqsr-recal-file bqsr/{wildcards.sample}.recal.table \
-O {output.bam}
"""
rule haplotypecaller:
input: bam="bqsr/{sample}.bqsr.bam"
output: gvcf="gvcfs/{sample}.g.vcf.gz"
threads: 4
shell:
"gatk HaplotypeCaller -R {GENOME} -I {input.bam} -O {output.gvcf} "
"-ERC GVCF --dbsnp {DBSNP} --native-pair-hmm-threads {threads}"
Expected Outputs
| Output | Format | Description |
|---|---|---|
*.g.vcf.gz |
GVCF | Per-sample GVCF with reference confidence blocks; input to GenomicsDBImport |
cohort_all.vcf.gz |
VCF | Joint-genotyped multi-sample VCF; unfiltered |
cohort_filtered.vcf.gz |
VCF | Filtered VCF; FILTER=PASS for passing variants |
*.recal.table |
BQSR | Base quality recalibration table from BaseRecalibrator |
*.bqsr.bam |
BAM | Recalibrated BAM; use as HaplotypeCaller input |
genomicsdb/ |
Directory | GenomicsDB workspace per chromosome for joint genotyping |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
SAM/BAM file has no @RG header |
Missing read group from BWA-MEM2 | Re-align with -R "@RG\tID:...\tSM:...\tPL:ILLUMINA" |
| Java OutOfMemoryError | Insufficient heap size | Add --java-options "-Xmx16g" or more |
| HaplotypeCaller very slow | Single-threaded HMM | Add --native-pair-hmm-threads 8; use interval lists to parallelize by chr |
| Empty GVCF output | Wrong interval or no reads in region | Check samtools view -c sample.bam chrN for read counts |
| VQSR fails (< 10k variants) | Too few variants for training | Use hard filters instead of VQSR for small cohorts or exomes |
| GenomicsDB import fails on existing path | GenomicsDB workspace already exists | Delete existing workspace: rm -rf genomicsdb/chr1 before re-running |
IndexOutOfBoundsException |
Chromosome name mismatch between BAM and reference | Ensure genome FASTA and BAM use same chr naming (chr1 vs 1) |
| BCFtools/tabix index missing | Tabix index (.tbi) not created | Run gatk IndexFeatureFile -I file.vcf.gz or tabix -p vcf file.vcf.gz |
References
- GATK documentation — official pipeline guides and tool documentation
- GATK Best Practices for germline short variants — step-by-step protocol
- GATK GitHub: broadinstitute/gatk — source code, releases, and resource bundle
- Van der Auwera GA & O'Connor BD (2020) Genomics in the Cloud — O'Reilly Media; comprehensive GATK4 guide
skills/genomics-bioinformatics/variant/plink2-gwas-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill plink2-gwas-analysis -g -y
SKILL.md
Frontmatter
{
"name": "plink2-gwas-analysis",
"license": "GPL-3.0",
"description": "GWAS and population genetics tool. Processes PLINK (.bed\/.bim\/.fam), VCF, and BGEN; runs QC (MAF, HWE, missingness), IBD estimation, PCA, and linear\/logistic regression GWAS. Outputs Manhattan-ready summary stats. Use regenie or SAIGE for biobanks (>100k samples) needing mixed models."
}
PLINK2 — GWAS and Population Genetics
Overview
PLINK2 is the high-performance successor to PLINK 1.9, designed for genome-wide association studies (GWAS) and population genetics analysis on large cohorts. It processes genotype data in PLINK binary format (.bed/.bim/.fam), VCF, and BGEN formats — performing sample and variant quality control (QC), kinship estimation, principal component analysis (PCA), and linear/logistic regression association testing. PLINK2 is 10–100× faster than PLINK 1.9 on most tasks due to multithreading and optimized I/O. Output files are compatible with downstream visualization (Manhattan/QQ plots) and meta-analysis tools.
When to Use
- Running GWAS on a case-control or quantitative trait cohort after genotyping array QC
- Performing sample QC: missingness, heterozygosity outliers, sex check, cryptic relatedness
- Computing genome-wide LD pruning for PCA or relatedness estimation
- Running PCA on genotype data to identify population stratification
- Converting between PLINK binary, VCF, and BGEN formats
- Filtering variants by MAF, HWE, missingness, or INFO score in VCF/imputed data
- Use regenie or SAIGE instead for biobank-scale GWAS (>100k samples) requiring mixed model association to control for population structure
- Use VCFtools as an alternative for VCF-specific population genetics statistics
Prerequisites
- Software: PLINK2 (pre-compiled binary; no pip/conda package)
- Input: PLINK binary files (.bed/.bim/.fam) or VCF/BGEN from array genotyping or imputation
Check before installing: The tool may already be available (e.g., inside a
pixi/condaenv). Always runcommand -v plink2first and skip the install block if it returns a path. When executing tools inside a pixi project, preferpixi run <tool>over plain<tool>.
# Skip install if already present
if command -v plink2 >/dev/null 2>&1; then
echo "plink2 already installed: $(plink2 --version)"
else
# Download PLINK2 pre-compiled binary (Linux)
wget https://s3.amazonaws.com/plink2-assets/alpha6/plink2_linux_avx2_20241112.zip
unzip plink2_linux_avx2_20241112.zip
chmod +x plink2
export PATH="$PWD:$PATH"
# macOS
# wget https://s3.amazonaws.com/plink2-assets/alpha6/plink2_mac_20241112.zip
# unzip plink2_mac_20241112.zip
plink2 --version
# PLINK v2.00a6LM
fi
# Python for downstream analysis
pip install pandas numpy matplotlib scipy
Quick Start
# Run GWAS: linear regression for quantitative trait
plink2 \
--bfile cohort_qc \
--pheno phenotypes.txt \
--covar covariates.txt \
--linear hide-covar \
--out results/gwas_result \
--threads 8
# View top hits
head results/gwas_result.*.glm.linear | sort -k12,12g | head -20
Workflow
Step 1: Convert VCF/Imputed Data to PLINK Binary Format
Convert input genotype data to PLINK binary format for fast processing.
# Convert VCF to PLINK binary
plink2 \
--vcf cohort_genotyped.vcf.gz \
--make-bed \
--out cohort_plink \
--threads 8
# Convert BGEN (imputed data from Michigan/TopMed imputation server)
plink2 \
--bgen cohort_imputed.bgen ref-first \
--sample cohort_imputed.sample \
--make-bed \
--out cohort_imputed_plink \
--threads 8
echo "Files created:"
ls -lh cohort_plink.{bed,bim,fam}
echo "Samples: $(wc -l < cohort_plink.fam)"
echo "Variants: $(wc -l < cohort_plink.bim)"
Step 2: Sample QC — Missingness and Heterozygosity
Remove samples with high missingness or heterozygosity outliers.
# Compute per-sample and per-variant missingness
plink2 \
--bfile cohort_plink \
--missing \
--out qc/sample_missingness \
--threads 8
# Remove samples with > 2% missingness and variants with > 5% missing
plink2 \
--bfile cohort_plink \
--mind 0.02 \
--geno 0.05 \
--make-bed \
--out cohort_sample_qc \
--threads 8
echo "After sample QC:"
echo "Samples: $(wc -l < cohort_sample_qc.fam)"
echo "Variants: $(wc -l < cohort_sample_qc.bim)"
Step 3: Variant QC — MAF, HWE, and INFO Score Filtering
Filter variants by minor allele frequency, Hardy-Weinberg equilibrium, and imputation quality.
# Variant QC: MAF, HWE, missingness
plink2 \
--bfile cohort_sample_qc \
--maf 0.01 \
--hwe 1e-6 \
--geno 0.05 \
--make-bed \
--out cohort_variantqc \
--threads 8
echo "After variant QC:"
echo "Variants remaining: $(wc -l < cohort_variantqc.bim)"
# For imputed data: also filter by INFO score (using VCF INFO field)
# plink2 --bfile cohort_variantqc --var-min-qual 0.8 --make-bed --out cohort_info_qc
Step 4: LD Pruning and PCA for Population Stratification
Compute principal components from LD-pruned variants.
# LD pruning: remove one variant from each pair with r² > 0.2
plink2 \
--bfile cohort_variantqc \
--indep-pairwise 50 5 0.2 \
--out qc/ld_pruned \
--threads 8
# Compute PCA on LD-pruned variants (top 20 PCs)
plink2 \
--bfile cohort_variantqc \
--extract qc/ld_pruned.prune.in \
--pca 20 \
--out pca/cohort_pca \
--threads 8
echo "PCA files: pca/cohort_pca.eigenvec (PCs) and pca/cohort_pca.eigenval (variance)"
head pca/cohort_pca.eigenvec
Step 5: Run GWAS Association Analysis
Perform logistic regression (case-control) or linear regression (quantitative trait).
# Case-control GWAS: logistic regression with covariates
plink2 \
--bfile cohort_variantqc \
--pheno phenotypes.txt \
--1 \
--covar covariates.txt \
--covar-variance-standardize \
--logistic hide-covar \
--ci 0.95 \
--out results/gwas_cc \
--threads 8
# Quantitative trait GWAS: linear regression
plink2 \
--bfile cohort_variantqc \
--pheno phenotypes.txt \
--covar covariates.txt \
--covar-variance-standardize \
--linear hide-covar \
--ci 0.95 \
--out results/gwas_qt \
--threads 8
echo "GWAS complete. Files: results/gwas_*.glm.*"
wc -l results/gwas_qt.*.glm.linear
Step 6: Plot Manhattan and QQ Plots
Visualize GWAS results with Python.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import chi2
# Load GWAS results (PLINK2 linear output)
df = pd.read_csv("results/gwas_qt.PHENO1.glm.linear", sep="\t",
usecols=["#CHROM", "POS", "ID", "P"])
df.columns = ["CHR", "POS", "SNP", "P"]
df = df.dropna(subset=["P"])
df["P"] = pd.to_numeric(df["P"], errors="coerce")
df = df[df["P"] > 0].copy()
df["-log10P"] = -np.log10(df["P"])
print(f"Variants: {len(df)}")
print(f"Genome-wide significant (p<5e-8): {(df['P'] < 5e-8).sum()}")
# Manhattan plot
fig, ax = plt.subplots(figsize=(14, 4))
colors = plt.cm.Set1.colors
chrom_pos = 0
ticks = []
for chrom, group in df.groupby("CHR", sort=False):
col = colors[int(chrom) % 2] if str(chrom).isdigit() else "gray"
ax.scatter(group["POS"] + chrom_pos, group["-log10P"],
c=[col], s=2, alpha=0.7)
ticks.append((chrom_pos + group["POS"].mean(), str(chrom)))
chrom_pos += group["POS"].max() + 1e7
ax.axhline(-np.log10(5e-8), color="red", linestyle="--", lw=1, label="p=5×10⁻⁸")
ax.set_xticks([t[0] for t in ticks[::2]])
ax.set_xticklabels([t[1] for t in ticks[::2]], fontsize=6)
ax.set_xlabel("Chromosome")
ax.set_ylabel("-log₁₀(p)")
ax.set_title("GWAS Manhattan Plot")
plt.tight_layout()
plt.savefig("manhattan.png", dpi=150)
print("Saved: manhattan.png")
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
--maf |
— | 0–0.5 | Minor allele frequency threshold; 0.01 removes singletons |
--hwe |
— | 0–1 | Hardy-Weinberg equilibrium p-value threshold; 1e-6 typical |
--geno |
— | 0–1 | Maximum variant missingness; 0.05 = 5% |
--mind |
— | 0–1 | Maximum sample missingness; 0.02 = 2% |
--linear / --logistic |
— | flag | Regression mode: linear for quantitative, logistic for case-control |
--covar |
— | file path | Covariate file (FID IID COV1 COV2...); add PCs here |
--pca |
— | integer | Number of PCs to compute from LD-pruned data |
--indep-pairwise |
— | window step r² | LD pruning: window size (variants), step, r² threshold |
--threads |
1 |
1–64 | CPU threads; 8–16 typical for cluster |
--ci |
— | 0–1 | Confidence interval for effect size output (0.95 for 95% CI) |
--1 |
off | flag | Recode phenotype: 1=control, 2=case → 0=control, 1=case (logistic) |
Common Recipes
Recipe 1: Relatedness QC and Remove One from Each Related Pair
# Compute kinship coefficients (King-robust estimator)
plink2 \
--bfile cohort_variantqc \
--extract qc/ld_pruned.prune.in \
--king-cutoff 0.0625 \
--out qc/kinship \
--threads 8
# Remove related individuals (king-cutoff auto-generates exclusion list)
plink2 \
--bfile cohort_variantqc \
--remove qc/kinship.king.cutoff.out.id \
--make-bed \
--out cohort_unrelated \
--threads 8
echo "After relatedness QC: $(wc -l < cohort_unrelated.fam) samples"
Recipe 2: Parse GWAS Results and Report Top Hits
import pandas as pd
import numpy as np
def load_gwas_results(filepath: str, p_threshold: float = 5e-8) -> pd.DataFrame:
"""Load PLINK2 GWAS linear/logistic output and return genome-wide significant hits."""
df = pd.read_csv(filepath, sep="\t",
usecols=["#CHROM", "POS", "ID", "REF", "ALT", "A1", "BETA", "SE", "P"])
df.columns = ["CHR", "POS", "SNP", "REF", "ALT", "A1", "BETA", "SE", "P"]
df = df.dropna(subset=["P"])
df["P"] = pd.to_numeric(df["P"], errors="coerce")
sig = df[df["P"] < p_threshold].sort_values("P")
return sig
# Load results for one phenotype
hits = load_gwas_results("results/gwas_qt.PHENO1.glm.linear")
print(f"Genome-wide significant loci: {len(hits)}")
print(hits.head(10)[["CHR", "POS", "SNP", "BETA", "SE", "P"]])
hits.to_csv("gwas_top_hits.tsv", sep="\t", index=False)
Expected Outputs
| Output | Format | Description |
|---|---|---|
*.glm.linear |
TSV | Linear GWAS results: CHR, POS, ID, BETA, SE, P per variant |
*.glm.logistic.hybrid |
TSV | Logistic GWAS results: OR, 95% CI, P per variant |
*.eigenvec |
TSV | PCA loadings: FID IID PC1 PC2 ... PC20 |
*.eigenval |
Text | Eigenvalues for PCA variance explained |
*.king.cutoff.out.id |
TSV | Sample IDs to remove for relatedness QC |
*.bed/.bim/.fam |
Binary | PLINK binary format after QC filtering |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error: No samples left after --mind filter |
Missingness threshold too strict | Relax to --mind 0.05; check input data quality |
| Logistic regression fails to converge | Rare variant, few cases, or collinear covariates | Use --logistic firth for Firth regression; remove correlated covariates |
| PCA shows clear outlier cluster | Admixed population or sample contamination | Remove outliers using PC cutoffs; check ancestry with 1000 Genomes reference panel |
--hwe removes too many variants |
Population structure inflating HWE test | Apply HWE filter to controls only: --hwe 1e-6 ctrl-only |
| BGEN import fails | Wrong ref-first/ref-last flag | Check imputation server documentation; try --bgen file.bgen ref-last |
| Very slow association test | Too many covariates or large file | Pre-filter to GWAS-significant window; use --threads 16 |
| Sex mismatch warnings | X chromosome heterozygosity outside sex-specific thresholds | Run --check-sex and remove F-statistic outliers |
| Memory error on large dataset | RAM insufficient for 500k+ variants | Use --memory 32000 to cap RAM; split by chromosome |
References
- PLINK2 documentation — official command reference and tutorials
- PLINK2 GitHub: chrchang/plink-ng — source code and releases
- Chang CC et al. (2015) "Second-generation PLINK: rising to the challenge of larger and richer datasets" — GigaScience 4:7. DOI:10.1186/s13742-015-0047-8
- GWAS best practices (Marees et al. 2018) — comprehensive QC and analysis guide
skills/genomics-bioinformatics/variant/snpeff-variant-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill snpeff-variant-annotation -g -y
SKILL.md
Frontmatter
{
"name": "snpeff-variant-annotation",
"license": "MIT",
"description": "Annotate and filter VCF variants with SnpEff and SnpSift. SnpEff predicts functional effects (HIGH\/MODERATE\/LOW\/MODIFIER), genes, transcripts, AA changes, HGVS; SnpSift filters and adds ClinVar\/dbSNP. Java CLI with Python subprocess integration. Use ANNOVAR for multi-database annotation; Ensembl VEP for REST API; SnpEff for fast CLI with pre-built genomes."
}
SnpEff + SnpSift — Variant Annotation and Filtering
Overview
SnpEff annotates variants in VCF files by predicting their functional consequences: impact level (HIGH, MODERATE, LOW, MODIFIER), affected gene and transcript, amino acid change, and HGVS notation. SnpSift is the companion tool for filtering, sorting, and enriching annotated VCFs with external databases such as ClinVar and dbSNP. Together they form a fast, self-contained pipeline for going from raw variant calls to biologically interpretable, filtered variant sets. Both tools are Java-based and are invoked from the command line or Python subprocess; pre-built genome databases (hg38, GRCh37, mm10, and 100+ others) are downloaded with a single command.
When to Use
- Annotating VCF files from GATK, DeepVariant, bcftools, or other callers with predicted gene-level functional consequences before manual review or downstream filtering
- Prioritizing clinically relevant variants by filtering to HIGH-impact stop-gain, frameshift, and splice-site variants for rare disease or cancer gene panel analysis
- Adding ClinVar pathogenicity classifications and dbSNP rsIDs to a variant set for cross-study comparison or clinical reporting
- Extracting structured, tab-delimited fields (gene, protein change, AF, ClinSig) from annotated VCFs into pandas DataFrames for statistical analysis
- Identifying candidate de novo variants in trio analysis by combining allele frequency thresholds, impact filters, and parent VCF exclusion
- Use ANNOVAR instead when comprehensive annotation from multiple databases (gnomAD, CADD, SpliceAI) in a single run is required
- Use Ensembl VEP instead when REST API access or VEP-specific plugins (CADD, LOFTEE, SpliceRegion) are needed
Prerequisites
- Java: Java 11+ (required for SnpEff 5.x)
- SnpEff JAR: downloaded from the SnpEff releases page or via conda/bioconda
- Python packages (optional):
cyvcf2,pandas,matplotlib,seabornfor Python-side parsing and visualization - Reference genome database: downloaded once per assembly (e.g.,
hg38,GRCh37,mm10)
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v snpEfffirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run snpEffrather than baresnpEff.
# Download SnpEff JAR
wget https://snpeff.blob.core.windows.net/versions/snpEff_latest_core.zip
unzip snpEff_latest_core.zip
# JAR is at snpEff/snpEff.jar and snpEff/SnpSift.jar
# Or via conda (recommended for reproducibility)
conda install -c bioconda snpeff
# Verify
java -jar snpEff/snpEff.jar -version
# SnpEff 5.2a (build 2024-02-06)
# Install Python packages for downstream parsing
pip install cyvcf2 pandas matplotlib seaborn
Quick Start
# 1. Download the hg38 genome database (one-time setup, ~1 GB)
java -jar snpEff/snpEff.jar download hg38
# 2. Annotate variants
java -jar snpEff/snpEff.jar \
-v hg38 \
input.vcf.gz \
> annotated.vcf
# 3. Filter to HIGH-impact variants
java -jar snpEff/SnpSift.jar filter \
"ANN[*].IMPACT = 'HIGH'" \
annotated.vcf \
> high_impact.vcf
echo "Done. Check snpEff_summary.html and snpEff_genes.txt for QC stats."
Workflow
Step 1: Install SnpEff and Download Genome Database
Download and verify the pre-built genome database for your target assembly. Databases include transcript models from Ensembl or UCSC and are built into SnpEff's local cache directory (~/.snpEff/data/ or snpEff/data/).
# List all available databases (grep for your assembly)
java -jar snpEff/snpEff.jar databases | grep -i "GRCh38\|hg38"
# Download hg38 (Homo sapiens, GRCh38)
java -jar snpEff/snpEff.jar download hg38
# Download GRCh37 (older assemblies still widely used in clinical pipelines)
java -jar snpEff/snpEff.jar download GRCh37.75
# Download mouse mm10
java -jar snpEff/snpEff.jar download mm10
# List installed databases
ls ~/.snpEff/data/
Step 2: Annotate VCF with Functional Effects
Run SnpEff annotation to add ANN INFO fields to each variant. The ANN field encodes pipe-separated annotations per transcript: Allele|Effect|Impact|Gene|GeneID|Feature|FeatureID|BioType|Rank|HGVS.c|HGVS.p|cDNA_pos|CDS_pos|Protein_pos|Distance|Errors.
# Annotate a gzipped VCF (outputs to stdout, pipe or redirect)
java -Xmx8g -jar snpEff/snpEff.jar \
-v \
-stats snpeff_summary.html \
hg38 \
input.vcf.gz \
> annotated.vcf
# Compress and index the output for downstream tools
bgzip annotated.vcf
tabix -p vcf annotated.vcf.gz
echo "Annotated variants in annotated.vcf.gz"
echo "QC report: snpeff_summary.html"
echo "Gene table: snpEff_genes.txt"
Step 3: Filter HIGH-Impact Variants with SnpSift
SnpSift filter evaluates boolean expressions over INFO and FORMAT fields. The ANN[*] syntax iterates over all transcript annotations for each variant — any matching transcript qualifies the variant.
# Filter for HIGH-impact variants only (stop-gain, frameshift, splice-site)
java -jar snpEff/SnpSift.jar filter \
"ANN[*].IMPACT = 'HIGH'" \
annotated.vcf.gz \
> high_impact.vcf
# Filter HIGH or MODERATE impact and allele frequency < 1%
# (requires AF in INFO field, e.g., from GATK or gnomAD annotation)
java -jar snpEff/SnpSift.jar filter \
"(ANN[*].IMPACT = 'HIGH' | ANN[*].IMPACT = 'MODERATE') & (AF < 0.01)" \
annotated.vcf.gz \
> rare_functional.vcf
# Count filtered variants
grep -v "^#" high_impact.vcf | wc -l
Step 4: Add ClinVar and dbSNP Annotations
SnpSift annotate transfers INFO fields from a reference VCF (ClinVar, dbSNP) to the target VCF by matching on chromosome and position. Download ClinVar and dbSNP VCFs from NCBI FTP before running.
# Download reference databases (one-time setup)
# ClinVar (GRCh38)
wget https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz
wget https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz.tbi
# dbSNP (GRCh38, b156 or later)
wget https://ftp.ncbi.nlm.nih.gov/snp/latest_release/VCF/GCF_000001405.40.gz
wget https://ftp.ncbi.nlm.nih.gov/snp/latest_release/VCF/GCF_000001405.40.gz.tbi
# Annotate with ClinVar CLNSIG and CLNDN fields
java -jar snpEff/SnpSift.jar annotate \
-info CLNSIG,CLNDN,CLNREVSTAT \
clinvar.vcf.gz \
annotated.vcf.gz \
> annotated_clinvar.vcf
# Annotate with dbSNP rsIDs (adds RS field to INFO)
java -jar snpEff/SnpSift.jar annotate \
GCF_000001405.40.gz \
annotated_clinvar.vcf \
> annotated_full.vcf
echo "ClinVar + dbSNP annotations added to annotated_full.vcf"
Step 5: Extract Fields to Tab-Delimited Output
SnpSift extractFields converts VCF annotations into a flat table. Use ANN[0] to take the first (most severe) transcript annotation, or ANN[*] to expand all transcripts.
# Extract key fields: CHROM, POS, REF, ALT, impact, gene, protein change, AF, ClinVar sig
java -jar snpEff/SnpSift.jar extractFields \
-s "," \
-e "." \
annotated_full.vcf \
CHROM POS REF ALT \
"ANN[0].GENE" \
"ANN[0].EFFECT" \
"ANN[0].IMPACT" \
"ANN[0].HGVS_P" \
"ANN[0].HGVS_C" \
"ANN[0].FEATUREID" \
AF \
CLNSIG \
CLNDN \
> variants_table.tsv
# Preview
head -3 variants_table.tsv
# CHROM POS REF ALT ANN[0].GENE ANN[0].EFFECT ANN[0].IMPACT ...
# chr1 69511 A G OR4F5 synonymous_variant LOW ...
# chr1 925952 G A SAMD11 missense_variant MODERATE ...
Step 6: Parse Annotated VCF in Python with cyvcf2
Use cyvcf2 (or PyVCF2) for programmatic VCF parsing in Python. cyvcf2 wraps htslib and is significantly faster than pure-Python parsers.
import re
import pandas as pd
from cyvcf2 import VCF
records = []
for variant in VCF("annotated_full.vcf.gz"):
ann_field = variant.INFO.get("ANN")
if ann_field is None:
continue
# Take the first (most severe) annotation transcript
first_ann = ann_field.split(",")[0].split("|")
# ANN field columns (0-indexed): allele, effect, impact, gene, gene_id,
# feature_type, feature_id, biotype, rank, hgvs_c, hgvs_p, ...
effect = first_ann[1] if len(first_ann) > 1 else "."
impact = first_ann[2] if len(first_ann) > 2 else "."
gene = first_ann[3] if len(first_ann) > 3 else "."
hgvs_p = first_ann[10] if len(first_ann) > 10 else "."
records.append({
"chrom": variant.CHROM,
"pos": variant.POS,
"ref": variant.REF,
"alt": ",".join(variant.ALT),
"gene": gene,
"effect": effect,
"impact": impact,
"hgvs_p": hgvs_p,
"af": variant.INFO.get("AF", None),
"clnsig": variant.INFO.get("CLNSIG", "."),
})
df = pd.DataFrame(records)
print(f"Total variants: {len(df)}")
print(df["impact"].value_counts())
# HIGH 342
# MODERATE 4218
# LOW 8903
# MODIFIER 51204
Step 7: Summary Statistics and Consequence Visualization
Generate a bar chart of variant consequences and a count summary by impact tier to prioritize review.
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import seaborn as sns
# Consequence counts by impact (df from Step 6)
impact_order = ["HIGH", "MODERATE", "LOW", "MODIFIER"]
impact_counts = df["impact"].value_counts().reindex(impact_order, fill_value=0)
# Top 15 effects within HIGH and MODERATE
top_effects = (
df[df["impact"].isin(["HIGH", "MODERATE"])]
["effect"]
.str.replace("_", " ")
.value_counts()
.head(15)
)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Left: impact tier counts
impact_counts.plot(kind="bar", ax=axes[0],
color=["#d62728", "#ff7f0e", "#2ca02c", "#aec7e8"],
edgecolor="black")
axes[0].set_title("Variant Counts by Impact Tier", fontsize=13)
axes[0].set_xlabel("Impact")
axes[0].set_ylabel("Count")
axes[0].yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{int(x):,}"))
axes[0].tick_params(axis="x", rotation=0)
# Right: top HIGH/MODERATE effects
top_effects.plot(kind="barh", ax=axes[1], color="#1f77b4", edgecolor="black")
axes[1].set_title("Top HIGH/MODERATE Variant Effects", fontsize=13)
axes[1].set_xlabel("Count")
axes[1].invert_yaxis()
plt.tight_layout()
plt.savefig("variant_consequence_summary.png", dpi=150, bbox_inches="tight")
plt.show()
print("Saved: variant_consequence_summary.png")
# Print HIGH-impact gene table
high_genes = (
df[df["impact"] == "HIGH"]["gene"]
.value_counts()
.head(20)
.rename("n_high_variants")
)
print("\nTop genes with HIGH-impact variants:")
print(high_genes.to_string())
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
-Xmx (JVM heap) |
JVM default (~256 MB) | 4g–32g |
Prevents OutOfMemoryError for large WGS VCFs; set to ~4–8 GB per run |
-v (verbose) |
off | flag | Prints per-chromosome progress and summary counts to stderr |
-stats |
snpEff_summary.html |
any .html path |
Writes interactive QC summary with consequence pie charts and gene tables |
-noStats |
off | flag | Disables HTML report generation (faster for batch runs) |
-canon |
off | flag | Annotates only the canonical transcript per gene (reduces ANN field size) |
-maxAF |
disabled | 0.0–1.0 |
Skip annotation for variants with population AF above threshold (requires gnomAD in database) |
filter expression (SnpSift) |
— | boolean expression | ANN[*].IMPACT = 'HIGH', AF < 0.001 & DP > 10, CLNSIG has 'Pathogenic' |
-s separator (extractFields) |
tab | any string | Field separator in output table; use "," for CSV |
-e empty value (extractFields) |
empty | any string | Placeholder for missing fields; use "." for consistency with VCF convention |
Key Concepts
ANN Field Structure
Each variant's ANN INFO field contains one annotation per predicted transcript consequence, separated by commas. Each annotation is a pipe-delimited string with 16 fields:
ANN=T|missense_variant|MODERATE|BRCA1|ENSG00000012048|transcript|ENST00000357654|protein_coding|12/23|c.1234A>T|p.Lys412Met|1234|1234|412|.|
The first annotation (index 0) is the most severe consequence. Multiple annotations arise when a variant affects multiple transcripts or genes.
Impact Levels
| Impact | Variant Types | Typical Priority |
|---|---|---|
HIGH |
Stop-gained, frameshift, splice-donor/acceptor, start-lost | Disease candidate; always review |
MODERATE |
Missense, in-frame indel, splice-region | Pathogenicity depends on conservation and domain context |
LOW |
Synonymous, stop-retained, start-retained | Rarely causal; useful as controls |
MODIFIER |
Intronic, intergenic, UTR, downstream | Background; filter out for most analyses |
HGVS Notation in ANN
SnpEff provides both cDNA (HGVS.c, e.g., c.1234A>T) and protein (HGVS.p, e.g., p.Lys412Met) notation. HGVS fields are empty (.) for non-coding variants. The FEATUREID field contains the Ensembl or RefSeq transcript ID enabling cross-database lookup.
SnpSift Filter Syntax
SnpSift filter expressions support comparison operators (=, !=, <, >), logical operators (&, |, !), and the has operator for substring matching. Use ANN[*] to match any annotation transcript, ANN[0] for first only:
# Compound filter: rare + damaging + not in ClinVar benign
(AF < 0.001) & (ANN[*].IMPACT = 'HIGH' | ANN[*].IMPACT = 'MODERATE') & !(CLNSIG has 'Benign')
Common Recipes
Recipe: Filter De Novo Candidates (Trio Analysis)
Identify rare, functionally damaging variants in a proband that are absent from both parents. Combine allele frequency filtering, impact filtering, and VCF subtraction using SnpSift.
# Step 1: Annotate proband VCF
java -jar snpEff/snpEff.jar -v hg38 proband.vcf.gz > proband_ann.vcf
# Step 2: Filter rare HIGH/MODERATE variants not in either parent
# Requires proband, mother, and father VCFs to be jointly genotyped or
# use SnpSift filter on a multi-sample VCF
java -jar snpEff/SnpSift.jar filter \
"(ANN[*].IMPACT = 'HIGH' | ANN[*].IMPACT = 'MODERATE') \
& (AF < 0.001 | AF = '.') \
& (GEN[proband].GT != './.') \
& (GEN[mother].GT = '0/0' | GEN[mother].GT = '0|0') \
& (GEN[father].GT = '0/0' | GEN[father].GT = '0|0')" \
proband_ann.vcf \
> denovo_candidates.vcf
# Step 3: Extract to table for review
java -jar snpEff/SnpSift.jar extractFields \
denovo_candidates.vcf \
CHROM POS REF ALT "ANN[0].GENE" "ANN[0].EFFECT" "ANN[0].IMPACT" \
"ANN[0].HGVS_P" AF CLNSIG \
> denovo_candidates.tsv
echo "De novo candidates: $(grep -v '^CHROM' denovo_candidates.tsv | wc -l)"
Recipe: Extract Protein-Changing Variants to pandas DataFrame
Load an annotated VCF directly into pandas using SnpSift extractFields output for downstream statistical or ML analysis.
import subprocess
import io
import pandas as pd
VCF_IN = "annotated_full.vcf.gz"
SNPSIFT = "snpEff/SnpSift.jar"
# Run SnpSift extractFields via subprocess and capture stdout
cmd = [
"java", "-jar", SNPSIFT, "extractFields",
"-s", ",", "-e", ".",
VCF_IN,
"CHROM", "POS", "REF", "ALT",
"ANN[0].GENE", "ANN[0].EFFECT", "ANN[0].IMPACT",
"ANN[0].HGVS_P", "ANN[0].HGVS_C", "ANN[0].FEATUREID",
"AF", "DP", "CLNSIG", "CLNDN",
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
df = pd.read_csv(io.StringIO(result.stdout), sep="\t")
df.columns = [c.replace("ANN[0].", "") for c in df.columns] # clean column names
# Keep protein-changing variants only
protein_changing = df[df["IMPACT"].isin(["HIGH", "MODERATE"])].copy()
protein_changing["AF"] = pd.to_numeric(protein_changing["AF"], errors="coerce")
protein_changing["DP"] = pd.to_numeric(protein_changing["DP"], errors="coerce")
print(f"Protein-changing variants: {len(protein_changing)}")
print(protein_changing[["GENE", "EFFECT", "IMPACT", "HGVS_P", "AF"]].head(10).to_string(index=False))
Recipe: Build a Custom Genome Database
When working with a non-standard organism or custom assembly, build a SnpEff database from a FASTA + GTF file.
# 1. Set up directory structure under SnpEff's data directory
GENOME_NAME="my_organism"
mkdir -p snpEff/data/${GENOME_NAME}
cp my_genome.fa snpEff/data/${GENOME_NAME}/sequences.fa
cp my_annotation.gtf snpEff/data/${GENOME_NAME}/genes.gtf
# 2. Add genome entry to snpEff.config
echo "${GENOME_NAME}.genome : My Organism" >> snpEff/snpEff.config
# 3. Build the database
java -jar snpEff/snpEff.jar build \
-gtf22 \
-v \
${GENOME_NAME}
# 4. Verify
java -jar snpEff/snpEff.jar dump ${GENOME_NAME} | head -20
echo "Custom database built for ${GENOME_NAME}"
Expected Outputs
| File | Format | Contents |
|---|---|---|
annotated.vcf.gz |
bgzipped VCF | Original variants with ANN INFO field; one record per variant |
snpEff_summary.html |
HTML | Interactive QC report: consequence pie charts, top genes, transition/transversion ratio |
snpEff_genes.txt |
TSV | Per-gene counts of variants by consequence type |
high_impact.vcf |
VCF | Subset of variants with ANN[*].IMPACT = 'HIGH' |
annotated_clinvar.vcf |
VCF | Annotated VCF enriched with CLNSIG, CLNDN, CLNREVSTAT from ClinVar |
variants_table.tsv |
TSV | Flat table of extracted fields; one row per variant (first transcript) |
variant_consequence_summary.png |
PNG | Bar charts of impact tiers and top consequence types |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
OutOfMemoryError during annotation |
Default JVM heap too small for WGS VCFs | Add -Xmx8g (or -Xmx16g) before -jar: java -Xmx8g -jar snpEff.jar ... |
ERROR_CHROMOSOME_NOT_FOUND for every variant |
Chromosome naming mismatch (e.g., chr1 vs 1) |
Pass -noCheckChr flag; or rename contigs in VCF with bcftools annotate --rename-chrs |
Database not found error |
Genome database not downloaded | Run java -jar snpEff.jar download hg38; check snpEff/data/ directory exists |
Empty ANN field on all variants |
Wrong genome database version relative to VCF reference | Confirm VCF uses the same assembly as the database (e.g., hg38 vs GRCh38 — use GRCh38.86 for Ensembl builds) |
| SnpSift filter returns zero variants | Filter expression syntax error or wrong field name | Test expression on small VCF; check ANN[*].IMPACT vs ANN[0].IMPACT; wrap expression in quotes |
CLNSIG field missing after snpSift annotate |
ClinVar VCF not indexed, or contig mismatch | Run tabix -p vcf clinvar.vcf.gz before annotating; ensure chromosome names match |
extractFields output missing HGVS_P column |
Variant is non-coding or affects UTR/intron | Use -e "." flag to fill empty fields; filter to coding variants first |
Very large ANN field (thousands of transcripts) |
Variant in a region with many overlapping transcripts | Add -canon flag to annotate only canonical transcripts, or use ANN[0] in downstream filters |
References
- SnpEff Documentation — comprehensive usage guide, ANN field specification, filter syntax
- SnpEff GitHub Repository — source code, issue tracker, release notes
- Cingolani P, et al. (2012) "A program for annotating and predicting the effects of single nucleotide polymorphisms, SnpEff: SNPs in the genome of Drosophila melanogaster strain w1118." Fly 6(2):80–92. doi:10.4161/fly.19695
- ClinVar VCF FTP — ClinVar VCF files by assembly
- cyvcf2 Documentation — Python VCF parsing library used in Step 6
skills/genomics-bioinformatics/variant/vcf-variant-filtering/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill vcf-variant-filtering -g -y
SKILL.md
Frontmatter
{
"name": "vcf-variant-filtering",
"license": "CC-BY-4.0",
"description": "Guide to quality filtering raw VCF files before computing summary stats (Ts\/Tv ratio, variant counts, AF distributions). Covers detecting raw VCFs via FILTER column and QUAL inspection, QUAL-based filtering with bcftools, Ts\/Tv interpretation, and when NOT to filter. Read before any variant-level QC task. See bcftools-variant-manipulation for advanced filters, gatk-variant-calling for caller config, samtools-bam-processing for upstream alignment QC."
}
VCF Variant Filtering Guide
Overview
Raw VCF files produced by variant callers (GATK HaplotypeCaller, bcftools mpileup, DeepVariant, etc.) contain a mixture of true variants and artifacts from sequencing errors, alignment issues, and low-coverage regions. Computing summary statistics -- Ts/Tv ratio, variant counts, allele frequency distributions -- on unfiltered data yields unreliable results because false-positive calls disproportionately inflate transversion counts and depress the Ts/Tv ratio. This guide covers how to detect whether a VCF is raw, how to apply appropriate quality filters, when filtering is not appropriate, and how to interpret the resulting statistics correctly.
Key Concepts
VCF Quality Scores (QUAL Field)
The QUAL column in a VCF file represents the Phred-scaled probability that the variant site is polymorphic. A QUAL score of 30 means a 1-in-1000 chance the call is wrong; a QUAL of 20 means 1-in-100. Variant callers assign QUAL scores based on read evidence, base qualities, and mapping qualities. Low-QUAL variants (below 20-30) are enriched for sequencing errors and alignment artifacts. Filtering on QUAL is the simplest and most widely used first-pass quality control step.
Common QUAL thresholds and their interpretation:
| QUAL Score | Error Probability | Typical Use |
|---|---|---|
| 10 | 1 in 10 | Very permissive; rarely appropriate for final calls |
| 20 | 1 in 100 | Lenient filtering; useful for somatic calling with supporting evidence |
| 30 | 1 in 1,000 | Standard default for germline variant filtering |
| 50 | 1 in 100,000 | Stringent; used in clinical or high-confidence applications |
| 100+ | Extremely low | Highly supported variants; may over-filter low-coverage regions |
For GATK-based workflows, VQSR or hard filtering on INFO annotations (QD, FS, MQ, etc.) may supplement or replace QUAL filtering. GATK's recommended hard filters for SNPs include QD < 2.0, FS > 60.0, MQ < 40.0, MQRankSum < -12.5, and ReadPosRankSum < -8.0.
Ts/Tv Ratio Significance
The transition-to-transversion (Ts/Tv) ratio is a key quality metric for variant call sets. Transitions (A<->G, C<->T) are chemically favored over transversions (all other substitutions) due to the molecular structure of nucleotide bases. Expected Ts/Tv values serve as benchmarks:
- Whole-genome sequencing (WGS): approximately 2.0-2.1
- Whole-exome sequencing (WES): approximately 2.8-3.3 (higher due to CpG enrichment in coding regions)
- Raw/unfiltered call sets: often 1.5-1.8 or lower
A Ts/Tv ratio significantly below the expected range indicates contamination by false-positive transversion calls, which are the hallmark of sequencing errors. After proper quality filtering, the Ts/Tv ratio should rise to the expected range for the assay type.
Raw vs Filtered VCFs
A "raw" VCF is the direct output of a variant caller before any quality filtering has been applied. A "filtered" VCF has had quality thresholds applied, either by hard filtering (QUAL, DP, QD, etc.) or by model-based filtering (GATK VQSR, CNN). Distinguishing between the two is critical because computing statistics on raw data without disclosure leads to incorrect conclusions.
Indicators that a VCF is raw or unfiltered:
- The filename contains "raw" (e.g.,
sample_raw_variants.vcf) - The FILTER column contains only
.(missing) for all records - A large fraction of variants have QUAL scores below 30
- The Ts/Tv ratio is well below the expected range for the assay type
Indicators that a VCF has already been filtered:
- The FILTER column contains meaningful values (
PASS,LowQual,VQSRTrancheSNP99.90to100.00) - A filter command is recorded in the VCF header (
##FILTER=and##bcftools_viewCommand=lines)
FILTER Column Semantics
The FILTER column in VCF format has specific semantics defined by the VCF specification:
.(dot) -- filter status has not been applied; the variant is unassessedPASS-- the variant passed all filters- Any other value -- the variant failed the named filter(s); multiple filters are semicolon-separated
A common misconception is that . means the variant passed. In reality, . means no filter has been evaluated, so the variant's quality is unknown. Another misconception is that PASS in every row means the file is unfiltered -- some callers (e.g., DeepVariant) mark all emitted variants as PASS because they only output high-confidence calls.
To inspect the FILTER column programmatically:
# Count occurrences of each FILTER value
bcftools query -f '%FILTER\n' input.vcf | sort | uniq -c | sort -rn | head
# Check if any non-'.' FILTER values exist
bcftools query -f '%FILTER\n' input.vcf | grep -v '^\.$' | head
Understanding the FILTER column is the first step in any VCF quality assessment. Always inspect it before deciding whether additional filtering is needed.
Decision Framework
Is the VCF raw or unfiltered?
├── Yes (FILTER='.', many low-QUAL variants)
│ ├── Does the user ask for RAW statistics specifically?
│ │ ├── Yes → Do NOT filter; compute on data as-is, note it is raw
│ │ └── No → Apply QUAL>=30 filter before computing statistics
│ └── Does the user specify a custom threshold?
│ ├── Yes → Use their threshold
│ └── No → Default to QUAL>=30
├── No (FILTER has PASS/other values, filtered header present)
│ ├── Does the user ask for additional filtering?
│ │ ├── Yes → Apply requested filter
│ │ └── No → Compute statistics on existing filtered set
│ └── Does the Ts/Tv ratio look suspicious despite filtering?
│ └── Yes → Investigate; may need stricter thresholds
└── Uncertain
└── Inspect FILTER column and QUAL distribution to determine status
| Scenario | Action | Rationale |
|---|---|---|
| Raw VCF, general statistics requested | Apply QUAL>=30 before computing | Low-QUAL variants are enriched for errors and bias all statistics |
| Raw VCF, user explicitly asks for raw stats | Compute as-is, report that data is unfiltered | Respect the user's intent; they may be evaluating caller performance |
| Raw VCF, user specifies threshold (e.g., QUAL>=50) | Use the user-specified threshold | User has domain-specific requirements |
| Pre-filtered VCF (FILTER=PASS present) | Compute without additional QUAL filter | Double-filtering may remove true variants unnecessarily |
| VCF with mixed FILTER values | Filter to PASS-only with bcftools view -f PASS |
Non-PASS variants were flagged for a reason |
| VQSR-filtered VCF | Respect existing tranche filtering | VQSR is a more sophisticated filter than simple QUAL thresholds |
| Small panel or targeted sequencing | Consider lower QUAL threshold (e.g., 20) | Small panels have different error profiles and fewer variants |
Best Practices
-
Always inspect the VCF before computing statistics. Check the FILTER column distribution and QUAL score distribution before running any summary. A 30-second inspection prevents publishing misleading numbers. Use
bcftools query -f '%FILTER\n' input.vcf | sort | uniq -c | sort -rn | headto get a quick overview. -
Use established CLI tools instead of custom parsers. bcftools, vcftools, and similar domain-specific tools handle edge cases that custom parsers typically miss: multi-allelic sites, complex variants, missing genotypes, and proper transition/transversion classification. Writing a Python parser to count Ts/Tv is error-prone and unnecessary.
# Preferred: use bcftools for Ts/Tv computation bcftools stats input.vcf | grep ^TSTV # Preferred: use bcftools for variant counts bcftools stats input.vcf | grep ^SN -
Report what filtering was applied (or not). Every time you present VCF summary statistics, state clearly whether the data was filtered, what threshold was used, and how many variants passed. This is essential for reproducibility and for the reader to assess the validity of the results.
-
Use QUAL>=30 as a sensible default, not a universal rule. QUAL>=30 (1-in-1000 error rate) is a widely used default for first-pass filtering of germline variant calls. However, different applications may warrant different thresholds: somatic variant calling may use lower thresholds with additional evidence, while clinical applications may demand stricter cutoffs. When in doubt, QUAL>=30 is a defensible starting point.
-
Combine filtering and statistics in a single pipeline. Piping the filtered output directly into the statistics command avoids creating intermediate files and ensures you never accidentally compute statistics on the wrong file.
# Filter and compute statistics in one pipeline bcftools view -i 'QUAL>=30' input.vcf | bcftools stats - > filtered_stats.txt -
Validate filtering by checking the Ts/Tv ratio. After filtering, the Ts/Tv ratio should be in the expected range for the assay type. If it is still low, consider that the variant caller may have systematic issues, the sample may have quality problems, or a stricter filter is needed.
-
Preserve the original VCF. Never overwrite the raw VCF with filtered output. Keep the raw file for auditability and in case you need to re-filter with different parameters.
Common Pitfalls
-
Computing statistics on raw VCF files without any filtering. This is the most common and most consequential mistake. Raw Ts/Tv ratios can be 1.5 or lower, suggesting much worse data quality than actually exists, and variant counts will be inflated by thousands of false positives.
- How to avoid: Always check the FILTER column before computing statistics. If FILTER is
.for all records, apply QUAL>=30 filtering first.
- How to avoid: Always check the FILTER column before computing statistics. If FILTER is
-
Assuming FILTER='.' means the variant passed. The dot in the FILTER column means "not assessed," not "passed." Treating unassessed variants as high-quality leads to inclusion of many false positives.
- How to avoid: Distinguish between
.(unassessed),PASS(passed all filters), and named filters (failed). Usebcftools query -f '%FILTER\n' | sort | uniq -cto inspect.
- How to avoid: Distinguish between
-
Writing custom Python parsers for Ts/Tv calculation. Custom parsers frequently mishandle multi-allelic sites, complex variants, or edge cases in the VCF format. They also tend to be slower than optimized C-based tools.
- How to avoid: Use
bcftools statsfor Ts/Tv computation. It is well-tested, fast, and handles all VCF edge cases correctly.
- How to avoid: Use
-
Double-filtering a VCF that was already filtered. Applying QUAL>=30 to a VCF that already went through VQSR or hard filtering can remove true variants that were assigned lower QUAL scores by the caller but passed model-based assessment.
- How to avoid: Inspect the VCF header for filter command records (
##bcftools_viewCommand,##GATKCommandLine) and the FILTER column for non-dot values before adding more filters.
- How to avoid: Inspect the VCF header for filter command records (
-
Using the wrong Ts/Tv expectation for the assay type. Comparing a WES Ts/Tv of 3.0 against a WGS expectation of 2.1 would incorrectly suggest the data is too good, while comparing a WGS Ts/Tv of 2.0 against WES expectations would incorrectly suggest poor quality.
- How to avoid: Know the expected Ts/Tv range for your assay type. WGS: 2.0-2.1; WES: 2.8-3.3; targeted panels: variable depending on target regions.
-
Forgetting to report the filtering status in results. Presenting a Ts/Tv ratio without stating whether or how the data was filtered makes the number uninterpretable and non-reproducible.
- How to avoid: Always include a statement like "Ts/Tv computed after QUAL>=30 filtering (N variants passed)" or "Ts/Tv computed on unfiltered raw calls (N total variants)."
-
Filtering on QUAL alone when richer annotations are available. GATK and other callers provide INFO-level annotations (QD, FS, MQ, SOR, MQRankSum, ReadPosRankSum) that are more informative than QUAL alone. Relying solely on QUAL when these are available leaves quality on the table.
- How to avoid: Check whether INFO annotations are present. If so, consider GATK hard filtering recommendations or VQSR as a more powerful alternative to simple QUAL filtering.
Workflow
-
Inspect the VCF metadata and FILTER column
- Check the VCF header for caller information and existing filter records
- Examine the FILTER column distribution
# View header for caller and filter information bcftools view -h input.vcf | grep -E '##(FILTER|source|GATKCommandLine|bcftools)' # Check FILTER column values bcftools query -f '%FILTER\n' input.vcf | sort | uniq -c | sort -rn | head -
Assess QUAL score distribution
- Determine the proportion of low-QUAL variants
# Check QUAL distribution bcftools query -f '%QUAL\n' input.vcf | awk '{if($1<30) low++; else high++} END {print "QUAL<30:", low+0, "QUAL>=30:", high+0}'- Decision point: If most variants are QUAL>=30 and FILTER=PASS, skip to Step 4. If FILTER=
.and many low-QUAL variants exist, proceed to Step 3.
-
Apply quality filtering
- Use the appropriate threshold (default QUAL>=30 unless otherwise specified)
# Apply QUAL>=30 filter bcftools view -i 'QUAL>=30' input.vcf -Oz -o filtered.vcf.gz bcftools index filtered.vcf.gz -
Compute summary statistics
- Extract Ts/Tv ratio, variant counts, and other metrics
# Ts/Tv ratio from filtered VCF bcftools view -i 'QUAL>=30' input.vcf | bcftools stats - | grep ^TSTV | cut -f5 # Full summary numbers bcftools view -i 'QUAL>=30' input.vcf | bcftools stats - | grep ^SN # Per-sample statistics bcftools stats -s - input.vcf | grep ^PSC -
Compare before and after filtering
- Count variants before and after to quantify the impact of filtering
# Count variants before filtering bcftools view -H input.vcf | wc -l # Count variants after filtering bcftools view -i 'QUAL>=30' input.vcf | bcftools view -H | wc -l # Compare Ts/Tv before and after echo "=== Before filtering ===" bcftools stats input.vcf | grep ^TSTV echo "=== After QUAL>=30 filtering ===" bcftools view -i 'QUAL>=30' input.vcf | bcftools stats - | grep ^TSTV -
Validate and report
- Check that Ts/Tv is within the expected range for the assay type (WGS: 2.0-2.1, WES: 2.8-3.3)
- If Ts/Tv is still below expected range after filtering, investigate sample quality or consider stricter thresholds
- Report the filtering applied, number of variants before and after, and the resulting statistics
- Include the bcftools command used so the analysis is fully reproducible
Protocol Guidelines
-
Pre-analysis inspection is non-negotiable. Before any VCF analysis, run
bcftools query -f '%FILTER\n'and examine the QUAL distribution. This takes seconds and prevents hours of wasted analysis on unreliable data. -
Default to QUAL>=30 for unlabeled VCFs. When the filtering status is ambiguous and the user has not specified a threshold, QUAL>=30 is the standard community default. Document this choice explicitly in the output.
-
Never silently filter. If you apply filtering, always report it. If you choose not to filter, state why. Transparency in filtering decisions is fundamental to reproducible genomics.
-
Use piped commands for efficiency. Chain
bcftools viewandbcftools statsin a pipe rather than writing intermediate files. This is faster, uses less disk, and reduces the chance of analyzing the wrong file. -
Verify with Ts/Tv as a sanity check. After any filtering operation, compute Ts/Tv as a quality control metric. If the ratio does not improve or remains outside the expected range, reassess the filtering strategy or investigate upstream data quality issues.
Further Reading
- bcftools documentation -- Comprehensive reference for bcftools commands, filtering expressions, and statistics output format
- VCF specification (v4.3+) -- Formal definition of VCF format including FILTER column semantics, QUAL field, and INFO annotations
- GATK Best Practices for Germline Short Variant Discovery -- Recommended filtering strategies including VQSR and hard filtering thresholds for GATK-called variants
Related Skills
bcftools-variant-manipulation-- Detailed bcftools usage for subsetting, merging, annotating, and manipulating VCF files beyond simple filteringgatk-variant-calling-- Upstream variant calling with GATK HaplotypeCaller, including VQSR and hard filtering configurationsamtools-bam-processing-- BAM file quality control and processing steps that precede variant calling and affect downstream VCF quality
skills/lab-automation/benchling-integration/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill benchling-integration -g -y
SKILL.md
Frontmatter
{
"name": "benchling-integration",
"license": "Apache-2.0",
"description": "Benchling R&D Python SDK: CRUD on registry entities (DNA, RNA, proteins, custom), inventory, ELN, workflow automation. Needs Benchling account and API key. Use biopython for local sequence analysis; pubchem for chemical DBs."
}
Benchling Integration — R&D Platform SDK
Overview
Benchling is a cloud platform for life sciences R&D. The Python SDK provides programmatic access to registry entities (DNA, proteins), inventory, electronic lab notebooks, and workflows. All operations require a Benchling tenant URL and API key or OAuth credentials.
When to Use
- Creating, updating, or querying biological sequences (DNA, RNA, proteins) in Benchling registry
- Automating inventory operations (containers, boxes, locations, sample transfers)
- Creating or querying electronic lab notebook (ELN) entries programmatically
- Building workflow automations (task creation, status updates, bulk operations)
- Bulk importing entities from FASTA files or spreadsheets into Benchling
- Exporting Benchling data to CSV or external databases for analysis
- Syncing Benchling with external systems via event-driven integrations
- For local sequence analysis (BLAST, alignment), use biopython instead
- For chemical compound databases, use pubchem-compound-search instead
Prerequisites
pip install benchling-sdk
Authentication setup: Obtain an API key from Benchling Profile Settings. Store securely in environment variables — never commit to version control.
import os
from benchling_sdk.benchling import Benchling
from benchling_sdk.auth.api_key_auth import ApiKeyAuth
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ApiKeyAuth(os.environ["BENCHLING_API_KEY"])
)
OAuth (for multi-user apps):
from benchling_sdk.auth.client_credentials_oauth2 import ClientCredentialsOAuth2
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ClientCredentialsOAuth2(
client_id=os.environ["BENCHLING_CLIENT_ID"],
client_secret=os.environ["BENCHLING_CLIENT_SECRET"]
)
)
API rate limits: Benchling enforces per-tenant rate limits. The SDK automatically retries on 429 responses with exponential backoff (up to 5 retries by default). For bulk operations, add time.sleep(0.5) between batches.
Quick Start
from benchling_sdk.benchling import Benchling
from benchling_sdk.auth.api_key_auth import ApiKeyAuth
from benchling_sdk.models import DnaSequenceCreate
import os
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ApiKeyAuth(os.environ["BENCHLING_API_KEY"])
)
# Create a DNA sequence
seq = benchling.dna_sequences.create(
DnaSequenceCreate(name="GFP-insert", bases="ATGGTGAGCAAGGGC", is_circular=False, folder_id="fld_abc123")
)
print(f"Created: {seq.name} ({seq.id})")
Core API
1. Registry — Entity CRUD
Registry entities include DNA sequences, RNA sequences, AA sequences, custom entities, and mixtures. All entity types follow the same create/read/update/archive pattern.
from benchling_sdk.models import DnaSequenceCreate, DnaSequenceUpdate
# Create
sequence = benchling.dna_sequences.create(
DnaSequenceCreate(
name="My Plasmid",
bases="ATCGATCG",
is_circular=True,
folder_id="fld_abc123",
schema_id="ts_abc123",
fields=benchling.models.fields({"gene_name": "GFP"})
)
)
print(f"Created: {sequence.id}")
# Read
seq = benchling.dna_sequences.get_by_id(sequence.id)
print(f"Name: {seq.name}, Length: {len(seq.bases)} bp")
# Update (partial — unspecified fields unchanged)
updated = benchling.dna_sequences.update(
sequence_id=sequence.id,
dna_sequence=DnaSequenceUpdate(
name="Updated Plasmid",
fields=benchling.models.fields({"gene_name": "mCherry"})
)
)
# Archive
benchling.dna_sequences.archive(ids=[sequence.id], reason="RETIRED")
# Register entity in registry (with auto-generated ID)
registered = benchling.dna_sequences.create(
DnaSequenceCreate(
name="Production Plasmid",
bases="ATCGATCG",
is_circular=True,
folder_id="fld_abc123",
entity_registry_id="src_abc123",
naming_strategy="NEW_IDS" # or "IDS_FROM_NAMES"
)
)
print(f"Registry ID: {registered.entity_registry_id}")
# Entity types available via SDK:
# benchling.dna_sequences, benchling.rna_sequences,
# benchling.aa_sequences, benchling.custom_entities, benchling.mixtures
2. Registry — Listing and Pagination
All list operations return paginated generators for memory efficiency.
# List with pagination
sequences = benchling.dna_sequences.list()
total = sequences.estimated_count()
print(f"Total sequences: {total}")
for page in sequences:
for seq in page:
print(f" {seq.name} ({seq.id}): {len(seq.bases)} bp")
# Filter by schema
filtered = benchling.dna_sequences.list(schema_id="ts_abc123")
for page in filtered:
for seq in page:
print(f" {seq.name}")
3. Inventory Management
Manage physical samples, containers, boxes, and locations.
from benchling_sdk.models import ContainerCreate, BoxCreate
# Create container (sample tube)
container = benchling.containers.create(
ContainerCreate(
name="Sample Tube 001",
schema_id="cont_schema_abc123",
parent_storage_id="box_abc123",
fields=benchling.models.fields({"concentration": "100 ng/uL"})
)
)
print(f"Container: {container.id}, Barcode: {container.barcode}")
# Create box
box = benchling.boxes.create(
BoxCreate(
name="Freezer Box A1",
schema_id="box_schema_abc123",
parent_storage_id="loc_abc123"
)
)
# Transfer container to new location
benchling.containers.transfer(
container_id=container.id,
destination_id="box_xyz789"
)
print(f"Transferred {container.name} to new box")
4. Notebook Entries (ELN)
Create and manage electronic lab notebook entries.
from benchling_sdk.models import EntryCreate
# Create notebook entry
entry = benchling.entries.create(
EntryCreate(
name="Experiment 2026-02-17",
folder_id="fld_abc123",
schema_id="entry_schema_abc123",
fields=benchling.models.fields({
"objective": "Test gene expression levels",
"protocol": "Standard qPCR"
})
)
)
print(f"Entry: {entry.id}")
# Link entity to entry
benchling.entry_links.create(
entry_id=entry.id,
entity_id="seq_xyz789"
)
5. Workflow Automation
Create and manage workflow tasks for lab process automation.
from benchling_sdk.models import WorkflowTaskCreate, WorkflowTaskUpdate
# Create workflow task
task = benchling.workflow_tasks.create(
WorkflowTaskCreate(
name="PCR Amplification",
workflow_id="wf_abc123",
assignee_id="user_abc123",
fields=benchling.models.fields({"template": "seq_abc123"})
)
)
print(f"Task: {task.id}, Status: {task.status}")
# Update task status
benchling.workflow_tasks.update(
task_id=task.id,
workflow_task=WorkflowTaskUpdate(status_id="status_complete_abc123")
)
# Wait for async operations
from benchling_sdk.helpers.tasks import wait_for_task
result = wait_for_task(
benchling, task_id="task_abc123",
interval_wait_seconds=2, max_wait_seconds=300
)
print(f"Async task completed: {result}")
6. Error Handling and Retry
from benchling_sdk.retry import RetryStrategy
from benchling_sdk.errors import BenchlingError
# Custom retry strategy
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ApiKeyAuth(os.environ["BENCHLING_API_KEY"]),
retry_strategy=RetryStrategy(max_retries=3)
)
# SDK auto-retries on 429 (rate limit), 502, 503, 504
# Error handling
try:
seq = benchling.dna_sequences.get_by_id("seq_nonexistent")
except BenchlingError as e:
print(f"API error: {e.status_code} — {e.message}")
Key Concepts
Entity Type Mapping
| Benchling Type | SDK Accessor | Use Case |
|---|---|---|
| DNA Sequence | benchling.dna_sequences |
Plasmids, primers, gene inserts |
| RNA Sequence | benchling.rna_sequences |
mRNA, gRNA, siRNA |
| AA Sequence | benchling.aa_sequences |
Proteins, antibodies, enzymes |
| Custom Entity | benchling.custom_entities |
Cell lines, reagents, samples |
| Mixture | benchling.mixtures |
Buffers, media, compound formulations |
| Container | benchling.containers |
Tubes, wells, vials |
| Box | benchling.boxes |
Storage boxes, racks |
| Entry | benchling.entries |
Lab notebook entries |
| Workflow Task | benchling.workflow_tasks |
Process steps, assignments |
Schema Fields
Benchling entities use schema-defined custom fields. Always use the fields() helper:
# Correct: use fields() helper
fields = benchling.models.fields({
"concentration": "100 ng/uL",
"date_prepared": "2026-02-17",
"passage_number": 5
})
# Fields are typed by schema — string, number, date, entity link, dropdown
Pagination Pattern
All list() calls return paginated generators. Never call list() without iterating:
# Correct: iterate through pages
for page in benchling.dna_sequences.list():
for item in page:
process(item)
# Get count without loading all data
count = benchling.dna_sequences.list().estimated_count()
Common Workflows
Workflow: Bulk Import from FASTA
import os, time
from Bio import SeqIO
from benchling_sdk.benchling import Benchling
from benchling_sdk.auth.api_key_auth import ApiKeyAuth
from benchling_sdk.models import DnaSequenceCreate
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ApiKeyAuth(os.environ["BENCHLING_API_KEY"])
)
created = []
for record in SeqIO.parse("sequences.fasta", "fasta"):
seq = benchling.dna_sequences.create(
DnaSequenceCreate(
name=record.id,
bases=str(record.seq),
is_circular=False,
folder_id="fld_abc123",
fields=benchling.models.fields({
"description": record.description,
"source": "FASTA import"
})
)
)
created.append(seq.id)
time.sleep(0.5) # Rate limit compliance
print(f"Created: {record.id} -> {seq.id}")
print(f"Imported {len(created)} sequences")
Workflow: Inventory Audit Report
import os, csv
from benchling_sdk.benchling import Benchling
from benchling_sdk.auth.api_key_auth import ApiKeyAuth
benchling = Benchling(
url="https://your-tenant.benchling.com",
auth_method=ApiKeyAuth(os.environ["BENCHLING_API_KEY"])
)
audit = []
containers = benchling.containers.list(parent_storage_id="loc_freezer01")
for page in containers:
for c in page:
audit.append({
"id": c.id,
"name": c.name,
"barcode": c.barcode,
"location": c.parent_storage_id,
"created": str(c.created_at)
})
with open("inventory_audit.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=audit[0].keys())
writer.writeheader()
writer.writerows(audit)
print(f"Audit complete: {len(audit)} containers")
Workflow: Automated QC Workflow
- List pending workflow tasks:
benchling.workflow_tasks.list(workflow_id=..., status="pending") - For each task, read associated entity via
benchling.dna_sequences.get_by_id() - Run automated validation checks (sequence length, GC content, restriction sites)
- Update task status to "complete" or "failed" via
benchling.workflow_tasks.update() - Log results to a notebook entry via
benchling.entries.create()
Key Parameters
| Parameter | Function/Endpoint | Default | Options | Effect |
|---|---|---|---|---|
folder_id |
All create operations | Required | fld_... |
Target folder for new entity |
schema_id |
All create operations | Optional | ts_..., cont_... |
Schema defining custom fields |
entity_registry_id |
Entity registration | Optional | src_... |
Registry to register entity in |
naming_strategy |
Entity registration | — | NEW_IDS, IDS_FROM_NAMES |
How registry IDs are generated |
parent_storage_id |
Containers, boxes | Optional | box_..., loc_... |
Storage location for inventory |
max_retries |
RetryStrategy |
5 | 0–10 | Number of retry attempts on failure |
interval_wait_seconds |
wait_for_task |
2 | 1–60 | Polling interval for async tasks |
max_wait_seconds |
wait_for_task |
300 | 10–3600 | Maximum wait for async completion |
Best Practices
-
Always use environment variables for credentials: Never hardcode API keys. Use
os.environ["BENCHLING_API_KEY"]. -
Use the
fields()helper for custom schema fields: Raw dicts will not work — the SDK requires typedFieldsobjects. -
Anti-pattern — loading all entities into memory: Use the paginated generator pattern. Never convert
list()to a Python list for large datasets. -
Add rate limit delays for bulk operations: Insert
time.sleep(0.5)between create/update calls when processing >50 entities. -
Use OAuth for production apps, API keys for scripts: API keys are user-scoped; OAuth allows app-level permissions and rotation.
-
Anti-pattern — using both
entity_registry_idandnaming_strategy: These are mutually exclusive on create. Use one or the other. -
Handle
BenchlingErrorexplicitly: Catch SDK exceptions and log the status code and message for debugging.
Common Recipes
Recipe: Export Sequences by Schema
import csv
export = []
for page in benchling.dna_sequences.list(schema_id="ts_target_schema"):
for seq in page:
export.append({
"registry_id": seq.entity_registry_id,
"name": seq.name,
"length": len(seq.bases),
"bases": seq.bases[:50] + "..." if len(seq.bases) > 50 else seq.bases
})
with open("sequences_export.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=export[0].keys())
writer.writeheader()
writer.writerows(export)
print(f"Exported {len(export)} sequences")
Recipe: Find Entities by Custom Field
# Search entities with specific field values
# Note: SDK list() supports limited filtering; for complex queries use Data Warehouse
results = []
for page in benchling.custom_entities.list(schema_id="ts_cell_lines"):
for entity in page:
fields = entity.fields or {}
if fields.get("organism", {}).get("value") == "Human":
results.append(entity)
print(f"Found: {entity.name} ({entity.id})")
print(f"Total human cell lines: {len(results)}")
Recipe: Batch Archive Old Entities
import time
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=365)
to_archive = []
for page in benchling.custom_entities.list():
for entity in page:
if entity.modified_at and entity.modified_at < cutoff:
to_archive.append(entity.id)
# Archive in batches
batch_size = 50
for i in range(0, len(to_archive), batch_size):
batch = to_archive[i:i+batch_size]
benchling.custom_entities.archive(ids=batch, reason="RETIRED")
print(f"Archived batch {i//batch_size + 1}: {len(batch)} entities")
time.sleep(1)
print(f"Total archived: {len(to_archive)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
401 Unauthorized |
Invalid or expired API key | Regenerate key in Benchling Profile Settings; check env var is set |
403 Forbidden |
Insufficient permissions | API key inherits user permissions; check user role in Benchling admin |
404 Not Found |
Wrong entity ID or tenant URL | Verify ID format (seq_, fld_, etc.); check tenant URL matches |
429 Too Many Requests |
Rate limit exceeded | SDK auto-retries; add time.sleep() between bulk operations |
fields ignored on create |
Using raw dict instead of fields() helper |
Use benchling.models.fields({...}) for custom schema fields |
naming_strategy error |
Used with entity_registry_id |
These are mutually exclusive — use one or the other |
| Pagination memory issues | Collecting all items into a list | Iterate page-by-page with for page in .list() pattern |
| OAuth token expired | Client credentials not refreshing | SDK handles refresh automatically; check client_id/secret are valid |
Related Skills
- biopython-molecular-biology — local sequence analysis (BLAST, alignment) before uploading to Benchling
- opentrons-protocol-api — automate lab protocols that feed samples into Benchling inventory
References
- Benchling Python SDK documentation — official SDK guide
- Benchling API reference — REST API endpoint documentation
- Benchling SDK PyPI — package installation and versioning
skills/lab-automation/opentrons-protocol-api/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill opentrons-protocol-api -g -y
SKILL.md
Frontmatter
{
"name": "opentrons-protocol-api",
"license": "Apache-2.0",
"description": "Python API v2 for Opentrons OT-2\/Flex liquid handlers: protocols as Python files with metadata and run(); control pipettes, labware, and modules (thermocycler, heater-shaker, magnetic, temperature). Simulate via opentrons_simulate then upload. Use PyLabRobot for vendor-agnostic scripts (Hamilton, Tecan)."
}
Opentrons Python Protocol API
Overview
The Opentrons Protocol API v2 lets you write liquid handling protocols as plain Python files that run on OT-2 or Flex robots. Every protocol defines a metadata dictionary, an optional requirements dictionary, and a run(protocol) function. The ProtocolContext object passed to run() exposes all deck setup, pipette operations, module control, and utility methods. Protocols can be simulated on any computer with opentrons_simulate before uploading to the robot through the Opentrons App or HTTP API.
When to Use
- Setting up PCR reactions: Distribute master mix from a tube rack into a thermocycler plate, add template DNA from individual tubes, then execute a PCR profile automatically.
- Running serial dilutions: Programmatically step a multi-channel pipette across a 96-well plate to create 2-fold or custom dilution curves with defined diluent volumes.
- Performing ELISA plate layouts: Add blocking buffer, primary antibody, secondary antibody, and substrate to defined wells with tip changes between each reagent.
- Automating magnetic bead cleanups: Engage/disengage the magnetic module, aspirate supernatant, wash with ethanol, and elute — in a fully automated loop.
- Plate reformatting and stamping: Transfer an entire 96-well plate to a destination plate with one command; reformat from tubes to plates.
- Integrating hardware modules: Coordinate temperature control, shaking, and liquid handling steps in a single protocol with precise timing.
- Use
PyLabRobotinstead when writing protocols that must run on Hamilton STAR, Tecan Freedom EVO, or other vendors without Opentrons-specific hardware; for Opentrons-only workflows the native Protocol API provides tighter integration and module support. - For retrieving and parsing published protocols before automation, use
protocolsio-integrationto search protocols.io alongside this skill.
Prerequisites
- Python packages:
opentrons - Robot types: OT-2 (slots 1-11, Gen2 pipettes) or Flex (slots A1-D3, Flex pipettes)
- Environment: Python 3.10+; Opentrons App for uploading to physical robot
- CLI tool:
opentrons_simulateships with the package for local testing
pip install opentrons
# Verify installation and simulate a protocol locally
opentrons_simulate my_protocol.py
Quick Start
A minimal protocol showing all required elements — metadata, labware, instrument, and a transfer:
from opentrons import protocol_api
metadata = {
"protocolName": "Simple Reagent Distribution",
"author": "Lab Automation Team",
"apiLevel": "2.19",
}
def run(protocol: protocol_api.ProtocolContext):
# Load labware onto deck slots
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
# Load pipette and attach tip rack
pipette = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# Distribute 50 µL from reservoir A1 to first 12 wells using one tip
pipette.distribute(50, source["A1"], plate.wells()[:12], new_tip="once")
protocol.comment("Distribution complete")
# Simulate locally — no robot needed
opentrons_simulate simple_reagent_distribution.py
Core API
Module 1: Protocol Metadata and Deck Setup
Every protocol requires a metadata dict specifying at minimum apiLevel. The optional requirements dict sets the target robot type. All labware and instruments are loaded through the ProtocolContext.
from opentrons import protocol_api
# Minimum required metadata
metadata = {
"protocolName": "My Assay Protocol",
"author": "Jane Smith <jane@lab.org>",
"description": "96-well assay setup with temperature control",
"apiLevel": "2.19",
}
# Optional: target a specific robot type (Flex or OT-2)
requirements = {"robotType": "OT-2", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
# OT-2: slots numbered 1-11 in a 3×4 grid
tips_300 = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
tips_20 = protocol.load_labware("opentrons_96_tiprack_20ul", "4")
source = protocol.load_labware("nest_12_reservoir_15ml", "2", label="Buffer Reservoir")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
tube_rack = protocol.load_labware("opentrons_24_tuberack_nest_1.5ml_snapcap", "5")
# Load both pipettes (optional: one or two mounts)
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips_300])
p20 = protocol.load_instrument("p20_single_gen2", "right", tip_racks=[tips_20])
print(f"Deck has {len(protocol.deck)} slots; pipettes: {[p300.name, p20.name]}")
OT-2 deck layout (3 columns × 4 rows, numbered left-to-right, bottom-to-top):
Slot map (OT-2): Slot map (Flex, A-D rows, 1-3 cols):
10 | 11 | Trash D1 | D2 | D3
7 | 8 | 9 C1 | C2 | C3
4 | 5 | 6 B1 | B2 | B3
1 | 2 | 3 A1 | A2 | A3
Common OT-2 pipette names: p20_single_gen2, p300_single_gen2, p1000_single_gen2, p20_multi_gen2, p300_multi_gen2.
Common Flex pipette names: p50_single_flex, p1000_single_flex, p50_multi_flex, p1000_multi_flex, flex_96channel_1000.
Module 2: Pipette Operations
Low-level aspirate/dispense/blow-out operations for precise step-by-step control.
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("nest_12_reservoir_15ml", "2")
dest = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
p300.pick_up_tip()
# Aspirate and dispense — basic liquid movement
p300.aspirate(100, source["A1"]) # draw 100 µL from reservoir
p300.dispense(100, dest["A1"]) # expel into plate well
# Air gap to prevent dripping during transport
p300.aspirate(80, source["A2"])
p300.air_gap(20) # draw 20 µL air to cap the tip
p300.dispense(100, dest["A2"]) # dispenses liquid + air
# Mix in place (repetitions, volume)
p300.mix(3, 60, dest["A1"]) # mix 60 µL × 3 times
# Remove exterior droplets / expel residual
p300.touch_tip(dest["A1"]) # wipe tip on well rim
p300.blow_out(dest["A1"].top()) # expel last drop at top
p300.drop_tip()
protocol.comment("Low-level operations complete")
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# Adjust flow rates (µL/s) for viscous or sensitive samples
p300.flow_rate.aspirate = 50 # slow down for viscous liquids (default ~150)
p300.flow_rate.dispense = 150 # default dispense speed
p300.flow_rate.blow_out = 300 # fast blow-out for complete expulsion
print(f"Aspirate rate: {p300.flow_rate.aspirate} µL/s")
Module 3: transfer() Shortcut
transfer(), distribute(), and consolidate() handle tip management automatically and accept mix, blow-out, and air-gap options.
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")
dest = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# transfer(): one source → one destination, with optional per-well tip changes
p300.transfer(
100,
source["A1"],
dest["A1"],
new_tip="always", # options: "always", "once", "never"
mix_after=(3, 50), # mix 50 µL × 3 reps after each dispense
blow_out=True,
touch_tip=True,
)
# transfer() with lists: pairwise source-destination mapping
sources = source.wells()[:8]
dests = dest.wells()[:8]
p300.transfer(75, sources, dests, new_tip="always")
# distribute(): one source → many destinations (single tip, multi-dispense)
p300.distribute(
50,
source["A1"],
dest.wells()[:12],
new_tip="once", # use one tip for all destinations
disposal_volume=10, # extra volume drawn to ensure accuracy
)
# consolidate(): many sources → one destination (collect, then dispense)
p300.consolidate(
50,
source.wells()[:8],
dest["A1"],
mix_after=(3, 100),
)
print("Compound transfer operations complete")
Module 4: Labware, Liquids, and Well Access
Load labware from the library, navigate wells by name/row/column, and define liquids for visual tracking in the Opentrons App.
def run(protocol: protocol_api.ProtocolContext):
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "1")
p300 = protocol.load_instrument("p300_single_gen2", "left",
tip_racks=[protocol.load_labware("opentrons_96_tiprack_300ul", "2")])
# Access wells by alphanumeric name
well_a1 = plate["A1"]
# Access all wells (column-major order: A1, B1, C1, ..., H1, A2, ...)
all_wells = plate.wells()
print(f"Total wells: {len(all_wells)}") # 96
# Access by row (8 rows, A-H; each row has 12 wells)
row_a = plate.rows()[0] # [A1, A2, ..., A12]
row_b = plate.rows()[1] # [B1, B2, ..., B12]
# Access by column (12 columns, 1-12; each column has 8 wells)
col_1 = plate.columns()[0] # [A1, B1, C1, D1, E1, F1, G1, H1]
# Vertical position control within a well
p300.pick_up_tip()
p300.aspirate(80, well_a1.bottom(z=1)) # 1 mm above well bottom
p300.dispense(80, well_a1.top(z=-2)) # 2 mm below well top
p300.aspirate(80, well_a1.center()) # geometric center
p300.drop_tip()
def run(protocol: protocol_api.ProtocolContext):
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "1")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")
# Define liquids for visual tracking in Opentrons App
pbs = protocol.define_liquid(name="1× PBS", description="Phosphate buffered saline", display_color="#0077BB")
sample = protocol.define_liquid(name="Sample", description="Cell lysate, 1 mg/mL protein", display_color="#EE7733")
# Assign liquids to wells with known starting volumes (µL)
reservoir["A1"].load_liquid(liquid=pbs, volume=10000)
reservoir["A2"].load_liquid(liquid=sample, volume=5000)
# Mark destination wells as empty
for well in plate.wells():
well.load_empty()
print("Liquids defined and assigned")
Module 5: Hardware Modules
Control temperature, magnetic, thermocycler, and heater-shaker modules. Each module is loaded by its model name string and occupies specific deck slots.
def run(protocol: protocol_api.ProtocolContext):
# --- Temperature Module (Gen2) ---
temp_mod = protocol.load_module("temperature module gen2", "3")
temp_plate = temp_mod.load_labware("corning_96_wellplate_360ul_flat")
temp_mod.set_temperature(celsius=4) # blocks until target reached
print(f"Temp module: {temp_mod.temperature}°C")
# temp_mod.deactivate() # turn off at end
# --- Magnetic Module (Gen2) ---
mag_mod = protocol.load_module("magnetic module gen2", "6")
mag_plate = mag_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
mag_mod.engage(height_from_base=10) # raise magnets 10 mm from plate base
protocol.delay(seconds=300) # hold beads for 5 min
mag_mod.disengage()
# --- Heater-Shaker Module ---
hs_mod = protocol.load_module("heaterShakerModuleV1", "1")
hs_plate = hs_mod.load_labware("corning_96_wellplate_360ul_flat")
hs_mod.close_labware_latch()
hs_mod.set_target_temperature(celsius=37)
hs_mod.wait_for_temperature()
hs_mod.set_and_wait_for_shake_speed(rpm=500)
protocol.delay(minutes=30)
hs_mod.deactivate_shaker()
hs_mod.deactivate_heater()
hs_mod.open_labware_latch()
print("Heater-shaker cycle complete")
def run(protocol: protocol_api.ProtocolContext):
# --- Thermocycler Module (Gen2) ---
# Auto-occupies slots 7-11 on OT-2; no slot argument needed
tc_mod = protocol.load_module("thermocyclerModuleV2")
tc_plate = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
tc_mod.open_lid()
tc_mod.set_lid_temperature(celsius=105) # pre-heat lid to prevent condensation
# Initial denaturation
tc_mod.set_block_temperature(95, hold_time_seconds=180)
# PCR cycling profile
profile = [
{"temperature": 95, "hold_time_seconds": 15}, # denaturation
{"temperature": 60, "hold_time_seconds": 30}, # annealing
{"temperature": 72, "hold_time_seconds": 30}, # extension
]
tc_mod.execute_profile(steps=profile, repetitions=35, block_max_volume=25)
# Final extension and hold
tc_mod.set_block_temperature(72, hold_time_minutes=5)
tc_mod.set_block_temperature(4) # hold at 4°C indefinitely
tc_mod.deactivate_lid()
tc_mod.open_lid()
print("PCR complete; plate held at 4°C")
Module 6: Advanced Protocol Features
Pause for user interaction, log comments visible in the app, control rail lights, and detect simulation mode.
def run(protocol: protocol_api.ProtocolContext):
# Pause and prompt the user (robot stops, app shows message)
protocol.pause(msg="Add 10 µL of enzyme to tube A1, then resume")
# Timed delay (robot waits without user action)
protocol.delay(seconds=30, msg="Waiting 30s for reaction incubation")
protocol.delay(minutes=5)
# Log a comment visible in Opentrons App run log
protocol.comment("Starting serial dilution — columns 1 to 11")
# Rail lights for visual status indication
protocol.set_rail_lights(True) # lights on
protocol.set_rail_lights(False) # lights off
# Home all axes (useful after an error or before finishing)
protocol.home()
# Detect simulation vs. physical run — skip slow waits in simulation
if protocol.is_simulating():
protocol.comment("Running in simulation mode — skipping 10-min incubation")
else:
protocol.delay(minutes=10)
# Load waste bin (Flex only — OT-2 uses fixed trash)
# trash = protocol.load_trash_bin("A3")
print("Protocol control features demonstrated")
Common Workflows
Workflow 1: PCR Setup with Thermocycler
Goal: Transfer master mix from a tube rack into a PCR plate on the thermocycler, add template DNA from individual samples, then run a complete PCR cycling program.
from opentrons import protocol_api
metadata = {
"protocolName": "PCR Setup and Run",
"author": "Lab Automation",
"apiLevel": "2.19",
}
def run(protocol: protocol_api.ProtocolContext):
# Hardware setup
tc_mod = protocol.load_module("thermocyclerModuleV2")
tc_plate = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
tips_300 = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
tips_20 = protocol.load_labware("opentrons_96_tiprack_20ul", "4")
reagents = protocol.load_labware("opentrons_24_tuberack_nest_1.5ml_snapcap", "2")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips_300])
p20 = protocol.load_instrument("p20_single_gen2", "right", tip_racks=[tips_20])
# Define liquids
master_mix = protocol.define_liquid("Master Mix", "2× PCR master mix", "#33BBEE")
template = protocol.define_liquid("Template", "gDNA 10 ng/µL", "#EE3377")
reagents["A1"].load_liquid(master_mix, volume=500)
for i in range(8):
reagents.wells()[i + 1].load_liquid(template, volume=50)
# Step 1: Open lid and distribute master mix (20 µL per well, 8 wells)
tc_mod.open_lid()
protocol.comment("Distributing master mix")
p300.distribute(
20,
reagents["A1"],
tc_plate.wells()[:8],
new_tip="once",
blow_out=True,
blowout_location="source well",
)
# Step 2: Add template DNA (5 µL per well, fresh tip each time)
protocol.comment("Adding template DNA")
for i in range(8):
p20.transfer(
5,
reagents.wells()[i + 1],
tc_plate.wells()[i],
new_tip="always",
mix_after=(2, 10),
)
# Step 3: Run PCR
tc_mod.close_lid()
tc_mod.set_lid_temperature(105)
tc_mod.set_block_temperature(95, hold_time_seconds=180) # initial denaturation
profile = [
{"temperature": 95, "hold_time_seconds": 15},
{"temperature": 60, "hold_time_seconds": 30},
{"temperature": 72, "hold_time_seconds": 30},
]
tc_mod.execute_profile(steps=profile, repetitions=35, block_max_volume=25)
tc_mod.set_block_temperature(72, hold_time_minutes=5) # final extension
tc_mod.set_block_temperature(4) # hold
tc_mod.deactivate_lid()
tc_mod.open_lid()
protocol.comment("PCR complete — 8 reactions in wells A1:H1")
Workflow 2: ELISA Serial Dilution with Multi-Channel Pipette
Goal: Use a multi-channel pipette to add diluent to columns 2-12, perform 2-fold serial dilutions across the plate, and add detection reagent to all wells in a single pass.
from opentrons import protocol_api
metadata = {
"protocolName": "ELISA Serial Dilution",
"author": "Lab Automation",
"apiLevel": "2.19",
}
def run(protocol: protocol_api.ProtocolContext):
# Deck layout
tips_300 = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
tips_300b = protocol.load_labware("opentrons_96_tiprack_300ul", "4") # extra rack
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
multi = protocol.load_instrument("p300_multi_gen2", "left",
tip_racks=[tips_300, tips_300b])
# Define liquids
diluent = protocol.define_liquid("Diluent", "PBS + 1% BSA", "#0077BB")
sample = protocol.define_liquid("Sample", "Serum 1:10", "#EE7733")
reservoir["A1"].load_liquid(diluent, volume=50000)
reservoir["A2"].load_liquid(sample, volume=5000)
# Step 1: Load column 1 with undiluted sample (all 8 rows at once)
protocol.comment("Loading undiluted sample into column 1")
multi.transfer(100, reservoir["A2"], plate.columns()[0], new_tip="once")
# Step 2: Add diluent to columns 2-12
protocol.comment("Adding diluent to columns 2-12")
multi.distribute(
100,
reservoir["A1"],
[col[0] for col in plate.columns()[1:]], # A2 through A12 (multi-channel reads full column)
new_tip="once",
disposal_volume=10,
)
# Step 3: Serial dilution — transfer 100 µL from each column to the next, mix
protocol.comment("Performing 2-fold serial dilution across columns 1→11")
multi.transfer(
100,
[col[0] for col in plate.columns()[:11]], # cols 1-11 as source
[col[0] for col in plate.columns()[1:]], # cols 2-12 as destination
mix_after=(5, 80), # mix 80 µL × 5 reps after each dispense
new_tip="always", # fresh tip per column to avoid carry-over
)
# Step 4: Remove 100 µL from column 12 to equalize volumes
multi.pick_up_tip()
multi.aspirate(100, plate.columns()[11][0])
multi.drop_tip()
protocol.comment("ELISA serial dilution complete — 11 dilution steps, 12 columns")
print("Protocol complete: 2-fold dilution series across 96-well plate")
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
new_tip |
transfer, distribute, consolidate |
"always" |
"always", "once", "never" |
Controls tip change strategy; use "always" to prevent cross-contamination |
mix_after |
transfer |
None |
(repetitions, volume) tuple |
Aspirate/dispense in destination well after each dispense to homogenize |
mix_before |
transfer |
None |
(repetitions, volume) tuple |
Aspirate/dispense in source well before each aspirate |
blow_out |
transfer |
False |
True, False |
Expel residual volume after dispense; set blowout_location to control where |
air_gap |
transfer |
0 |
0–pipette max µL |
Insert air gap after aspirate to prevent dripping during robot moves |
disposal_volume |
distribute |
0 |
0–pipette max µL |
Extra volume drawn at start to improve dispense accuracy; discarded to trash |
flow_rate.aspirate |
pipette property | varies by model | 1–1000 µL/s |
Aspirate speed; lower for viscous samples (glycerol, proteins > 5 mg/mL) |
flow_rate.dispense |
pipette property | varies by model | 1–1000 µL/s |
Dispense speed; lower for foaming or delicate cell suspensions |
height_from_base |
mag_mod.engage() |
— | 0–20 mm |
Height of magnet tips above plate base; depends on bead/plate geometry |
repetitions |
tc_mod.execute_profile() |
— | 1–99 |
Number of PCR thermal cycles |
Best Practices
-
Always simulate before running on hardware: Use
opentrons_simulate protocol.pyto catch labware name errors, tip shortages, volume overflows, and slot conflicts without consuming consumables or robot time.opentrons_simulate my_pcr_setup.py # Output shows all commands; errors printed with line numbers -
Prefer compound operations over manual pick-up/aspirate/dispense/drop sequences:
transfer(),distribute(), andconsolidate()handle tip management, air gaps, and blow-out automatically. Reserve low-level calls for operations not supported by compound methods. -
Count tips before running: Calculate total tip consumptions (each
new_tip="always"transfer costs one tip per well pair). If tips exceed rack capacity, add additional racks totip_racks=[].n_transfers = len(source_wells) # one tip per transfer tips_per_rack = 96 racks_needed = -(-n_transfers // tips_per_rack) # ceiling division print(f"Need {racks_needed} tip rack(s) for {n_transfers} transfers") -
Use
define_liquid()andload_liquid()for setup validation: Liquid tracking in the Opentrons App displays color-coded wells with volumes, making it easy to verify correct reagent placement before pressing Run. -
Distinguish OT-2 slots from Flex slots in protocol files: OT-2 uses numeric strings (
"1"through"11") while Flex uses grid coordinates ("A1"through"D3"). Setrequirements = {"robotType": "Flex"}or"OT-2"to catch slot mismatches during simulation. -
Adjust flow rates for difficult liquids: Viscous solutions (≥20% glycerol, PEG, protein > 5 mg/mL) require lower aspirate rates (25-50 µL/s). Reduce dispense speed for foaming samples to avoid bubble formation.
-
Use
protocol.pause()for manual steps, notprotocol.delay():pause()stops the robot and notifies the operator; the run resumes on demand.delay()is for timed waits (incubations, module equilibration) where no human action is needed.
Common Recipes
Recipe: Plate Replication (96-Well to 96-Well)
When to use: Duplicate an entire source plate into a destination plate with fresh tips per well.
from opentrons import protocol_api
metadata = {"protocolName": "Plate Replication", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
source = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")
dest = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# Transfer all 96 wells in one call — pairwise source[i] → dest[i]
p300.transfer(100, source.wells(), dest.wells(), new_tip="always")
protocol.comment("Plate replicated: 96 wells transferred")
Recipe: Multi-Channel Column-by-Column Fill
When to use: Fill a 96-well plate column by column with a single reagent using a multi-channel pipette and one tip.
from opentrons import protocol_api
metadata = {"protocolName": "Multi-Channel Fill", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
multi = protocol.load_instrument("p300_multi_gen2", "left", tip_racks=[tips])
# distribute() with multi-channel: one pick-up, 12 dispenses across all columns
multi.distribute(
100,
reservoir["A1"],
[plate.columns()[i][0] for i in range(12)],
new_tip="once",
disposal_volume=10,
)
protocol.comment("96-well plate filled: 100 µL per well, single tip")
Recipe: Magnetic Bead Wash Loop
When to use: Automated bead-based cleanup (DNA extraction, IP assay) with repeating wash steps.
from opentrons import protocol_api
metadata = {"protocolName": "Magnetic Bead Wash", "apiLevel": "2.19"}
def run(protocol: protocol_api.ProtocolContext):
mag_mod = protocol.load_module("magnetic module gen2", "4")
bead_plate = mag_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
waste = protocol.load_labware("nest_12_reservoir_15ml", "5")
p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])
# Engage magnets and remove supernatant
mag_mod.engage(height_from_base=6)
protocol.delay(seconds=120, msg="Beads pelleting on magnet")
p300.transfer(90, bead_plate["A1"].bottom(z=0.5), waste["A1"], new_tip="once")
# Wash loop (2 washes)
for wash_num in range(2):
mag_mod.disengage()
protocol.comment(f"Wash {wash_num + 1} of 2")
p300.transfer(100, reservoir["A1"], bead_plate["A1"],
mix_after=(5, 80), new_tip="always")
mag_mod.engage(height_from_base=6)
protocol.delay(seconds=90)
p300.transfer(100, bead_plate["A1"].bottom(z=0.5), waste["A2"], new_tip="always")
# Elute
mag_mod.disengage()
elution_plate = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
p300.transfer(50, reservoir["A2"], bead_plate["A1"],
mix_after=(10, 40), new_tip="always")
mag_mod.engage(height_from_base=6)
protocol.delay(seconds=120)
p300.transfer(45, bead_plate["A1"].bottom(z=0.5), elution_plate["A1"], new_tip="always")
mag_mod.disengage()
protocol.comment("Bead cleanup complete: eluate in elution_plate A1")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
LabwareNotFoundError: [labware name] |
Incorrect labware API name string | Look up exact names at labware.opentrons.com; names are case-sensitive (e.g., "corning_96_wellplate_360ul_flat") |
OutOfTipsError during run |
Protocol needs more tips than racks provide | Add additional tip racks to tip_racks=[]; or call pipette.reset_tipracks() if racks have been reloaded |
| Volume exceeds pipette max capacity | Trying to aspirate/dispense more than the pipette can hold | Use distribute() which auto-splits large volumes; switch to p1000_single_gen2 for large volumes (up to 1000 µL) |
DeckConflictError |
Labware placed in overlapping slots | Thermocycler auto-occupies slots 7-11; check protocol.deck output from simulation before running |
Simulation passes but robot fails with ModuleNotAttachedError |
Module not physically connected or wrong model string | Verify USB connection; use exact model strings: "temperature module gen2", "magnetic module gen2", "thermocyclerModuleV2", "heaterShakerModuleV1" |
| Inaccurate volumes, especially near pipette minimum | Pipette at edge of calibrated range or viscous liquid | Use a pipette whose optimal range covers your volume; pre-wet tips with mix() before critical transfers; reduce flow rates |
TypeError on transfer() with well list length mismatch |
Source and destination lists different lengths | Ensure source and destination lists are same length for pairwise transfer, or use a single source with a destination list for 1-to-many |
| OT-2 protocol errors on Flex with slot names | Robot type mismatch (numeric vs grid slots) | Set requirements = {"robotType": "Flex"} or "OT-2" to enforce slot naming; Flex slots are strings like "A1", OT-2 slots are "1"-"11" |
Related Skills
- pylabrobot — hardware-agnostic Python API for Hamilton, Tecan, Beckman, and other vendors; use when protocols must run on non-Opentrons hardware
- protocolsio-integration — search and retrieve published wet-lab protocols from protocols.io to adapt into Opentrons Python protocols
- benchling-integration — connect protocol execution to Benchling ELN entries and sample registries
References
- Opentrons Protocol API v2 Documentation — official API reference covering all context methods, labware, and modules
- Opentrons Labware Library — searchable catalog of all supported labware with API name strings
- Protocol API Tutorial — step-by-step guide from metadata through hardware modules
- Opentrons GitHub Repository — source code, protocol examples, and issue tracker
- Opentrons Community Forum — community Q&A for protocol debugging and hardware questions
skills/lab-automation/protocolsio-integration/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill protocolsio-integration -g -y
SKILL.md
Frontmatter
{
"name": "protocolsio-integration",
"license": "CC-BY-4.0",
"description": "protocols.io REST API: search and fetch wet-lab, bioinformatics, and clinical protocols by keyword, DOI, or category, with steps, reagents, materials, equipment, timing. Public access free; auth needed for private or publishing. Pair with opentrons-protocol-api or benchling-integration to execute."
}
protocols.io Integration
Overview
protocols.io is the leading protocol repository for life sciences with 90,000+ open-access experimental protocols covering molecular biology, cell biology, bioinformatics, clinical research, and lab automation. The REST API provides programmatic access to protocol search, full protocol retrieval (steps, reagents, materials, equipment), protocol versioning, workspace management, and protocol publishing. Public protocols are freely accessible; authentication (OAuth2 token) is required for private protocols or creating/editing.
When to Use
- Searching for validated wet-lab protocols by keyword, technique, or journal article DOI
- Retrieving the full step-by-step content of a protocol (reagents, timing, volumes, notes) for automation or analysis
- Finding protocols associated with a specific reagent, kit, or instrument
- Building lab automation workflows by extracting protocol steps and reagent lists programmatically
- Verifying protocol versions and citing the correct DOI for methods sections
- Discovering community-validated protocols as alternatives to proprietary methods
- Use alongside
opentrons-protocol-apiorbenchling-integrationto implement downloaded protocols in automated workflows
Prerequisites
- Python packages:
requests,pandas - Data requirements: protocol keywords, DOIs, or protocols.io protocol IDs
- Environment: internet connection; public protocols: no auth needed; private: OAuth2 token from https://www.protocols.io/developers
- Rate limits: 10 requests/second for public API; unauthenticated requests allowed for public protocols
pip install requests pandas
# For private protocol access or publishing:
# Register at https://www.protocols.io/developers to obtain an API token
Quick Start
import requests
BASE = "https://www.protocols.io/api/v4"
# For public protocols, no token needed (but add for higher rate limits)
HEADERS = {"Authorization": "Bearer YOUR_TOKEN_HERE"} # Optional for public
# Search for CRISPR protocols
r = requests.get(f"{BASE}/protocols",
params={"q": "CRISPR guide RNA design", "order_field": "views",
"page_size": 5},
headers=HEADERS)
r.raise_for_status()
data = r.json()
print(f"Total CRISPR protocols: {data['pagination']['total_results']}")
for p in data["items"][:3]:
print(f"\n {p['title']}")
print(f" DOI: {p.get('doi')} | Views: {p.get('stats', {}).get('number_of_views')}")
print(f" Authors: {', '.join(a['name'] for a in p.get('creators', [])[:3])}")
Core API
Query 1: Protocol Search
Search the protocols.io public library by keyword, technique, or full-text.
import requests, pandas as pd
BASE = "https://www.protocols.io/api/v4"
def search_protocols(query, page_size=20, order_field="relevance", category_id=None):
params = {"q": query, "page_size": page_size, "order_field": order_field}
if category_id:
params["filter[categories_ids][]"] = category_id
r = requests.get(f"{BASE}/protocols", params=params)
r.raise_for_status()
return r.json()
data = search_protocols("RNA extraction tissue", page_size=10, order_field="views")
total = data["pagination"]["total_results"]
print(f"RNA extraction protocols: {total}")
rows = []
for p in data["items"][:10]:
rows.append({
"id": p.get("id"),
"title": p.get("title"),
"doi": p.get("doi"),
"views": p.get("stats", {}).get("number_of_views", 0),
"created": p.get("created_on"),
"category": p.get("categories", [{}])[0].get("name", "n/a"),
})
df = pd.DataFrame(rows).sort_values("views", ascending=False)
print(df.to_string(index=False))
# Search with category filter (get category IDs from /categories endpoint)
data_pcr = search_protocols("qPCR primer design", order_field="views")
print(f"\nqPCR protocols: {data_pcr['pagination']['total_results']}")
for p in data_pcr["items"][:3]:
print(f" {p['title'][:70]} (DOI: {p.get('doi', 'n/a')})")
Query 2: Retrieve Full Protocol Content
Fetch the complete protocol with steps, reagents, materials, and equipment.
import requests
BASE = "https://www.protocols.io/api/v4"
def get_protocol(protocol_id):
r = requests.get(f"{BASE}/protocols/{protocol_id}")
r.raise_for_status()
return r.json()
# Retrieve protocol by ID (from search results or DOI lookup)
protocol_id = 45979 # Example: a public protocol
data = get_protocol(protocol_id)
protocol = data.get("payload", data) # Handle API response structure
print(f"Title: {protocol.get('title')}")
print(f"DOI: {protocol.get('doi')}")
print(f"Authors: {', '.join(a['name'] for a in protocol.get('creators', []))}")
print(f"Steps: {len(protocol.get('steps', []))}")
print(f"Materials: {len(protocol.get('materials', []))}")
print(f"Abstract: {protocol.get('description', '')[:200]}")
# Parse protocol steps
protocol_steps = protocol.get("steps", [])
for i, step in enumerate(protocol_steps[:5], 1):
step_desc = step.get("description", "")
duration = step.get("duration", {})
print(f"\nStep {i}: {step_desc[:120]}")
if duration:
print(f" Duration: {duration.get('duration')} {duration.get('unit_label', '')}")
Query 3: Retrieve Protocol by DOI
Fetch a protocol using its DOI for precise citation-based retrieval.
import requests, json
BASE = "https://www.protocols.io/api/v4"
def get_protocol_by_doi(doi):
"""Retrieve protocol using its DOI."""
# URL-encode the DOI for the query
r = requests.get(f"{BASE}/protocols",
params={"q": doi, "page_size": 5})
r.raise_for_status()
items = r.json()["items"]
for item in items:
if item.get("doi") == doi:
return item
return None
doi = "10.17504/protocols.io.bvb3n2qn" # Example protocols.io DOI
protocol = get_protocol_by_doi(doi)
if protocol:
print(f"Found: {protocol['title']}")
print(f" ID: {protocol['id']}")
print(f" Version: {protocol.get('version_id')}")
Query 4: Extract Reagents and Materials
Parse out the materials list from a retrieved protocol.
import requests, pandas as pd
BASE = "https://www.protocols.io/api/v4"
def get_reagents(protocol_id):
r = requests.get(f"{BASE}/protocols/{protocol_id}")
r.raise_for_status()
data = r.json()
protocol = data.get("payload", data)
return protocol.get("materials", [])
# Get reagents list
materials = get_reagents(45979) # Example protocol ID
print(f"Materials ({len(materials)} items):")
rows = []
for m in materials[:10]:
rows.append({
"name": m.get("name"),
"quantity": m.get("quantity"),
"unit": m.get("unit", {}).get("name", ""),
"supplier": m.get("supplier", {}).get("name", ""),
"catalog": m.get("sku"),
})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
Query 5: Browse Protocol Categories
List available protocol categories for targeted searches.
import requests, pandas as pd
BASE = "https://www.protocols.io/api/v4"
r = requests.get(f"{BASE}/categories")
r.raise_for_status()
data = r.json()
categories = data.get("items", data.get("payload", []))
print(f"protocols.io categories: {len(categories)}")
df = pd.DataFrame(categories)[["id", "name"]].head(20)
print(df.to_string(index=False))
Query 6: List Protocol Versions
Retrieve version history for a protocol to track updates.
import requests
BASE = "https://www.protocols.io/api/v4"
def get_protocol_versions(protocol_id):
r = requests.get(f"{BASE}/protocols/{protocol_id}")
r.raise_for_status()
protocol = r.json().get("payload", r.json())
return {
"title": protocol.get("title"),
"version": protocol.get("version_id"),
"published": protocol.get("published_on"),
"doi": protocol.get("doi"),
"parent_doi": protocol.get("parent_publication", {}).get("doi"),
}
info = get_protocol_versions(45979)
for k, v in info.items():
print(f" {k}: {v}")
Key Concepts
Protocol DOIs and Versioning
Each published protocols.io protocol has a citable DOI (format: 10.17504/protocols.io.XXXXX). When a protocol is updated, a new version is created with a new DOI while the original DOI remains valid. Always cite the specific version DOI in methods sections for reproducibility.
API Authentication
Public protocols are accessible without authentication. OAuth2 Bearer tokens are needed for: private protocols, workspace management, protocol creation/editing, and user-specific queries. Obtain tokens at https://www.protocols.io/developers.
Common Workflows
Workflow 1: Protocol Discovery and Comparison
Goal: Search for protocols matching a technique, compare them, and select the best one for adaptation.
import requests, pandas as pd
BASE = "https://www.protocols.io/api/v4"
def search_and_rank(query, top_n=20):
"""Search protocols and return ranked by views + forks."""
r = requests.get(f"{BASE}/protocols",
params={"q": query, "page_size": top_n, "order_field": "views"})
r.raise_for_status()
data = r.json()
rows = []
for p in data["items"]:
stats = p.get("stats", {})
rows.append({
"id": p.get("id"),
"title": p.get("title"),
"doi": p.get("doi"),
"views": stats.get("number_of_views", 0),
"forks": stats.get("number_of_forks", 0),
"steps": p.get("number_of_steps"),
"created": p.get("created_on")[:10] if p.get("created_on") else "n/a",
"category": p.get("categories", [{}])[0].get("name", "n/a"),
})
df = pd.DataFrame(rows)
df["popularity_score"] = df["views"] * 0.7 + df["forks"] * 0.3 * 100
return df.sort_values("popularity_score", ascending=False)
# Compare western blotting protocols
df = search_and_rank("western blot protein detection", top_n=15)
df.to_csv("western_blot_protocols.csv", index=False)
print("Top western blot protocols:")
print(df[["title", "views", "forks", "steps"]].head(8).to_string(index=False))
Workflow 2: Protocol Step Extraction for Automation
Goal: Extract protocol steps, timing, and reagent volumes for downstream automation scripting.
import requests, pandas as pd
BASE = "https://www.protocols.io/api/v4"
def extract_protocol_steps(protocol_id):
"""Extract structured step data from a protocol."""
r = requests.get(f"{BASE}/protocols/{protocol_id}")
r.raise_for_status()
protocol = r.json().get("payload", r.json())
steps = []
for i, step in enumerate(protocol.get("steps", []), 1):
duration = step.get("duration", {})
steps.append({
"step_number": i,
"description": step.get("description", ""),
"duration_value": duration.get("duration"),
"duration_unit": duration.get("unit_label", ""),
"temperature": step.get("temperature", {}).get("value"),
"temp_unit": step.get("temperature", {}).get("unit_label", ""),
})
materials = [{
"name": m.get("name"),
"quantity": m.get("quantity"),
"unit": m.get("unit", {}).get("name", ""),
} for m in protocol.get("materials", [])]
return {
"title": protocol.get("title"),
"doi": protocol.get("doi"),
"steps": pd.DataFrame(steps),
"materials": pd.DataFrame(materials),
}
result = extract_protocol_steps(45979)
print(f"Protocol: {result['title']}")
print(f"\nSteps ({len(result['steps'])}):")
print(result["steps"][["step_number", "description", "duration_value", "duration_unit"]].head(5).to_string(index=False))
print(f"\nMaterials ({len(result['materials'])}):")
print(result["materials"].head(5).to_string(index=False))
# Export for automation
result["steps"].to_csv("protocol_steps.csv", index=False)
result["materials"].to_csv("protocol_materials.csv", index=False)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
q |
Search | — | keyword string | Full-text search query |
order_field |
Search | "relevance" |
"relevance", "views", "date", "activity" |
Sort order for results |
page_size |
Search | 10 |
1–50 |
Results per page |
page_id |
Search | 1 |
integer | Page number for pagination |
filter[categories_ids][] |
Search | — | category integer ID | Filter by protocol category |
| Protocol ID | Retrieve | required | integer | Specific protocol to fetch |
Best Practices
-
Sort by views for quality: Use
order_field=viewswhen searching for well-validated protocols, as highly-viewed protocols have been tested by many groups. -
Always cite the specific DOI: protocols.io DOIs are versioned; cite the exact version DOI (not just the protocol title) in methods sections so readers can reproduce your exact protocol.
-
Check license before use: All public protocols.io protocols are CC-BY 4.0 by default. Commercial use requires checking individual protocol licenses.
-
Extract materials list for reagent ordering: The materials API returns catalog numbers and supplier names, enabling direct procurement list generation.
-
Store protocol ID + DOI for reproducibility: Record both the integer ID (for API access) and the DOI (for stable citation) when selecting protocols for a project.
Common Recipes
Recipe: Search by Reagent Name
When to use: Find protocols that use a specific commercial kit or reagent.
import requests
r = requests.get("https://www.protocols.io/api/v4/protocols",
params={"q": "RNeasy Mini Kit RNA extraction", "page_size": 5,
"order_field": "views"})
data = r.json()
print(f"Protocols using RNeasy: {data['pagination']['total_results']}")
for p in data["items"][:3]:
print(f" {p['title'][:70]} ({p.get('doi', 'n/a')})")
Recipe: Get Protocol Citation for Methods Section
When to use: Generate a citation string for a methods section.
import requests
protocol_id = 45979
r = requests.get(f"https://www.protocols.io/api/v4/protocols/{protocol_id}")
p = r.json().get("payload", r.json())
authors = "; ".join(a["name"] for a in p.get("creators", [])[:3])
print(f"Citation: {authors} ({p.get('created_on', '')[:4]}). ")
print(f"{p.get('title')}. protocols.io. https://doi.org/{p.get('doi')}")
Recipe: Find Most-Forked Protocols
When to use: Identify widely-adapted protocols (high forks = adapted by many labs).
import requests, pandas as pd
r = requests.get("https://www.protocols.io/api/v4/protocols",
params={"q": "ChIP-seq chromatin", "page_size": 20})
data = r.json()
df = pd.DataFrame([{
"title": p["title"][:60],
"forks": p.get("stats", {}).get("number_of_forks", 0),
"views": p.get("stats", {}).get("number_of_views", 0),
} for p in data["items"]])
print(df.sort_values("forks", ascending=False).head(5).to_string(index=False))
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Empty items in search |
Query too specific or no match | Broaden query; remove special characters |
HTTP 401 accessing protocol |
Private protocol without auth | Obtain OAuth2 token; public protocols don't need auth |
| Protocol steps have empty descriptions | Protocol uses rich text formatting | Strip HTML tags from description with re.sub(r'<[^>]+>', '', text) |
materials list is empty |
Protocol has no structured materials | Materials may be embedded in step descriptions as free text |
| DOI lookup returns wrong protocol | Similar title match instead of DOI | Compare DOI field exactly; use string equality check if item.get("doi") == doi |
| Rate limit errors | >10 requests/second | Add time.sleep(0.15) between requests |
Related Skills
opentrons-protocol-api— Execute protocols on Opentrons liquid handling robots using steps extracted via this skillbenchling-integration— Store retrieved protocols in Benchling ELN with reagent trackingscientific-manuscript-writing— Reference protocols correctly in methods sections using protocols.io DOIs
References
- protocols.io API documentation — Official REST API reference
- protocols.io platform — Public protocol library and search
- protocols.io citation guide — How to cite protocols.io in publications
- Teytelman et al. (2016) Nature Methods — protocols.io platform description paper
skills/lab-automation/pylabrobot/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pylabrobot -g -y
SKILL.md
Frontmatter
{
"name": "pylabrobot",
"license": "MIT",
"description": "Hardware-agnostic Python liquid-handler library: portable scripts run on Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulator without vendor lock-in. For protocol automation, method dev, plate reformatting, serial dilutions, and Python lab workflows."
}
pylabrobot
Overview
PyLabRobot is an open-source Python library that abstracts liquid handling robot hardware behind a unified API. Write a protocol once and run it on any supported robot — Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulated backend — without changing the protocol code. PyLabRobot handles deck layout, resource management, and aspirate/dispense operations through a clean, async-first interface.
When to Use
- Writing portable liquid handling protocols: You want a single Python script that works across Hamilton, Tecan, Opentrons, and a simulator without code changes.
- Developing and testing protocols before robot time: Use the simulation backend to validate logic, volumes, and deck layouts without occupying physical hardware.
- Automating plate reformatting and cherry-picking: Transfer specific wells between plates based on upstream data (e.g., hit compounds from a screen).
- Building serial dilution curves: Systematically aspirate and dispense across a plate with precise volume steps.
- Integrating liquid handling into Python data pipelines: Trigger robot actions from analysis code, LIMS queries, or machine learning models.
- Rapid method development: Iterate quickly in Python rather than in vendor-specific scripting environments.
- For Opentrons-specific features (temperature module control, built-in app integration), use the
opentronsPython SDK instead; for multi-vendor portability use PyLabRobot.
Prerequisites
- Python packages:
pylabrobot - Optional backends:
pylabrobot[hamilton]for Hamilton STAR,pylabrobot[opentrons]for OT-2 - Data requirements: None — deck resources are defined in Python
- Environment: Python 3.9+; physical robot drivers installed separately per vendor docs
pip install pylabrobot
pip install "pylabrobot[hamilton]" # add Hamilton USB driver
pip install "pylabrobot[opentrons]" # add Opentrons REST driver
Quick Start
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L
async def main():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
plate = Cos_96_Rd(name="plate")
tips = HTF_L(name="tips")
lh.deck.assign_child_resource(plate, rails=2)
lh.deck.assign_child_resource(tips, rails=5)
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(plate["A1"], vols=50)
await lh.dispense(plate["B1"], vols=50)
await lh.drop_tips(tips["A1"])
await lh.stop()
print("Transfer complete: 50 uL from A1 -> B1")
asyncio.run(main())
Core API
Module 1: LiquidHandler — Setup and Teardown
The LiquidHandler class is the central controller. It wraps a backend and a Deck.
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck
async def main():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup() # connect to hardware / start simulator
print("LiquidHandler ready:", lh)
await lh.stop() # disconnect cleanly
asyncio.run(main())
# Connecting to a real Hamilton STAR
from pylabrobot.liquid_handling.backends.hamilton import STAR
async def main():
backend = STAR()
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
# lh is now connected to physical hardware
await lh.stop()
Module 2: Deck and Resource Assignment
Resources (plates, tip racks, reservoirs) are placed on the deck by rail position.
from pylabrobot.resources import (
Deck,
Cos_96_Rd, # Corning 96-well round-bottom plate
Cos_384_Sq, # Corning 384-well plate
HTF_L, # Hamilton tip rack (filtered, large)
Trough_1_Row_1_Col_4, # 4-channel reservoir
)
deck = Deck()
plate_96 = Cos_96_Rd(name="sample_plate")
plate_384 = Cos_384_Sq(name="assay_plate")
tips = HTF_L(name="tip_rack")
reservoir = Trough_1_Row_1_Col_4(name="buffer")
deck.assign_child_resource(plate_96, rails=1)
deck.assign_child_resource(plate_384, rails=4)
deck.assign_child_resource(tips, rails=8)
deck.assign_child_resource(reservoir, rails=11)
print("Deck resources:", [r.name for r in deck.children])
Module 3: Tip Operations
Pick up and drop tips before and after liquid operations.
# Pick up tips from the first column of the tip rack
await lh.pick_up_tips(tips["A1:H1"]) # all 8 tips in column 1
# After liquid operations, drop tips back
await lh.drop_tips(tips["A1:H1"])
# Single tip
await lh.pick_up_tips(tips["A1"])
await lh.drop_tips(tips["A1"])
print("Tip operations complete")
Module 4: Aspirate Operations
Aspirate liquid from wells. Accepts single wells, ranges, or lists.
# Aspirate 100 uL from a single well
await lh.aspirate(plate["A1"], vols=100)
# Aspirate different volumes from multiple wells simultaneously
await lh.aspirate(
plate["A1:A4"],
vols=[50, 75, 100, 125],
)
print("Aspiration complete")
from pylabrobot.resources import Coordinate
# Aspirate with flow rate and liquid height control
await lh.aspirate(
plate["A1"],
vols=50,
flow_rates=100, # uL/s
offsets=Coordinate(0, 0, 1), # 1 mm above well bottom
)
Module 5: Dispense Operations
Dispense liquid into target wells.
# Dispense 100 uL into a single well
await lh.dispense(plate["B1"], vols=100)
# Multi-well dispense with different volumes
await lh.dispense(
plate["B1:B4"],
vols=[50, 75, 100, 125],
)
print("Dispense complete")
Module 6: Transfer — High-Level Convenience
transfer combines aspirate and dispense for simple source-to-destination moves.
# Transfer 50 uL from A1 -> B1
await lh.transfer(plate["A1"], plate["B1"], transfer_volume=50)
# Multi-well pairwise transfer
sources = plate["A1:A8"]
destinations = plate["B1:B8"]
await lh.transfer(sources, destinations, transfer_volume=75)
print("Transfer complete")
Module 7: Simulation Backend
The SimulatorBackend runs a browser-based visualizer for protocol debugging.
from pylabrobot.liquid_handling.backends import SimulatorBackend
# With visual browser (default — opens http://localhost:2121)
backend = SimulatorBackend(open_browser=True)
# Headless simulation (CI/testing)
backend = SimulatorBackend(open_browser=False)
# After setup(), liquid movements are visualized in real time
await lh.setup()
# Check browser for visual confirmation before running on real hardware
print("Simulator running at http://localhost:2121")
Key Concepts
Async-First Design
All robot operations (setup, aspirate, dispense, transfer) are Python async coroutines. Run them inside an async def function using asyncio.run() or Jupyter's top-level await syntax.
import asyncio
async def run_protocol(lh, plate, tips):
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(plate["A1"], vols=50)
await lh.dispense(plate["B1"], vols=50)
await lh.drop_tips(tips["A1"])
print("Protocol complete")
asyncio.run(run_protocol(lh, plate, tips))
Well Addressing
Wells are addressed by alphanumeric position ("A1") or slice notation ("A1:H1" for a column, "A1:A12" for a row).
well = plate["A1"] # single well
col1 = plate["A1:H1"] # 8 wells in column 1
row_a = plate["A1:A12"] # 12 wells in row A
print(f"Single: {well.name}")
print(f"Column: {len(col1)} wells")
print(f"Row: {len(row_a)} wells")
Common Workflows
Workflow 1: 96-Well Serial Dilution
Goal: Perform a 2-fold serial dilution across a 96-well plate.
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L, Trough_1_Row_1_Col_4
async def serial_dilution():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
plate = Cos_96_Rd(name="plate")
tips = HTF_L(name="tips")
diluent = Trough_1_Row_1_Col_4(name="diluent")
lh.deck.assign_child_resource(plate, rails=1)
lh.deck.assign_child_resource(tips, rails=5)
lh.deck.assign_child_resource(diluent, rails=9)
# Add 100 uL diluent to columns 2-12
for col in range(2, 13):
col_label = f"A{col}:H{col}"
await lh.pick_up_tips(tips[f"A{col}:H{col}"])
await lh.aspirate(diluent["A1:H1"], vols=100)
await lh.dispense(plate[col_label], vols=100)
await lh.drop_tips(tips[f"A{col}:H{col}"])
# Serial transfer: col 1 -> 2 -> ... -> 11
for col in range(1, 12):
src = f"A{col}:H{col}"
dst = f"A{col+1}:H{col+1}"
await lh.pick_up_tips(tips[f"A{col}:H{col}"])
await lh.aspirate(plate[src], vols=100)
await lh.dispense(plate[dst], vols=100)
await lh.drop_tips(tips[f"A{col}:H{col}"])
print("Serial dilution complete: 12 columns, 2-fold steps")
await lh.stop()
asyncio.run(serial_dilution())
Workflow 2: Cherry-Picking from a Hit List
Goal: Transfer compounds from specified source wells to a destination plate based on a CSV hit list.
import asyncio
import pandas as pd
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L
async def cherry_pick(hit_list_csv: str, volume: float = 50.0):
# CSV must have columns: source_well, dest_well
hits = pd.read_csv(hit_list_csv)
print(f"Cherry-picking {len(hits)} hits at {volume} uL each")
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
src = Cos_96_Rd(name="source")
dst = Cos_96_Rd(name="destination")
tips = HTF_L(name="tips")
lh.deck.assign_child_resource(src, rails=1)
lh.deck.assign_child_resource(dst, rails=4)
lh.deck.assign_child_resource(tips, rails=8)
# Get all well names from tip rack
tip_wells = [w.name for w in tips.wells]
for i, row in hits.iterrows():
await lh.pick_up_tips(tips[tip_wells[i]])
await lh.transfer(src[row["source_well"]], dst[row["dest_well"]],
transfer_volume=volume)
await lh.drop_tips(tips[tip_wells[i]])
print(f"Cherry-pick complete: {len(hits)} transfers done")
await lh.stop()
# asyncio.run(cherry_pick("hits.csv", volume=50))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
vols |
aspirate / dispense | required | 0 – robot max (µL) |
Volume to aspirate or dispense per well |
flow_rates |
aspirate / dispense | backend default | 10 – 1000 µL/s |
Speed of liquid movement |
blow_out_air_volume |
dispense | 0 |
0 – 30 µL |
Air volume blown after dispense to empty tip |
offsets |
aspirate / dispense | Coordinate(0,0,0) |
Any Coordinate |
Positional offset from well center (x, y, z mm) |
open_browser |
SimulatorBackend | True |
True, False |
Open browser-based visual simulator on setup |
rails |
deck assignment | required | 1 – max deck rails |
Physical slot on the deck for a resource |
transfer_volume |
transfer | required | 0 – robot max (µL) |
Volume for high-level aspirate+dispense transfer |
Best Practices
-
Always test with the simulator first: Run your full protocol with
SimulatorBackend(open_browser=True)before connecting to physical hardware. The browser visualizer shows deck layout and liquid movements in real time. -
Use fresh tips for each transfer when contamination matters: Reusing tips in cherry-picking workflows risks cross-contamination. Track tip consumption against tip rack capacity programmatically.
-
Wrap protocols in try/finally for cleanup: If an exception occurs mid-protocol, always call
await lh.stop()to release hardware connections.try: await run_my_protocol(lh) finally: await lh.stop() -
Define resources once at the top of your script: Create resource objects and assign them to the deck once. Reassigning the same resource mid-run can desynchronize the robot's internal state tracking.
-
Pre-calculate volume and tip requirements: For high-throughput runs, compute total volume and tip count needed before starting, and assert that resources are sufficient.
Common Recipes
Recipe: Dispense with Post-Dispense Mixing
When to use: Reagent addition to cell culture wells requiring homogeneous mixing.
async def dispense_and_mix(lh, src, dst, tips, volume=50, mix_vol=40, mix_reps=3):
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(src["A1"], vols=volume)
await lh.dispense(dst["A1"], vols=volume)
for _ in range(mix_reps):
await lh.aspirate(dst["A1"], vols=mix_vol)
await lh.dispense(dst["A1"], vols=mix_vol)
await lh.drop_tips(tips["A1"])
print(f"Dispensed {volume} uL and mixed {mix_reps}x")
Recipe: Full-Plate Stamp
When to use: Replicate an entire 96-well plate to a second plate.
async def stamp_plate(lh, src_plate, dst_plate, tips, volume=100):
for col in range(1, 13):
col_label = f"A{col}:H{col}"
await lh.pick_up_tips(tips[col_label])
await lh.aspirate(src_plate[col_label], vols=volume)
await lh.dispense(dst_plate[col_label], vols=volume)
await lh.drop_tips(tips[col_label])
print(f"Full plate stamped: {volume} uL per well, 12 columns")
Expected Outputs
- Robot executes liquid handling operations as scripted (physical or simulated)
- Simulator at
http://localhost:2121shows animated deck with per-well volume tracking - No file outputs by default; integrate
pandas/ CSV logging in wrapper code as needed
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
RuntimeError: No backend connected |
lh.setup() not awaited before operations |
Ensure await lh.setup() completes before any liquid handling call |
ResourceNotFoundError |
Resource name not assigned to deck | Call deck.assign_child_resource(resource, rails=N) before referencing wells |
asyncio.InvalidStateError |
Coroutine called outside async context | Wrap top-level calls in async def main() and use asyncio.run(main()) |
Well address KeyError |
Incorrect well label format | Use uppercase letter + integer: "A1", "H12", not "a1" or "A01" |
VolumeError: exceeds tip capacity |
Requested volume larger than tip max | Use appropriate tip type; HTF_L holds up to 1000 µL |
| Simulator shows no movement | open_browser=False with no viewer |
Set open_browser=True or open http://localhost:2121 manually |
ImportError: pylabrobot.hamilton |
Backend extras not installed | pip install "pylabrobot[hamilton]" |
References
- PyLabRobot Documentation — official docs covering all backends and resources
- PyLabRobot GitHub — source code, examples, and issue tracker
- Azeloglu & Bhatt (2023), Cell Device — PyLabRobot paper — original publication
- PyPI: pylabrobot — installation and version history
skills/lab-automation/western-blot-quantification/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill western-blot-quantification -g -y
SKILL.md
Frontmatter
{
"name": "western-blot-quantification",
"license": "open",
"description": "Protocols and best practices for western blot quantification and analysis including band detection, normalization, and statistical methods."
}
Western Blot Quantification and Analysis
Metadata
Short Description: Comprehensive guide for quantifying and analyzing Western blot images with multiple experimental repetitions, including intensity measurement, normalization, statistical analysis, and visualization.
Authors: Ohagent Team
Version: 1.0
Last Updated: December 2025
License: CC BY 4.0
Commercial Use: ✅ Allowed
Overview
This guide provides a standardized workflow for analyzing Western blot images, particularly for experiments with multiple repetitions and conditions. The protocol covers band intensity detection, normalization procedures, statistical aggregation, and visualization best practices.
Key Concepts
Loading Control Normalization
Western blot quantification cannot use raw band intensities, because total protein loaded per lane varies between samples (pipetting error, transfer efficiency, gel artifacts). A loading control is a protein assumed to be expressed at the same level across all samples (commonly GAPDH, β-actin, α-tubulin, or a total-protein stain such as Ponceau S / stain-free imaging). Dividing the target band intensity by the loading control intensity in the same lane yields a normalized value that corrects for these per-lane technical variations. The loading control must itself be unsaturated and within the linear dynamic range of the detection system.
Two-Step Normalization
When two related signals are measured in the same blot — for example a total form (SMAD2) and its phosphorylated form (PSMAD2) — a two-step normalization disentangles changes in protein abundance from changes in modification state. Step A normalizes the total protein to a housekeeping control (SMAD2_norm = SMAD2 / GAPDH); Step B normalizes the modified form to that loading-corrected total (PSMAD2_target = PSMAD2 / SMAD2_norm). This isolates the modification-specific signal from changes in expression of the underlying protein.
Statistical Aggregation Across Repetitions
Each Western blot is one experimental observation; biological conclusions require biological replicates (independent experiments, not just multiple lanes from one gel). Aggregation steps: (1) normalize within each replicate, (2) compute fold-change relative to the within-replicate control (so the control is 1.0 by definition), (3) compute mean and dispersion (SD or SE) across replicates. Normalizing across replicates before computing fold-change inflates apparent effect size and confuses gel-to-gel variation with biological effect.
Standard Deviation vs Standard Error
SD describes the spread of the underlying biological response across replicates and is appropriate when the question is "how variable is this effect?". SE (= SD / √n) describes the precision of the estimated mean and is appropriate when the question is "how confident are we in this mean value?". For typical n=3 western blot experiments, SD bars look larger than SE bars but communicate the underlying biology more honestly. Always state which error measure is plotted in the figure legend.
Decision Framework
Western blot quantification decision tree
└── Single target protein measured?
├── Yes -> Single-step normalization: Target / LoadingControl (per lane)
│ └── Compute fold change vs control within each replicate
│ └── Aggregate mean +/- error across replicates
└── No, two related signals (e.g., total + modified form)
└── Two-step normalization
├── Step A: TotalForm_norm = TotalForm / LoadingControl (per lane)
└── Step B: ModifiedForm_target = ModifiedForm / TotalForm_norm
Error bar choice:
└── Reporting biological variability of the effect? -> SD
└── Reporting precision of the mean estimate? -> SE = SD / sqrt(n)
Experimental design choice:
└── Discrete treatments (control vs conditions) -> Multi-condition design + bar graph + ANOVA / t-tests
└── Same treatment over multiple time points -> Time course design + line graph; normalize to t0 control
└── Same treatment at multiple concentrations -> Dose response design + log-x line graph; fit EC50 / IC50
| Situation | Recommended choice | Rationale |
|---|---|---|
| Quantifying total protein abundance changes | Single-step normalization (Target / LoadingControl) | One measurement per lane; loading control corrects total-protein loading |
| Quantifying post-translational modification (phosphorylation, ubiquitination) | Two-step normalization (Modified / Total_norm) | Isolates modification stoichiometry from changes in total protein expression |
| n = 3 replicates, biology-focused figure | Mean ± SD | Communicates the spread of the biological response |
| n = 3 replicates, statistical-precision figure | Mean ± SE | Communicates the precision of the mean estimate |
| Small fold changes (~1.5×) on noisy blots | Increase n to ≥ 4–6 and report SE with explicit n in legend | Low effect size requires more replicates for adequate statistical power |
| Comparing 4+ discrete conditions | Multi-condition design + ANOVA with post-hoc correction | Pairwise t-tests across many conditions inflate Type I error |
| Tracking the same effect over time | Time-course design, normalize to t = 0 within each replicate | Removes baseline drift between replicates |
| Determining potency (EC50 / IC50) | Dose-response design with log-spaced concentrations | Log spacing samples the sigmoidal response uniformly; nonlinear fit gives EC50 |
| Loading control band saturated | Re-image at lower exposure or dilute the lysate | Saturated bands violate the linear dynamic range and silently bias normalization |
| One outlier replicate with unusually high variability | Document and exclude with justification (e.g., transfer artifact) | Honest exclusion is preferable to a noisy mean; never silently drop data |
Standard Workflow
Step 1: Image Preprocessing and Band Detection
Objective: Identify ROIs and isolate individual bands in the Western blot image.
Key Considerations:
- Use analyze_pixel_distribution then find_roi_from_image functions
- If find_roi_from_image couldn't detect ROIs properly, retry with different lower_threshold and upper_threshold parameters
- If there are still undetected ROIs, you can manually infer the coordinates of undetected ROIs by using the correctly detected ROIs (IMPORTANT: You MUST preserve the coordinates of correctly detected ROIs)
- The final image with ROIs should also be saved as an image
- Detect all bands for target proteins
- Identify experimental repetitions (typically arranged in lanes)
- Recognize different conditions (e.g., control, treatment groups)
- Handle potential artifacts, background noise, and lane alignment issues
Tools: analyze_pixel_distribution, find_roi_from_image
Step 2: Intensity Measurement
Objective: Quantify band intensities for all detected bands.
Procedure:
-
For each lane/repetition, measure the intensity of:
- Target protein band (e.g., PSMAD2)
- Loading control band (e.g., SMAD2, GAPDH)
- Background intensity (for correction if needed)
-
Record measurements in a structured format:
- Condition name (e.g., "control", "P144", "TGF-β1", "Tβ1Ab")
- Repetition number (e.g., Rep1, Rep2, Rep3)
- Protein target (e.g., PSMAD2, SMAD2, GAPDH)
- Raw intensity value
Best Practices:
- Use consistent ROI (Region of Interest) sizes for all bands
- Apply background subtraction if necessary
- Verify band detection visually before proceeding
Step 3: Normalization Procedure
Objective: Normalize target protein intensities to account for loading variations.
Two-Step Normalization Process:
Step A: Loading Control Normalization
Calculate the relative intensity of the loading control protein:
SMAD2_norm = Intensity_SMAD2 / Intensity_GAPDH
This accounts for variations in total protein loading across samples.
Step B: Target Protein Normalization
Calculate the final normalized target protein intensity:
Target_value = Intensity_PSMAD2 / SMAD2_norm
This provides the normalized PSMAD2 intensity that accounts for both loading control and relative protein levels.
Alternative Normalization Methods:
- Single loading control: If only GAPDH is available:
Target_norm = Intensity_Target / Intensity_GAPDH - Total protein normalization: If using total protein stain:
Target_norm = Intensity_Target / Intensity_TotalProtein - Housekeeping gene: Common controls include GAPDH, β-actin, α-tubulin
Step 4: Relative Quantification (Fold Change Calculation)
Objective: Express results relative to a control condition.
Procedure:
- For each experimental repetition, identify the control condition
- Calculate fold change for each condition:
Fold_Change = Target_value_condition / Target_value_control
- The control condition will have a fold change of 1.0 by definition
- Treatment conditions will show fold changes relative to control (e.g., 1.5 = 50% increase, 0.7 = 30% decrease)
Important Notes:
- Always normalize within the same repetition before comparing across repetitions
- Ensure control condition is clearly identified
- Document which condition serves as the baseline
Step 5: Statistical Aggregation
Objective: Combine data from multiple experimental repetitions.
Procedure:
- Collect normalized values (or fold changes) from all repetitions
- For each condition, calculate:
- Mean: Average across repetitions
- Standard Deviation (SD): Measure of variability
- Standard Error (SE): SD / √n, where n = number of repetitions
- Sample size (n): Number of repetitions
Statistical Considerations:
- Minimum of 3 repetitions recommended for meaningful statistics
- Report both mean and error measure (SD or SE)
- Consider statistical tests (t-test, ANOVA) if comparing multiple conditions
- Document any excluded repetitions and reasons
Step 6: Visualization
Objective: Create clear, publication-ready visualizations.
Bar Graph Requirements:
- X-axis: Experimental conditions (e.g., Control, P144, TGF-β1, Tβ1Ab)
- Y-axis: Normalized values or fold change (typically starting from 0)
- Bars: Mean values for each condition
- Error bars: Standard deviation or standard error
- Labels: Clear condition names, units, sample size (n=X)
Visualization Best Practices:
- Use consistent colors for conditions across figures
- Include statistical significance indicators if applicable (e.g., *, **, ***)
- Add figure title and axis labels
- Save in high resolution (300 DPI for publications)
Verification Images:
- Create grid/overlay images showing detected ROIs
- Helps verify correct band detection
- Useful for troubleshooting and quality control
- Save as separate file (e.g.,
wb_grid_verification.png)
Common Experimental Designs
Design 1: Multiple Conditions with Replicates
- Structure: 3-4 conditions × 3 repetitions = 9-12 lanes
- Example: Control, Treatment A, Treatment B, Treatment C (each in triplicate)
- Analysis: Normalize within each repetition, then aggregate across repetitions
Design 2: Time Course
- Structure: Multiple time points × conditions × repetitions
- Example: 0h, 6h, 12h, 24h for Control and Treatment
- Analysis: Normalize to time 0 control, then compare across time points
Design 3: Dose Response
- Structure: Multiple concentrations × repetitions
- Example: 0, 1, 5, 10, 50 μM treatment
- Analysis: Normalize to 0 concentration, plot dose-response curve
Troubleshooting
Issue: Inconsistent Band Detection
Problem: Some bands not detected or incorrectly identified Solutions:
- Adjust detection parameters (threshold, size filters)
- Manually verify and correct ROI placement
- Check image quality and contrast
- Use grid verification image to validate detection
Issue: High Variability Between Repetitions
Problem: Large standard deviations or inconsistent results Solutions:
- Verify loading control normalization is correct
- Check for technical artifacts (bubbles, uneven transfer)
- Ensure consistent sample preparation
- Consider excluding outliers if justified
Issue: Unexpected Normalization Results
Problem: Normalized values don't match expected biological response Solutions:
- Verify loading control bands are appropriate (not saturated)
- Check that control condition is correctly identified
- Ensure all calculations are performed in correct order
- Review raw intensity values for anomalies
Issue: Background Issues
Problem: High background affecting intensity measurements Solutions:
- Apply background subtraction
- Use local background measurement near each band
- Adjust image preprocessing (contrast, brightness)
- Consider re-imaging if background is too high
Quality Control Checklist
Before finalizing analysis, verify:
- All bands detected correctly (verify with grid image)
- Loading control bands are present and measurable for all samples
- Normalization calculations are correct
- Control condition is clearly identified
- All repetitions included in statistical analysis
- Error bars represent appropriate measure (SD or SE)
- Visualization clearly shows experimental design
- Results are biologically plausible
Output Files
Required Outputs:
-
Quantification results: CSV or Excel file with:
- Raw intensities
- Normalized values
- Fold changes
- Statistical summaries (mean, SD, SE)
-
Visualization: Bar graph image (e.g.,
psmad2_quantification.png)- High resolution (300 DPI minimum)
- Clear labels and error bars
- Publication-ready format
-
Verification image (optional but recommended):
wb_grid_verification.png- Shows detected ROIs
- Helps validate analysis
Example Workflow Summary
For a typical experiment with 3 repetitions and 4 conditions:
- Detect bands: Identify all PSMAD2, SMAD2, and GAPDH bands across 12 lanes
- Measure intensities: Extract intensity values for each band
- Normalize:
- Step A: SMAD2_norm = SMAD2 / GAPDH (for each sample)
- Step B: Target_value = PSMAD2 / SMAD2_norm (for each sample)
- Calculate fold change: Target_value_condition / Target_value_control (within each repetition)
- Aggregate: Calculate mean ± SD across 3 repetitions for each condition
- Visualize: Create bar graph with error bars
- Save: Export quantification table and visualization images
Key Formulas Reference
Loading Control Normalization:
Loading_norm = Intensity_LoadingControl / Intensity_Housekeeping
Target Normalization:
Target_norm = Intensity_Target / Loading_norm
Fold Change:
Fold_Change = Target_norm_condition / Target_norm_control
Statistics:
Mean = Σ(values) / n
SD = √[Σ(value - mean)² / (n-1)]
SE = SD / √n
Common Pitfalls
-
Pitfall: Reporting raw band intensities without loading-control normalization. Differences seen on the blot can be entirely explained by per-lane loading variation.
- How to avoid: Always divide by a loading-control band measured in the same lane (or a total-protein stain). Never plot raw intensities as a quantitative result.
-
Pitfall: Using a saturated loading control. A saturated GAPDH/β-actin band looks "even" but is outside the linear dynamic range, so normalization silently understates true differences.
- How to avoid: Confirm the loading control intensity is below saturation (e.g., max pixel value < 90% of detector range) before using it for normalization. Re-image at shorter exposure if needed.
-
Pitfall: Aggregating normalized values across replicates before computing fold change. This conflates gel-to-gel variation with biological signal and inflates the apparent effect size.
- How to avoid: Normalize within each replicate first, compute fold change vs the within-replicate control, then aggregate fold changes across replicates.
-
Pitfall: Plotting SE bars but labeling them SD (or vice versa). Reviewers and readers cannot interpret the figure correctly.
- How to avoid: State the error measure (SD or SE) and the n explicitly in the figure legend; if both are useful, plot SD and report SE in the text.
-
Pitfall: Drawing conclusions from n = 1 or n = 2 experiments. A single observation cannot distinguish biological effect from technical noise.
- How to avoid: Run a minimum of n = 3 independent biological replicates before quantifying effect sizes; for small fold changes (~1.5×) plan for n ≥ 4–6.
-
Pitfall: Silently excluding outlier replicates without documentation. This biases the reported mean and is irreproducible.
- How to avoid: Document the reason for any exclusion (transfer artifact, poor membrane, no signal in loading control) and report both the included-only and all-data analyses if there is any ambiguity.
-
Pitfall: Choosing a loading control that itself responds to the treatment. Some "housekeeping" proteins (e.g., GAPDH) change under metabolic stress, hypoxia, or starvation, breaking the assumption that the loading control is constant.
- How to avoid: For treatments that may affect housekeeping genes, use a total-protein stain (Ponceau S, stain-free) instead of a single housekeeping protein.
-
Pitfall: Using fixed-threshold automatic ROI detection on every image. Different exposures, contrasts, and noise floors require different thresholds; one-size-fits-all detection misses dim bands or splits strong ones.
- How to avoid: Tune
lower_thresholdandupper_thresholdper image; manually verify the grid overlay before extracting intensities, and preserve correctly detected ROIs when adjusting parameters.
- How to avoid: Tune
Best Practices
- Always normalize: Never use raw intensities without normalization. Per-lane loading variation alone can produce 2–3× apparent differences that have nothing to do with biology.
- Use appropriate controls: Choose loading controls that are stable across the specific treatments being studied. For metabolic, hypoxic, or stress treatments, prefer total-protein stains over single housekeeping proteins.
- Verify detection: Always review the grid/verification image overlay before extracting intensities. Automatic ROI detection can split or merge bands and silently introduce errors.
- Document exclusions: Note any excluded samples and the reasons. Silent exclusion biases the reported mean and breaks reproducibility.
- Report statistics: Include both the mean and an explicit error measure (SD or SE) along with n; state which error measure is plotted in the figure legend.
- Save intermediate data: Keep raw intensities, ROI coordinates, and per-replicate normalized values for potential re-analysis or supplementary deposition.
- Visual validation: Cross-check the final visualization (bar / line graph) against the qualitative impression of the original blot. If the chart and the image disagree, investigate before publishing.
References
- Pillai-Kastoori L, Schutz-Geschwender AR, Harford JA. "A systematic approach to quantitative Western blot analysis." Anal Biochem. 2020;593:113608. https://doi.org/10.1016/j.ab.2020.113608
- Taylor SC, Berkelman T, Yadav G, Hammond M. "A defined methodology for reliable quantification of Western blot data." Mol Biotechnol. 2013;55(3):217-226. https://doi.org/10.1007/s12033-013-9672-6
- Aldridge GM, Podrebarac DM, Greenough WT, Weiler IJ. "The use of total protein stains as loading controls: an alternative to high-abundance single-protein controls in semi-quantitative immunoblotting." J Neurosci Methods. 2008;172(2):250-254. https://doi.org/10.1016/j.jneumeth.2008.05.003
- Schneider CA, Rasband WS, Eliceiri KW. "NIH Image to ImageJ: 25 years of image analysis." Nat Methods. 2012;9(7):671-675. https://doi.org/10.1038/nmeth.2089
- ImageJ / Fiji documentation — Gel Analyzer: https://imagej.nih.gov/ij/docs/menus/analyze.html#gels
- LI-COR Biosciences — Western Blot Normalization Handbook: https://www.licor.com/bio/applications/quantitative_western_blots/normalization
- Bio-Rad — Stain-Free Imaging Technology technical note: https://www.bio-rad.com/en-us/applications-technologies/stain-free-imaging-technology
skills/medical-imaging/histolab-wsi-processing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill histolab-wsi-processing -g -y
SKILL.md
Frontmatter
{
"name": "histolab-wsi-processing",
"license": "Apache-2.0",
"description": "WSI processing for digital pathology. Tissue detection, tile extraction (random, grid, score-based), filter pipelines for H&E\/IHC. For dataset prep, tile-based DL, slide QC. Use pathml for multiplexed imaging."
}
Histolab WSI Processing
Overview
Histolab is a Python library for processing whole slide images (WSI) in digital pathology. It automates tissue detection, extracts informative tiles from gigapixel images using multiple strategies, and provides composable filter pipelines for preprocessing. The library handles SVS, TIFF, NDPI, and other WSI formats via OpenSlide.
When to Use
- Extracting tiles from whole slide images for deep learning model training
- Detecting tissue regions and filtering background/artifacts in histopathology slides
- Building preprocessing pipelines for H&E or IHC stained tissue sections
- Creating quality-driven tile datasets ranked by nuclei density or cellularity
- Performing batch tile extraction across slide collections with consistent parameters
- Assessing slide quality and tissue coverage before computational pathology workflows
- For raw slide access without tile extraction, use
openslide-pythondirectly - For complex multiplexed imaging or spatial proteomics pipelines, use
pathmlinstead
Prerequisites
- Python packages:
histolab(includes OpenSlide Python bindings) - System dependency: OpenSlide C library must be installed separately
- Supported formats: SVS, TIFF, NDPI, VMS, SCN, MRXS (via OpenSlide)
# macOS
brew install openslide
pip install histolab
# Ubuntu/Debian
sudo apt-get install openslide-tools
pip install histolab
Quick Start
from histolab.slide import Slide
from histolab.tiler import RandomTiler
# Load slide
slide = Slide("slide.svs", processed_path="output/")
print(f"Dimensions: {slide.dimensions}, Levels: {slide.levels}")
# Configure tiler
tiler = RandomTiler(
tile_size=(512, 512), n_tiles=100, level=0, seed=42,
check_tissue=True, tissue_percent=80.0
)
# Preview and extract
tiler.locate_tiles(slide, n_tiles=20)
tiler.extract(slide)
Core API
Module 1: Slide Management
The Slide class is the primary interface for loading and inspecting WSI files.
from histolab.slide import Slide
from histolab.data import prostate_tissue
# Load from built-in sample data (prostate, ovarian, breast, heart, kidney)
prostate_svs, prostate_path = prostate_tissue()
slide = Slide(prostate_path, processed_path="output/")
# Inspect properties
print(f"Dimensions: {slide.dimensions}") # (width, height) at level 0
print(f"Levels: {slide.levels}") # Number of pyramid levels
print(f"Level dims: {slide.level_dimensions}") # Dimensions per level
print(f"Magnification: {slide.properties.get('openslide.objective-power', 'N/A')}")
print(f"MPP-X: {slide.properties.get('openslide.mpp-x', 'N/A')}")
# Thumbnail and scaled image
slide.save_thumbnail() # Saves to processed_path
scaled = slide.scaled_image(scale_factor=32)
# Extract region at specific coordinates
region = slide.extract_region(location=(1000, 2000), size=(512, 512), level=0)
Module 2: Tissue Detection
Mask classes identify tissue regions and filter background for tile extraction.
from histolab.masks import TissueMask, BiggestTissueBoxMask, BinaryMask
import numpy as np
# TissueMask: segments ALL tissue regions (multiple sections)
tissue_mask = TissueMask()
mask_array = tissue_mask(slide) # Binary NumPy array: True=tissue, False=background
print(f"Tissue coverage: {mask_array.sum() / mask_array.size * 100:.1f}%")
# BiggestTissueBoxMask: bounding box of largest tissue region (default)
biggest_mask = BiggestTissueBoxMask()
# Visualize mask on slide thumbnail
slide.locate_mask(tissue_mask)
# Custom mask via BinaryMask subclass
class RectangularROI(BinaryMask):
def __init__(self, x, y, w, h):
self.x, self.y, self.w, self.h = x, y, w, h
def _mask(self, slide):
thumb = slide.thumbnail
mask = np.zeros(thumb.shape[:2], dtype=bool)
mask[self.y:self.y+self.h, self.x:self.x+self.w] = True
return mask
Module 3: Tile Extraction
Three strategies for extracting tiles: random sampling, grid coverage, and score-based selection.
from histolab.tiler import RandomTiler, GridTiler, ScoreTiler
from histolab.scorer import NucleiScorer
from histolab.masks import TissueMask
# RandomTiler: fixed number of randomly positioned tiles
random_tiler = RandomTiler(
tile_size=(512, 512), n_tiles=100, level=0,
seed=42, check_tissue=True, tissue_percent=80.0
)
random_tiler.locate_tiles(slide, n_tiles=20) # Preview first
random_tiler.extract(slide)
# GridTiler: systematic grid coverage
grid_tiler = GridTiler(
tile_size=(512, 512), level=0,
pixel_overlap=0, check_tissue=True, tissue_percent=70.0
)
grid_tiler.extract(slide, extraction_mask=TissueMask())
# ScoreTiler: top-ranked tiles by scoring function
score_tiler = ScoreTiler(
tile_size=(512, 512), n_tiles=50, level=0,
scorer=NucleiScorer(), check_tissue=True
)
score_tiler.extract(slide, report_path="tiles_report.csv")
# Report CSV: tile_name, x_coord, y_coord, level, score, tissue_percent
Module 4: Filters and Preprocessing
Composable image and morphological filters for tissue detection and preprocessing.
from histolab.filters.image_filters import (
RgbToGrayscale, RgbToHsv, RgbToHed,
OtsuThreshold, AdaptiveThreshold,
StretchContrast, HistogramEqualization, Invert
)
from histolab.filters.morphological_filters import (
BinaryDilation, BinaryErosion, BinaryOpening, BinaryClosing,
RemoveSmallObjects, RemoveSmallHoles
)
from histolab.filters.compositions import Compose
# Standard tissue detection pipeline
tissue_pipeline = Compose([
RgbToGrayscale(),
OtsuThreshold(),
BinaryDilation(disk_size=5),
RemoveSmallHoles(area_threshold=1000),
RemoveSmallObjects(area_threshold=500)
])
# Use custom pipeline with TissueMask
from histolab.masks import TissueMask
custom_mask = TissueMask(filters=tissue_pipeline)
# Stain deconvolution (H&E)
hed_filter = RgbToHed() # Hematoxylin-Eosin-DAB separation
# Apply filters to individual tiles
from histolab.tile import Tile
filter_chain = Compose([RgbToGrayscale(), StretchContrast()])
filtered_tile = tile.apply_filters(filter_chain)
# Lambda for custom inline filters
from histolab.filters.image_filters import Lambda
import numpy as np
brightness = Lambda(lambda img: np.clip(img * 1.2, 0, 255).astype(np.uint8))
red_channel = Lambda(lambda img: img[:, :, 0])
Module 5: Scoring
Scorers rank tiles by tissue content quality for use with ScoreTiler.
from histolab.scorer import NucleiScorer, CellularityScorer, Scorer
import numpy as np
# Built-in scorers
nuclei = NucleiScorer() # Scores by nuclei density (grayscale threshold + count)
cellularity = CellularityScorer() # Scores by overall cellular content
# Custom scorer
class ColorVarianceScorer(Scorer):
def __call__(self, tile):
"""Score tiles by color variance (higher = more informative)."""
tile_array = np.array(tile.image)
return np.var(tile_array, axis=(0, 1)).sum()
score_tiler = ScoreTiler(
tile_size=(512, 512), n_tiles=30,
scorer=ColorVarianceScorer()
)
Module 6: Visualization
Built-in methods and matplotlib patterns for inspecting slides, masks, and tiles.
import matplotlib.pyplot as plt
from histolab.masks import TissueMask
# Built-in: mask overlay on slide thumbnail
slide.locate_mask(TissueMask())
# Built-in: tile location preview
tiler.locate_tiles(slide, n_tiles=20)
# Manual side-by-side: slide vs mask
mask = TissueMask()
mask_array = mask(slide)
fig, axes = plt.subplots(1, 2, figsize=(15, 7))
axes[0].imshow(slide.thumbnail); axes[0].set_title("Slide"); axes[0].axis('off')
axes[1].imshow(mask_array, cmap='gray'); axes[1].set_title("Mask"); axes[1].axis('off')
plt.tight_layout()
plt.show()
# Display extracted tiles in grid
from pathlib import Path
from PIL import Image
tile_paths = list(Path("output/tiles/").glob("*.png"))[:16]
fig, axes = plt.subplots(4, 4, figsize=(12, 12))
for idx, tp in enumerate(axes.ravel()):
if idx < len(tile_paths):
tp.imshow(Image.open(tile_paths[idx]))
tp.set_title(tile_paths[idx].stem, fontsize=8)
tp.axis('off')
plt.tight_layout()
plt.show()
Key Concepts
WSI Pyramid Levels
Whole slide images use a pyramidal structure with multiple resolution levels. Level 0 is the highest resolution (native scan). Higher levels provide progressively lower resolutions for faster access.
for level in range(slide.levels):
dims = slide.level_dimensions[level]
downsample = slide.level_downsamples[level]
print(f"Level {level}: {dims}, downsample: {downsample:.0f}x")
# Level 0: (98304, 221184), downsample: 1x
# Level 1: (24576, 55296), downsample: 4x
Filter Composition Pattern
Filters are designed to be chained via Compose. The output of one filter becomes the input of the next. Image filters operate on RGB/grayscale arrays; morphological filters operate on binary arrays. Order matters: always convert to the expected input type before applying downstream filters.
Mask-Tiler Integration
All tilers accept an extraction_mask parameter. The default is BiggestTissueBoxMask(). Override with TissueMask() for multi-section slides or a custom BinaryMask subclass for ROI-specific extraction.
Common Workflows
Workflow 1: Exploratory Slide Analysis
Goal: Quickly inspect a slide, detect tissue, and sample diverse regions for review.
from histolab.slide import Slide
from histolab.tiler import RandomTiler
from histolab.masks import TissueMask
import matplotlib.pyplot as plt
import logging
logging.basicConfig(level=logging.INFO)
slide = Slide("slide.svs", processed_path="output/exploratory/")
print(f"Dimensions: {slide.dimensions}, Levels: {slide.levels}")
slide.save_thumbnail()
# Visualize tissue detection
tissue_mask = TissueMask()
slide.locate_mask(tissue_mask)
mask_arr = tissue_mask(slide)
print(f"Tissue coverage: {mask_arr.sum() / mask_arr.size * 100:.1f}%")
# Sample tiles
tiler = RandomTiler(
tile_size=(512, 512), n_tiles=50, level=0,
seed=42, check_tissue=True, tissue_percent=80.0
)
tiler.locate_tiles(slide, n_tiles=20)
tiler.extract(slide)
Workflow 2: Deep Learning Dataset Preparation
Goal: Build a quality-controlled tile dataset from multiple slides for model training.
from pathlib import Path
from histolab.slide import Slide
from histolab.tiler import ScoreTiler
from histolab.scorer import NucleiScorer
import pandas as pd
import logging
logging.basicConfig(level=logging.INFO)
slide_dir = Path("slides/")
output_base = Path("output/dataset/")
all_reports = []
tiler = ScoreTiler(
tile_size=(512, 512), n_tiles=100, level=0,
scorer=NucleiScorer(), check_tissue=True, tissue_percent=80.0
)
for slide_path in sorted(slide_dir.glob("*.svs")):
out_dir = output_base / slide_path.stem
out_dir.mkdir(parents=True, exist_ok=True)
slide = Slide(str(slide_path), processed_path=str(out_dir))
slide.save_thumbnail()
report_path = str(out_dir / "report.csv")
tiler.extract(slide, report_path=report_path)
df = pd.read_csv(report_path)
df["slide"] = slide_path.stem
all_reports.append(df)
print(f"{slide_path.stem}: {len(df)} tiles, mean score {df['score'].mean():.3f}")
combined = pd.concat(all_reports, ignore_index=True)
combined.to_csv(output_base / "dataset_manifest.csv", index=False)
print(f"Total: {len(combined)} tiles from {len(all_reports)} slides")
Workflow 3: Custom Tissue Detection with Artifact Removal
Goal: Handle slides with pen annotations or unusual staining using custom filter pipelines.
from histolab.slide import Slide
from histolab.masks import TissueMask
from histolab.tiler import GridTiler
from histolab.filters.compositions import Compose
from histolab.filters.image_filters import RgbToGrayscale, OtsuThreshold
from histolab.filters.morphological_filters import (
BinaryDilation, RemoveSmallObjects, RemoveSmallHoles
)
# Aggressive artifact removal pipeline
aggressive_filters = Compose([
RgbToGrayscale(),
OtsuThreshold(),
BinaryDilation(disk_size=10),
RemoveSmallHoles(area_threshold=5000),
RemoveSmallObjects(area_threshold=3000)
])
custom_mask = TissueMask(filters=aggressive_filters)
slide = Slide("artifact_slide.svs", processed_path="output/clean/")
# Compare default vs custom mask
slide.locate_mask(TissueMask()) # Default
slide.locate_mask(custom_mask) # Custom (tighter)
# Extract with custom mask
grid_tiler = GridTiler(
tile_size=(512, 512), level=1,
check_tissue=True, tissue_percent=70.0
)
grid_tiler.extract(slide, extraction_mask=custom_mask)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
tile_size |
All Tilers | (512, 512) |
Any (w, h) tuple |
Tile dimensions in pixels |
level |
All Tilers | 0 |
0 to slide.levels-1 |
Pyramid level (0=highest resolution) |
check_tissue |
All Tilers | True |
True/False |
Filter tiles by tissue content |
tissue_percent |
All Tilers | 80.0 |
0.0-100.0 |
Minimum tissue coverage threshold |
n_tiles |
Random/ScoreTiler | varies | Any positive int | Number of tiles to extract |
seed |
RandomTiler | None |
Any int | Random seed for reproducibility |
pixel_overlap |
GridTiler | 0 |
0+ |
Overlap between adjacent tiles in pixels |
scorer |
ScoreTiler | required | NucleiScorer(), CellularityScorer(), custom |
Scoring function for tile ranking |
extraction_mask |
All Tilers | BiggestTissueBoxMask() |
Any BinaryMask |
Mask defining valid extraction region |
disk_size |
Morphological filters | 5 |
1-20 |
Structuring element size |
area_threshold |
RemoveSmall* | 500/1000 |
0+ |
Minimum area for objects/holes in pixels |
Best Practices
-
Always preview before extracting: Call
locate_tiles()andlocate_mask()to validate settings before committing to full extraction. This saves hours on large slide collections. -
Match level to analysis resolution: Level 0 provides maximum detail but is slow; level 1-2 is typically sufficient for initial analysis. Use level 0 only for tasks requiring cellular detail.
-
Choose the right tiler strategy:
RandomTilerfor exploratory analysis and diverse samplingGridTilerfor complete coverage and spatial analysisScoreTilerfor quality-driven dataset curation
-
Use seeds for reproducibility: Always set
seedinRandomTilerto ensure consistent tile selection across runs. -
Customize masks for specific stains: H&E and IHC stains have different color profiles. Adjust filter parameters or build custom
Composepipelines for non-standard stains. -
Anti-pattern -- Don't use TissueMask when BiggestTissueBoxMask suffices:
TissueMaskis more computationally expensive. Use it only when the slide has multiple tissue sections. -
Enable logging for batch processing:
logging.basicConfig(level=logging.INFO)provides progress tracking during extraction. -
Generate CSV reports with ScoreTiler: Use
report_pathinextract()to create metadata manifests for downstream ML pipelines. -
Anti-pattern -- Don't extract at level 0 for initial exploration: Use level 1 or 2 for fast iteration, then switch to level 0 for final dataset generation.
Common Recipes
Recipe: Nuclei Enhancement Pipeline
When to use: Isolate and enhance nuclei signal from H&E stained sections for analysis.
from histolab.filters.image_filters import RgbToHed, HistogramEqualization, Lambda
from histolab.filters.compositions import Compose
nuclei_pipeline = Compose([
RgbToHed(),
Lambda(lambda hed: hed[:, :, 0]), # Extract hematoxylin channel
HistogramEqualization()
])
# Apply to slide thumbnail for visualization
enhanced = nuclei_pipeline(slide.thumbnail)
Recipe: Score Distribution Analysis
When to use: Assess tile quality distribution and identify optimal score thresholds.
import pandas as pd
import matplotlib.pyplot as plt
report = pd.read_csv("tiles_report.csv")
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].hist(report['score'], bins=30, edgecolor='black', alpha=0.7)
axes[0].set_xlabel('Tile Score'); axes[0].set_ylabel('Frequency')
axes[0].set_title('Score Distribution')
axes[1].scatter(report['tissue_percent'], report['score'], alpha=0.5)
axes[1].set_xlabel('Tissue %'); axes[1].set_ylabel('Score')
axes[1].set_title('Score vs Tissue Coverage')
plt.tight_layout()
plt.savefig("quality_analysis.png", dpi=150, bbox_inches='tight')
plt.show()
Recipe: Multi-Level Hierarchical Extraction
When to use: Extract tiles at multiple magnification levels from the same locations for multi-scale analysis.
from histolab.tiler import RandomTiler
for level in [0, 1, 2]:
tiler = RandomTiler(
tile_size=(512, 512), n_tiles=50,
level=level, seed=42, # Same seed = same locations
prefix=f"level{level}_"
)
tiler.extract(slide)
print(f"Extracted level {level} tiles")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
OpenSlideError: Unsupported format |
OpenSlide C library not installed or slide format unsupported | Install OpenSlide system library; verify format with openslide-show-properties |
| No tiles extracted | tissue_percent too high or mask misses tissue |
Lower tissue_percent (try 60-70%); preview mask with locate_mask() |
| Many background tiles | check_tissue=False or poor mask |
Enable check_tissue=True; increase tissue_percent; use TissueMask() |
| Extraction very slow | Level 0 on large slide or TissueMask on many sections |
Extract at level 1-2; use BiggestTissueBoxMask; reduce n_tiles |
| Tiles have pen artifacts | Default mask includes pen marks | Build custom filter with HSV-based pen detection; increase RemoveSmallObjects threshold |
MemoryError during extraction |
Level 0 tile access on very large WSI | Extract at lower level; process fewer tiles per batch |
| Inconsistent results across runs | Missing seed in RandomTiler |
Always set seed parameter |
| Tiles too small/large for model | tile_size mismatch with model input |
Adjust tile_size to match model requirements (commonly 224, 256, 512) |
Bundled Resources
references/filters_preprocessing.md
Comprehensive filter reference covering all image filters (RgbToGrayscale, RgbToHsv, RgbToHed, OtsuThreshold, AdaptiveThreshold, Invert, StretchContrast, HistogramEqualization, Lambda) and morphological filters (BinaryDilation, BinaryErosion, BinaryOpening, BinaryClosing, RemoveSmallObjects, RemoveSmallHoles) with individual code examples, use-case descriptions, and common preprocessing pipelines (tissue detection, pen removal, nuclei enhancement, stain normalization).
- Covers: All filter types with individual code blocks,
Composechaining, quality control filters, custom mask integration - Relocated inline: Standard tissue detection pipeline and Lambda filter basics moved to Core API Module 4
- Omitted: Filter effect visualization step-by-step (covered in references/visualization_slides.md); best practices list partially consolidated into main Best Practices section
references/tile_extraction.md
Detailed tile extraction reference covering all three tiler strategies (RandomTiler, GridTiler, ScoreTiler) with full parameter documentation, all built-in scorers (NucleiScorer, CellularityScorer), custom scorer creation, extraction workflows with logging and CSV reporting, and advanced patterns (multi-level, hierarchical, post-extraction blur filtering).
- Covers: Per-tiler parameters and use cases, scorer API, tile preview, extraction with reports, advanced patterns, performance optimization
- Relocated inline: Basic tiler usage and scorer creation moved to Core API Modules 3 and 5; common parameters table moved to Key Parameters
- Omitted: ASCII grid pattern diagrams (trivial visual aid)
references/visualization_slides.md
Consolidated visualization, slide management, and tissue mask reference. Covers slide inspection workflows, mask comparison visualization, tile grid display, quality assessment (score distributions, top/bottom tile comparison), multi-slide collection thumbnails, tissue coverage bar charts, filter effect visualization pipeline, PDF report generation, and interactive Jupyter widgets.
- Source: Consolidates visualization.md (547 lines) + slide_management.md (172 lines) + tissue_masks.md (251 lines)
- Covers: All visualization patterns from original, slide inspection workflow, sample datasets, pyramid level enumeration, mask classes and customization, annotation exclusion pattern
- Relocated inline: Core slide properties and mask basics moved to Core API Modules 1-2; thumbnail display and basic locate_mask/locate_tiles moved to Module 6
- Omitted: Slide name/path trivial properties (2 lines); custom tile location visualization with manual coordinate calculation (conceptual only, not practical)
Original reference file disposition (5 files):
- slide_management.md (172 lines) -- (b) Consolidated: core content into Core API Module 1 (Slide class, properties, sample data, thumbnails, regions, pyramid levels); advanced slide inspection and multi-slide workflow into references/visualization_slides.md
- tissue_masks.md (251 lines) -- (b) Consolidated: core mask classes and usage into Core API Module 2; custom masks (RectangularMask, AnnotationExclusionMask) and mask comparison into references/visualization_slides.md
- tile_extraction.md (421 lines) -- (a) Migrated to references/tile_extraction.md with condensation
- filters_preprocessing.md (514 lines) -- (a) Migrated to references/filters_preprocessing.md with condensation
- visualization.md (547 lines) -- (a) Migrated to references/visualization_slides.md (consolidated with slide_management.md and tissue_masks.md content)
Related Skills
- pathml-spatial-omics (planned) -- advanced multiplexed imaging and spatial proteomics
- matplotlib-scientific-plotting -- publication-quality figure generation from histolab outputs
References
- Histolab documentation -- official API reference and tutorials
- Histolab GitHub repository -- source code and examples
- OpenSlide -- underlying C library for WSI format support
- TCGA sample data -- source of built-in sample datasets
skills/medical-imaging/nnunet-segmentation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill nnunet-segmentation -g -y
SKILL.md
Frontmatter
{
"name": "nnunet-segmentation",
"license": "Apache-2.0",
"description": "Medical image segmentation with nnU-Net's self-configuring framework — auto-selects architecture, preprocessing, training for any modality. CT, MRI, microscopy, ultrasound in 2D, 3D full-res, 3D low-res, cascade. Pipeline: convert → plan\/preprocess → train (5-fold CV) → best config → predict → ensemble. Use when classical segmentation fails and annotated data exists."
}
nnU-Net Automated Medical Image Segmentation
Overview
nnU-Net (no-new-Net) is a self-configuring deep learning framework for biomedical image segmentation. Given a labeled training dataset, nnU-Net automatically determines the optimal network architecture (2D, 3D full-resolution, or 3D cascade), preprocessing steps (resampling, normalization, patch size), training schedule, and post-processing. It consistently achieves state-of-the-art performance across diverse imaging modalities and anatomical structures without manual hyperparameter tuning. nnU-Net v2 (nnunetv2) is the current release with a Python API for inference alongside the standard CLI.
When to Use
- Segmenting anatomical structures in CT or MRI scans (organs, tumors, lesions) when you have 20+ annotated training cases
- Automating cell or nucleus segmentation in 3D fluorescence or electron microscopy volumes
- Establishing a strong baseline for any new segmentation challenge without manually tuning a U-Net
- Running inference on new images using a pretrained nnU-Net model from a published challenge
- Comparing segmentation methods: nnU-Net's auto-configured ensembles serve as a rigorous baseline
- Building production segmentation pipelines where training is done once and inference is repeated on many new cases
- Use Cellpose (
cellpose-cell-segmentation) instead for 2D fluorescence cell segmentation without labeled training data; nnU-Net requires annotated training cases - Use SimpleITK (
simpleitk-image-registration) instead for rule-based segmentation with classical thresholding and region growing on images where deep learning training data is unavailable
Prerequisites
- Python packages:
nnunetv2>=2.2,torch>=2.0(with CUDA for GPU training) - Data requirements: Images in NIfTI format (
.nii.gz); binary or multi-class label masks; minimum 20 training cases recommended (50+ for best performance) - Environment variables:
nnUNet_raw,nnUNet_preprocessed,nnUNet_resultsmust be set - Hardware: GPU with 8+ GB VRAM recommended for training; CPU inference is supported but slow
- Environment: Python 3.9+; Linux or macOS (Windows supported but not officially recommended)
pip install nnunetv2
# Verify installation
nnUNetv2_train --help
# Set required environment variables (add to ~/.bashrc or ~/.zshrc)
export nnUNet_raw=/data/nnUNet_raw
export nnUNet_preprocessed=/data/nnUNet_preprocessed
export nnUNet_results=/data/nnUNet_results
mkdir -p $nnUNet_raw $nnUNet_preprocessed $nnUNet_results
Quick Start
# Minimal end-to-end pipeline: convert dataset → preprocess → train → predict
# Assumes dataset is in Medical Segmentation Decathlon format
# 1. Set environment
export nnUNet_raw=/data/nnUNet_raw
export nnUNet_preprocessed=/data/nnUNet_preprocessed
export nnUNet_results=/data/nnUNet_results
# 2. Convert Medical Segmentation Decathlon dataset (dataset ID 7 = Pancreas)
nnUNetv2_convert_MSD_dataset -i /data/Task07_Pancreas -overwrite_id 7
# 3. Plan preprocessing and verify dataset integrity
nnUNetv2_plan_and_preprocess -d 7 --verify_dataset_integrity
# 4. Train 3D full-res model, fold 0 (of 5-fold cross-validation)
nnUNetv2_train 7 3d_fullres 0 --npz
# 5. Predict on new images
nnUNetv2_predict -i /data/test_images/ -o /data/predictions/ -d 7 -c 3d_fullres -f 0
echo "Segmentation predictions saved to /data/predictions/"
Workflow
Step 1: Prepare Dataset in nnU-Net Format
nnU-Net requires images in NIfTI format organized in a specific directory structure with a dataset.json descriptor.
# Dataset directory structure (Dataset007_Pancreas as example):
# $nnUNet_raw/
# └── Dataset007_Pancreas/
# ├── dataset.json ← metadata descriptor
# ├── imagesTr/ ← training images
# │ ├── pancreas_001_0000.nii.gz (0000 = channel/modality index)
# │ └── pancreas_002_0000.nii.gz
# ├── labelsTr/ ← training segmentation masks
# │ ├── pancreas_001.nii.gz
# │ └── pancreas_002.nii.gz
# └── imagesTs/ ← test images (no labels required)
# └── pancreas_101_0000.nii.gz
# For Medical Segmentation Decathlon datasets, convert automatically:
nnUNetv2_convert_MSD_dataset -i /data/Task07_Pancreas -overwrite_id 7
echo "Dataset 7 created at $nnUNet_raw/Dataset007_Pancreas/"
import json
from pathlib import Path
import shutil
# Create dataset.json for a custom dataset (single CT modality, 2-class segmentation)
dataset_id = 8
dataset_name = f"Dataset{dataset_id:03d}_MyOrgan"
dataset_dir = Path(f"{dataset_name}")
(dataset_dir / "imagesTr").mkdir(parents=True, exist_ok=True)
(dataset_dir / "labelsTr").mkdir(parents=True, exist_ok=True)
(dataset_dir / "imagesTs").mkdir(parents=True, exist_ok=True)
dataset_json = {
"channel_names": {
"0": "CT" # for MRI: "0": "T1", "1": "T2" (multi-modal = multiple channels)
},
"labels": {
"background": 0,
"organ": 1 # add more classes: "tumor": 2, "vessel": 3
},
"numTraining": 50, # number of training cases
"file_ending": ".nii.gz"
}
with open(dataset_dir / "dataset.json", "w") as f:
json.dump(dataset_json, f, indent=2)
print(f"Created dataset.json with {dataset_json['numTraining']} training cases")
print(f"Labels: {dataset_json['labels']}")
print(f"Channels: {dataset_json['channel_names']}")
print(f"\nPlace training images as: imagesTr/case_NNN_0000.nii.gz")
print(f"Place training labels as: labelsTr/case_NNN.nii.gz")
Step 2: Plan and Preprocess
nnU-Net analyzes the dataset fingerprint (image intensities, spacings, sizes) to automatically configure architectures and preprocessing. This step can take 30–120 minutes for large datasets.
# Plan preprocessing for dataset 7 with integrity checks
nnUNetv2_plan_and_preprocess -d 7 --verify_dataset_integrity
# This creates:
# $nnUNet_preprocessed/Dataset007_Pancreas/
# ├── nnUNetPlans.json ← architecture configurations (patch size, batch size, etc.)
# ├── dataset_fingerprint.json ← image statistics used for auto-configuration
# ├── nnUNetPlans_2d/ ← preprocessed 2D slices
# ├── nnUNetPlans_3d_fullres/ ← preprocessed 3D full-resolution patches
# └── nnUNetPlans_3d_lowres/ ← preprocessed 3D low-resolution patches (if applicable)
echo "Preprocessing complete. Plans stored in $nnUNet_preprocessed/Dataset007_Pancreas/"
import json
from pathlib import Path
import os
# Inspect the auto-generated plans to understand what nnU-Net configured
preprocessed_dir = Path(os.environ["nnUNet_preprocessed"])
plans_file = preprocessed_dir / "Dataset007_Pancreas" / "nnUNetPlans.json"
with open(plans_file) as f:
plans = json.load(f)
print("nnU-Net auto-configuration summary:")
print(f" Dataset name: {plans['dataset_name']}")
print(f" Original spacing (mm): {plans['original_median_spacing_after_transp']}")
for config_name, config in plans["configurations"].items():
print(f"\n Configuration: {config_name}")
print(f" Patch size: {config.get('patch_size', 'N/A')}")
print(f" Batch size: {config.get('batch_size', 'N/A')}")
print(f" Spacing: {config.get('spacing', 'N/A')}")
print(f" Network arch: {config.get('network_arch_class_name', 'N/A')}")
Step 3: Train (5-Fold Cross-Validation)
nnU-Net uses 5-fold cross-validation by default. Train all 5 folds per configuration for the best ensemble performance, or train fold 0 only for a quick first model.
# Train 3D full-resolution configuration, fold 0 (on GPU)
# --npz saves softmax predictions for ensembling
nnUNetv2_train 7 3d_fullres 0 --npz
# Train all 5 folds (submit as 5 parallel jobs on a cluster)
for fold in 0 1 2 3 4; do
nnUNetv2_train 7 3d_fullres $fold --npz
done
# Also train 2D configuration for potential ensemble
nnUNetv2_train 7 2d 0 --npz
echo "Training outputs in $nnUNet_results/Dataset007_Pancreas/"
# Resume training from a checkpoint (if interrupted)
nnUNetv2_train 7 3d_fullres 0 --npz --c
# Training on CPU only (slow — for testing only)
nnUNetv2_train 7 3d_fullres 0 --npz --device cpu
# Monitor GPU memory and training progress
watch -n 10 nvidia-smi
# Check training log for loss curves
tail -f $nnUNet_results/Dataset007_Pancreas/nnUNetTrainer__nnUNetPlans__3d_fullres/fold_0/training_log.txt
Step 4: Find Best Configuration
After training, nnU-Net evaluates all trained configurations and their ensembles to recommend the best single model or ensemble.
# Determine which configuration or ensemble performs best on validation folds
# Must have trained multiple folds (0-4) for accurate comparison
nnUNetv2_find_best_configuration 7 -c 2d 3d_fullres
# Output example:
# Best configuration: 3d_fullres
# Best ensemble: 2d + 3d_fullres (Dice = 0.823 vs 3d_fullres alone = 0.814)
# Recommended: ensemble of 2d + 3d_fullres
# Also run post-processing determination
# (removes small connected components if it helps validation Dice)
nnUNetv2_apply_postprocessing --help
import json
from pathlib import Path
import os
# Parse cross-validation results to see per-fold performance
results_dir = Path(os.environ["nnUNet_results"])
summary_file = results_dir / "Dataset007_Pancreas" / \
"nnUNetTrainer__nnUNetPlans__3d_fullres" / "fold_0" / "validation" / "summary.json"
if summary_file.exists():
with open(summary_file) as f:
summary = json.load(f)
metrics = summary["foreground_mean"]
print("3D full-res fold_0 validation metrics:")
for metric_name, value in metrics.items():
print(f" {metric_name}: {value:.4f}")
# Expected output:
# Dice: 0.81
# IoU: 0.68
# HD95: 12.3 (mm)
else:
print(f"Summary not found at {summary_file}")
print("Run nnUNetv2_train first to generate validation metrics")
Step 5: Predict on New Images
Run inference on a folder of new images using the trained model.
# Predict using the best single configuration (fold 0 only, fast)
nnUNetv2_predict \
-i /data/test_images/ \
-o /data/predictions_3d/ \
-d 7 \
-c 3d_fullres \
-f 0 \
--save_probabilities # save softmax probabilities for later ensembling
# Predict using all 5 folds (better performance, slower)
nnUNetv2_predict \
-i /data/test_images/ \
-o /data/predictions_allfolds/ \
-d 7 \
-c 3d_fullres \
-f 0 1 2 3 4
echo "Predictions saved to /data/predictions_allfolds/"
import torch
from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
from pathlib import Path
import os
# Python API for inference — useful for integration into custom pipelines
results_dir = os.environ["nnUNet_results"]
model_folder = f"{results_dir}/Dataset007_Pancreas/nnUNetTrainer__nnUNetPlans__3d_fullres"
# Initialize predictor
predictor = nnUNetPredictor(
tile_step_size=0.5, # overlap fraction between tiles (higher = better quality, slower)
use_gaussian=True, # Gaussian weighting at tile edges (reduces boundary artifacts)
use_mirroring=True, # test-time augmentation with flips (improves accuracy)
device=torch.device("cuda", 0), # use GPU 0; set to torch.device("cpu") for CPU
verbose=False,
allow_tqdm=True,
)
predictor.initialize_from_trained_model_folder(
model_folder,
use_folds=(0,), # which folds to use; (0,1,2,3,4) for ensemble of all folds
checkpoint_name="checkpoint_best.pth", # or "checkpoint_final.pth"
)
# Predict from files
input_folder = "/data/test_images/"
output_folder = "/data/predictions/"
Path(output_folder).mkdir(exist_ok=True)
predictor.predict_from_files(
list_of_lists_or_source_folder=input_folder,
output_folder_or_list_of_truncated_output_files=output_folder,
save_probabilities=False,
overwrite=True,
num_processes_preprocessing=2,
num_processes_segmentation_export=2,
)
print(f"Python API inference complete → {output_folder}")
Step 6: Ensemble Predictions
Combine predictions from multiple configurations (e.g., 2D + 3D full-res) or folds to improve segmentation accuracy.
# Ensemble predictions from two configurations
# Both must have been predicted with --save_probabilities
nnUNetv2_ensemble \
-i /data/predictions_2d/ /data/predictions_3d/ \
-o /data/predictions_ensemble/ \
-np 4
echo "Ensemble predictions saved to /data/predictions_ensemble/"
# Apply post-processing (e.g., remove small connected components)
# Post-processing file generated during nnUNetv2_find_best_configuration
nnUNetv2_apply_postprocessing \
-i /data/predictions_ensemble/ \
-o /data/predictions_final/ \
-pp_pkl_file $nnUNet_results/Dataset007_Pancreas/nnUNetTrainer__nnUNetPlans__3d_fullres/crossval_results_folds_0_1_2_3_4/postprocessing.pkl \
-np 4 \
-plans_json $nnUNet_preprocessed/Dataset007_Pancreas/nnUNetPlans.json
echo "Post-processing complete → /data/predictions_final/"
Step 7: Evaluate Segmentation Quality
Compute Dice scores and Hausdorff distances between predicted and ground-truth segmentations.
import SimpleITK as sitk
import numpy as np
import pandas as pd
from pathlib import Path
def compute_dice(pred_path: str, gt_path: str, label_value: int = 1) -> dict:
"""Compute Dice coefficient and Hausdorff distance for a single case."""
pred = sitk.GetArrayFromImage(sitk.ReadImage(pred_path))
gt = sitk.GetArrayFromImage(sitk.ReadImage(gt_path))
pred_bin = (pred == label_value).astype(bool)
gt_bin = (gt == label_value).astype(bool)
intersection = (pred_bin & gt_bin).sum()
dice = 2.0 * intersection / (pred_bin.sum() + gt_bin.sum() + 1e-8)
# Surface distance for Hausdorff (using SimpleITK)
pred_sitk = sitk.ReadImage(pred_path)
gt_sitk = sitk.ReadImage(gt_path)
pred_label = sitk.BinaryThreshold(pred_sitk, label_value, label_value, 1, 0)
gt_label = sitk.BinaryThreshold(gt_sitk, label_value, label_value, 1, 0)
hausdorff_filter = sitk.HausdorffDistanceImageFilter()
hausdorff_filter.Execute(pred_label, gt_label)
hd95 = hausdorff_filter.GetAverageHausdorffDistance()
return {
"dice": dice,
"hd_avg_mm": hd95,
"pred_volume_ml": pred_bin.sum() * np.prod(pred_sitk.GetSpacing()) / 1000,
"gt_volume_ml": gt_bin.sum() * np.prod(gt_sitk.GetSpacing()) / 1000,
}
# Evaluate all predictions
pred_dir = Path("/data/predictions_ensemble/")
gt_dir = Path("/data/test_labels/")
rows = []
for pred_file in sorted(pred_dir.glob("*.nii.gz")):
gt_file = gt_dir / pred_file.name
if gt_file.exists():
metrics = compute_dice(str(pred_file), str(gt_file), label_value=1)
metrics["case"] = pred_file.stem
rows.append(metrics)
df = pd.DataFrame(rows)
print("Segmentation evaluation summary:")
print(df[["case", "dice", "hd_avg_mm", "pred_volume_ml", "gt_volume_ml"]].to_string(index=False))
print(f"\nMean Dice: {df['dice'].mean():.3f} ± {df['dice'].std():.3f}")
print(f"Mean Avg HD: {df['hd_avg_mm'].mean():.1f} ± {df['hd_avg_mm'].std():.1f} mm")
df.to_csv("segmentation_evaluation.csv", index=False)
print("Saved: segmentation_evaluation.csv")
Step 8: Visualize Segmentation Results
Overlay predicted segmentations on input images for visual quality control.
import SimpleITK as sitk
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from pathlib import Path
def visualize_segmentation(image_path: str, pred_path: str, gt_path: str = None,
output_path: str = "segmentation_qc.png",
n_slices: int = 5, label_value: int = 1):
"""Create a multi-slice overlay figure for visual QC."""
image = sitk.GetArrayFromImage(sitk.ReadImage(image_path)) # shape: (z, y, x)
pred = sitk.GetArrayFromImage(sitk.ReadImage(pred_path))
# Select evenly spaced slices where segmentation is present
label_slices = np.where((pred == label_value).any(axis=(1, 2)))[0]
if len(label_slices) == 0:
print("No foreground voxels found in prediction")
return
step = max(1, len(label_slices) // n_slices)
selected_slices = label_slices[::step][:n_slices]
n_cols = n_slices
n_rows = 2 if gt_path is None else 3
fig, axes = plt.subplots(n_rows, n_cols, figsize=(4 * n_cols, 4 * n_rows))
# Normalize image for display
vmin, vmax = np.percentile(image, [1, 99])
cmap_label = mcolors.ListedColormap(["none", "#FF4500"]) # orange-red for prediction
for col, z in enumerate(selected_slices):
ax_img = axes[0, col] if n_rows > 1 else axes[col]
ax_img.imshow(image[z], cmap="gray", vmin=vmin, vmax=vmax)
ax_img.imshow((pred[z] == label_value), cmap=cmap_label, alpha=0.5, vmin=0, vmax=1)
ax_img.set_title(f"Pred z={z}", fontsize=9)
ax_img.axis("off")
if gt_path is not None:
gt = sitk.GetArrayFromImage(sitk.ReadImage(gt_path))
ax_gt = axes[1, col]
ax_gt.imshow(image[z], cmap="gray", vmin=vmin, vmax=vmax)
ax_gt.imshow((gt[z] == label_value), cmap=cmap_label, alpha=0.5, vmin=0, vmax=1)
ax_gt.set_title(f"GT z={z}", fontsize=9)
ax_gt.axis("off")
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close()
print(f"QC figure saved: {output_path}")
# Generate QC plots for first 3 test cases
pred_dir = Path("/data/predictions_ensemble/")
image_dir = Path("/data/test_images/")
gt_dir = Path("/data/test_labels/")
for i, pred_file in enumerate(sorted(pred_dir.glob("*.nii.gz"))[:3]):
case_id = pred_file.stem.replace(".nii", "")
image_file = image_dir / f"{case_id}_0000.nii.gz"
gt_file = gt_dir / pred_file.name
if image_file.exists():
visualize_segmentation(
image_path=str(image_file),
pred_path=str(pred_file),
gt_path=str(gt_file) if gt_file.exists() else None,
output_path=f"qc_{case_id}.png",
n_slices=5
)
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
-c (configuration) |
— | 2d, 3d_fullres, 3d_lowres, 3d_cascade_fullres |
Network architecture; 3d_fullres is the standard starting point |
-f (fold) |
— | 0, 1, 2, 3, 4, all |
Cross-validation fold(s) to train or use for inference |
--npz (train) |
off | flag | Save softmax probability maps during training; required for ensembling |
tile_step_size (Python API) |
0.5 |
0.3–0.9 |
Overlap between inference tiles; larger values improve accuracy at cost of speed |
use_mirroring (Python API) |
True |
True, False |
Test-time augmentation with axis flips; disable for 2–4× speedup with slight Dice drop |
--num_epochs (train) |
1000 |
250–2000 |
Training epochs; default 1000 is suitable for most datasets |
checkpoint_name (Python API) |
"checkpoint_best.pth" |
"checkpoint_best.pth", "checkpoint_final.pth" |
Which saved checkpoint to load; _best is the highest-validation-Dice snapshot |
-np (ensemble/postproc) |
3 |
1–CPU count |
Number of parallel processes for ensemble or post-processing |
minimumObjectSize (postprocessing) |
auto | voxel count | Minimum connected component size kept; determined automatically by find_best_configuration |
--device (train) |
cuda |
cuda, cpu, mps |
Training device; mps for Apple Silicon GPU |
Common Recipes
Recipe: Inference with a Pretrained Community Model
When to use: A published challenge model exists for your anatomy (e.g., abdominal organs, brain tumors). Download and apply without retraining.
import torch
from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
from pathlib import Path
# Use a downloaded pretrained model folder
# Community models: https://zenodo.org/record/7783116 (abdominal organs)
model_folder = "/data/pretrained_models/Dataset003_Liver/nnUNetTrainer__nnUNetPlans__3d_fullres"
predictor = nnUNetPredictor(
tile_step_size=0.5,
use_gaussian=True,
use_mirroring=True,
device=torch.device("cuda", 0),
allow_tqdm=True,
)
predictor.initialize_from_trained_model_folder(
model_folder,
use_folds=(0, 1, 2, 3, 4), # ensemble all folds
checkpoint_name="checkpoint_best.pth",
)
input_dir = "/data/new_ct_scans/"
output_dir = "/data/liver_predictions/"
Path(output_dir).mkdir(exist_ok=True)
predictor.predict_from_files(
list_of_lists_or_source_folder=input_dir,
output_folder_or_list_of_truncated_output_files=output_dir,
save_probabilities=False,
overwrite=True,
num_processes_preprocessing=4,
num_processes_segmentation_export=4,
)
print(f"Inference complete → {output_dir}")
Recipe: Multi-Channel (Multi-Modal) Dataset Setup
When to use: Training with multiple imaging modalities for the same case (e.g., T1+T2+FLAIR for brain tumor segmentation).
import json
from pathlib import Path
import shutil
# Multi-channel dataset: T1 + T2 + FLAIR brain MRI
dataset_dir = Path("Dataset001_BrainTumor")
for subdir in ["imagesTr", "labelsTr", "imagesTs"]:
(dataset_dir / subdir).mkdir(parents=True, exist_ok=True)
# File naming convention: case_{ID}_{channel_index}.nii.gz
# For case 001: T1=001_0000, T2=001_0001, FLAIR=001_0002
# Rename your files accordingly:
case_id = "001"
modalities = {"T1": "0000", "T2": "0001", "FLAIR": "0002"}
for modality, idx in modalities.items():
src = Path(f"/data/raw/{case_id}_{modality}.nii.gz")
dst = dataset_dir / "imagesTr" / f"BrainTumor_{case_id}_{idx}.nii.gz"
if src.exists():
shutil.copy(src, dst)
print(f"Copied {modality} → {dst.name}")
# dataset.json with multiple channels
dataset_json = {
"channel_names": {"0": "T1", "1": "T2", "2": "FLAIR"},
"labels": {"background": 0, "edema": 1, "non_enhancing_tumor": 2, "enhancing_tumor": 3},
"numTraining": 200,
"file_ending": ".nii.gz"
}
with open(dataset_dir / "dataset.json", "w") as f:
json.dump(dataset_json, f, indent=2)
print("Multi-channel dataset.json created")
print(f"Channels: {dataset_json['channel_names']}")
print(f"Classes: {dataset_json['labels']}")
Recipe: Cascade Training for Large Structures
When to use: The structure to segment is very large relative to the image (e.g., whole brain, large organ in low-resolution CT), making 3D full-res training impractical due to memory.
# First train the low-res model (predicts a coarse segmentation)
nnUNetv2_train 7 3d_lowres 0 --npz
nnUNetv2_train 7 3d_lowres 1 --npz
nnUNetv2_train 7 3d_lowres 2 --npz
nnUNetv2_train 7 3d_lowres 3 --npz
nnUNetv2_train 7 3d_lowres 4 --npz
# Then train the high-res cascade (refines the low-res prediction)
# The cascade uses the low-res output as additional input
nnUNetv2_train 7 3d_cascade_fullres 0 --npz
nnUNetv2_train 7 3d_cascade_fullres 1 --npz
nnUNetv2_train 7 3d_cascade_fullres 2 --npz
nnUNetv2_train 7 3d_cascade_fullres 3 --npz
nnUNetv2_train 7 3d_cascade_fullres 4 --npz
# Predict with cascade: automatically uses lowres then cascade_fullres
nnUNetv2_predict -i /data/test/ -o /data/cascade_preds/ \
-d 7 -c 3d_cascade_fullres -f 0 1 2 3 4
echo "Cascade prediction complete"
Expected Outputs
| Output | Location | Description |
|---|---|---|
nnUNetPlans.json |
$nnUNet_preprocessed/DatasetXXX/ |
Auto-configured architecture, patch size, batch size, spacing |
dataset_fingerprint.json |
$nnUNet_preprocessed/DatasetXXX/ |
Dataset statistics used for auto-configuration |
checkpoint_best.pth |
$nnUNet_results/.../fold_N/ |
Model snapshot with highest validation Dice score |
checkpoint_final.pth |
$nnUNet_results/.../fold_N/ |
Final model checkpoint after all training epochs |
training_log.txt |
$nnUNet_results/.../fold_N/ |
Per-epoch train/validation loss and Dice scores |
summary.json |
$nnUNet_results/.../fold_N/validation/ |
Dice, IoU, Hausdorff distance on validation fold |
{case}.nii.gz |
Prediction output folder | Integer label map (0=background, 1,2,...=classes) |
{case}.npz |
Prediction folder (if --save_probabilities) |
Softmax probability map for each class |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
RuntimeError: CUDA out of memory |
GPU VRAM insufficient for patch size | Reduce patch size in nnUNetPlans.json (edit patch_size and batch_size); use 2D configuration instead of 3D |
FileNotFoundError: nnUNet_raw not set |
Environment variable missing | export nnUNet_raw=/path/to/raw; verify with echo $nnUNet_raw |
| Dataset integrity check fails | Mismatched image/label sizes or naming | Ensure every labelsTr/case_NNN.nii.gz has a matching imagesTr/case_NNN_0000.nii.gz; check spacing matches |
| Low Dice after training | Too few training cases or poor labels | Minimum 20 cases; verify label quality visually; check class imbalance in dataset.json |
| Training plateau (loss not decreasing after epoch 200) | Learning rate too high or too low | nnU-Net uses auto LR schedule; if plateau persists, increase numTraining examples or check data augmentation |
ValueError: images contain incorrect pixel type |
Images not in float or uint format | Convert to float32 with SimpleITK: sitk.Cast(image, sitk.sitkFloat32) before placing in imagesTr/ |
| Prediction is all background | Wrong model folder or checkpoint path | Verify model_folder path; check that checkpoint_best.pth exists; confirm dataset ID matches the trained model |
| Very slow inference (>10 min per case) | CPU inference or large patch with full mirroring | Set device=torch.device("cuda",0); set use_mirroring=False for 4× speedup; reduce tile_step_size to 0.3 |
References
- nnU-Net GitHub — official source code, installation instructions, and documentation
- Isensee F et al. (2021) Nature Methods 18:203–211 — original nnU-Net paper: "nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation"
- nnU-Net v2 documentation — migration guide, dataset format, inference API, custom trainers
- Medical Segmentation Decathlon — 10 benchmark datasets in nnU-Net-compatible format for training and validation
- Zenodo: pretrained nnU-Net models — community-shared pretrained models for abdominal organs and other structures
skills/medical-imaging/omero-integration/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill omero-integration -g -y
SKILL.md
Frontmatter
{
"name": "omero-integration",
"license": "GPL-2.0",
"description": "Open-source bio-image data management. Use the omero-py client to connect to an OMERO server, retrieve images as numpy arrays, annotate with tags and key-value pairs, manage ROIs, and feed image data into Python analysis pipelines — programmatically, no GUI."
}
omero-integration
Overview
OMERO is an open-source image data management system widely used in microscopy facilities and core labs. The omero-py library provides a Python client (BlitzGateway) that connects to an OMERO server, allowing programmatic access to images, datasets, projects, tags, annotations, and ROIs. Use it to build automated analysis workflows that pull images from OMERO, process them in Python, and write results back as annotations.
When to Use
- Programmatic image retrieval from OMERO: Downloading microscopy images as numpy arrays for downstream analysis without using the OMERO Insight GUI.
- Bulk annotation and tagging: Applying tags, key-value pair annotations, or comments to large numbers of images/datasets based on analysis results.
- ROI access and management: Reading segmentation ROIs (shapes) stored in OMERO for downstream quantification or export.
- Integrating OMERO into Python analysis pipelines: Connecting OMERO image data to scikit-image, OpenCV, CellPose, or other image analysis tools.
- Automated QC workflows: Querying images by metadata (channel, acquisition date, experimenter) and flagging those that fail quality criteria.
- Data provenance tracking: Attaching analysis provenance (parameters, tool versions) as structured key-value annotations to images.
- For local image analysis without an OMERO server, use
tifffile,aicsimageio, orimageiodirectly.
Prerequisites
- Python packages:
omero-py,numpy,Pillow - System: Java 8+ (required by
omero-pyinternals), Ice 3.6 (installed automatically via conda) - Data requirements: Access credentials to a running OMERO server (host, port, username, password)
- Environment: Conda is strongly recommended;
omero-pyhas complex dependencies
conda create -n omero python=3.9
conda activate omero
conda install -c ome -c conda-forge omero-py
pip install numpy Pillow
Quick Start
import omero
from omero.gateway import BlitzGateway
# Connect to OMERO server
conn = BlitzGateway("username", "password", host="omero.example.org", port=4064)
conn.connect()
print(f"Connected: {conn.isConnected()}, user: {conn.getUser().getName()}")
# Get an image by ID and download as numpy array
image = conn.getObject("Image", 12345)
pixels = image.getPrimaryPixels()
plane = pixels.getPlane(0, 0, 0) # z=0, c=0, t=0
print(f"Image shape: {plane.shape}, dtype: {plane.dtype}")
conn.close()
Core API
Module 1: BlitzGateway — Connection Management
BlitzGateway is the main entry point for all server interactions.
from omero.gateway import BlitzGateway
# Establish connection
conn = BlitzGateway(
username="user",
passwd="password",
host="omero.example.org",
port=4064,
secure=True,
)
success = conn.connect()
print(f"Connected: {success}")
print(f"Server version: {conn.getServerVersion()}")
print(f"Current group: {conn.getGroupFromContext().getName()}")
# Always close when done
conn.close()
# Context manager pattern for automatic cleanup
class OmeroConnection:
def __init__(self, **kwargs):
self.conn = BlitzGateway(**kwargs)
def __enter__(self):
self.conn.connect()
return self.conn
def __exit__(self, *args):
self.conn.close()
with OmeroConnection(username="user", passwd="pass",
host="omero.example.org", port=4064) as conn:
print(f"Connected as: {conn.getUser().getFullName()}")
Module 2: Project, Dataset, and Image Queries
Traverse the OMERO data hierarchy (Project → Dataset → Image).
# List all projects for the current user
for project in conn.listProjects():
print(f"Project {project.getId()}: {project.getName()}")
for dataset in project.listChildren():
print(f" Dataset {dataset.getId()}: {dataset.getName()}")
for image in dataset.listChildren():
print(f" Image {image.getId()}: {image.getName()}")
# Search for images by name
results = conn.searchObjects(["Image"], "GFP_control")
for img in results:
print(f" Found: {img.getId()} - {img.getName()}")
# Get a specific object by ID
image = conn.getObject("Image", 12345)
dataset = conn.getObject("Dataset", 678)
project = conn.getObject("Project", 90)
print(f"Image: {image.getName()}, size: {image.getSizeX()}x{image.getSizeY()}")
print(f"Channels: {image.getSizeC()}, Z-slices: {image.getSizeZ()}, timepoints: {image.getSizeT()}")
Module 3: Image Download as NumPy Arrays
Retrieve pixel data as numpy arrays for processing.
import numpy as np
image = conn.getObject("Image", 12345)
pixels = image.getPrimaryPixels()
# Get a single 2D plane: getPlane(z_index, channel_index, time_index)
plane = pixels.getPlane(0, 0, 0)
print(f"Plane shape: {plane.shape}, dtype: {plane.dtype}")
# Get all channels at z=0, t=0
planes = [pixels.getPlane(0, c, 0) for c in range(image.getSizeC())]
stack = np.stack(planes, axis=0) # shape: (C, Y, X)
print(f"Multi-channel stack: {stack.shape}")
# Efficient bulk download using getTiles (for large images)
image = conn.getObject("Image", 12345)
pixels = image.getPrimaryPixels()
tile_coords = [(0, 0, 0, (0, 0, 512, 512))] # (z, c, t, (x, y, w, h))
for tile in pixels.getTiles(tile_coords):
print(f"Tile shape: {tile.shape}") # (512, 512) numpy array
Module 4: Tag and Annotation Management
Add, retrieve, and update tags and key-value pair annotations on OMERO objects.
import omero
# Add a tag to an image
tag_ann = omero.gateway.TagAnnotationWrapper(conn)
tag_ann.setValue("passed_QC")
tag_ann.setNs("my.analysis.namespace")
tag_ann.save()
image = conn.getObject("Image", 12345)
image.linkAnnotation(tag_ann)
print(f"Tag '{tag_ann.getValue()}' linked to image {image.getId()}")
# Add key-value pairs (MapAnnotation) to an image
map_ann = omero.gateway.MapAnnotationWrapper(conn)
map_ann.setNs("openmicroscopy.org/omero/client/mapAnnotation")
kv_pairs = [
["analysis_tool", "CellProfiler 4.2"],
["cell_count", "342"],
["mean_intensity", "1847.3"],
["analysis_date", "2026-02-18"],
]
map_ann.setValue(kv_pairs)
map_ann.save()
image.linkAnnotation(map_ann)
print(f"Key-value annotation attached to image {image.getId()}")
# Read existing annotations
for ann in image.listAnnotations():
print(f" {ann.OMERO_TYPE}: {ann.getValue()}")
Module 5: ROI Access
Read segmentation ROIs (shapes) stored in OMERO for downstream quantification.
from omero.model import RoiI
# Get all ROIs for an image
roi_service = conn.getRoiService()
result = roi_service.findByImage(12345, None)
for roi in result.rois:
for shape in roi.copyShapes():
shape_type = shape.__class__.__name__
print(f" ROI {roi.id.val}: {shape_type}")
if shape_type == "RectangleI":
print(f" x={shape.x.val:.1f}, y={shape.y.val:.1f}, "
f"w={shape.width.val:.1f}, h={shape.height.val:.1f}")
elif shape_type == "EllipseI":
print(f" cx={shape.x.val:.1f}, cy={shape.y.val:.1f}, "
f"rx={shape.radiusX.val:.1f}, ry={shape.radiusY.val:.1f}")
# Convert ROI masks to numpy boolean arrays
import numpy as np
from omero.gateway import BlitzGateway
def roi_to_mask(shape, height, width):
"""Convert a rectangle ROI to a boolean numpy mask."""
mask = np.zeros((height, width), dtype=bool)
x = int(shape.x.val)
y = int(shape.y.val)
w = int(shape.width.val)
h = int(shape.height.val)
mask[y:y+h, x:x+w] = True
return mask
image = conn.getObject("Image", 12345)
height = image.getSizeY()
width = image.getSizeX()
result = conn.getRoiService().findByImage(12345, None)
for roi in result.rois:
for shape in roi.copyShapes():
if shape.__class__.__name__ == "RectangleI":
mask = roi_to_mask(shape, height, width)
print(f"ROI mask: {mask.sum()} pixels selected")
Key Concepts
OMERO Data Hierarchy
OMERO organizes data as Project → Dataset → Image. Images contain pixel data plus metadata (channels, Z-slices, timepoints). Annotations (tags, key-value pairs, comments) can be attached to any level of the hierarchy.
# Hierarchy navigation
project = conn.getObject("Project", 90)
for dataset in project.listChildren():
imgs = list(dataset.listChildren())
print(f"Dataset '{dataset.getName()}': {len(imgs)} images")
Pixel Access Patterns
OMERO stores pixels as (Z, C, T) stacks. getPlane(z, c, t) returns a single 2D numpy array. For large images, use getTiles to download spatial subregions. Pixel type (uint8, uint16, float32) matches the original acquisition format.
image = conn.getObject("Image", 12345)
pixels = image.getPrimaryPixels()
ptype = pixels.getPixelsType().getValue()
print(f"Pixel type: {ptype}") # e.g., "uint16"
print(f"Dimensions: XY={image.getSizeX()}x{image.getSizeY()}, "
f"Z={image.getSizeZ()}, C={image.getSizeC()}, T={image.getSizeT()}")
Common Workflows
Workflow 1: Batch Download and Analysis
Goal: Download all images from a dataset, apply processing, and store results as key-value annotations.
import numpy as np
from omero.gateway import BlitzGateway, MapAnnotationWrapper
def mean_intensity(plane):
return float(plane.mean())
conn = BlitzGateway("user", "pass", host="omero.example.org", port=4064)
conn.connect()
dataset = conn.getObject("Dataset", 678)
results = []
for image in dataset.listChildren():
pixels = image.getPrimaryPixels()
plane = pixels.getPlane(0, 0, 0) # first z, channel 0, t=0
mi = mean_intensity(plane)
results.append((image, mi))
# Attach mean intensity as key-value annotation
ann = MapAnnotationWrapper(conn)
ann.setNs("my.pipeline.v1")
ann.setValue([["mean_intensity_ch0", f"{mi:.2f}"]])
ann.save()
image.linkAnnotation(ann)
print(f"Image {image.getId()} '{image.getName()}': mean={mi:.2f}")
print(f"\nProcessed {len(results)} images in dataset '{dataset.getName()}'")
conn.close()
Workflow 2: Retrieve Images by Tag and Export
Goal: Find all images tagged "screen_hits", download channel 1 as numpy arrays, and save as TIFF files.
import numpy as np
import tifffile
from omero.gateway import BlitzGateway
conn = BlitzGateway("user", "pass", host="omero.example.org", port=4064)
conn.connect()
# Find images with a specific tag
tag_value = "screen_hits"
tagged_images = []
for ann in conn.getObjects("TagAnnotation", attributes={"textValue": tag_value}):
for image in ann.listLinkedObjects(["Image"]):
tagged_images.append(image)
print(f"Found {len(tagged_images)} images tagged '{tag_value}'")
for image in tagged_images:
pixels = image.getPrimaryPixels()
# Download DAPI channel (index 0) at z=0, t=0
plane = pixels.getPlane(0, 0, 0)
fname = f"image_{image.getId()}_DAPI.tif"
tifffile.imwrite(fname, plane)
print(f"Saved {fname}: shape={plane.shape}, dtype={plane.dtype}")
conn.close()
print("Export complete")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
host |
BlitzGateway | required | hostname or IP | OMERO server address |
port |
BlitzGateway | 4064 |
1024–65535 |
OMERO server port (4064 = standard) |
secure |
BlitzGateway | False |
True, False |
Use SSL/TLS encrypted connection |
z, c, t |
getPlane | 0, 0, 0 |
0 – size-1 |
Z-slice, channel, timepoint indices |
tile_coords |
getTiles | — | list of (z,c,t,(x,y,w,h)) |
Spatial subregion download coordinates |
Ns |
annotations | None |
any URI string | Namespace for annotation filtering |
Best Practices
-
Always close the connection: Call
conn.close()after all operations, or use a context manager. Unclosed connections consume server resources and can cause session timeouts. -
Use namespaces on annotations: Set a unique
Ns(namespace URI) on every annotation you create so your programmatic annotations can be distinguished from manual ones and other tools. -
Download only the planes you need: For large time-lapse or Z-stack images, use
getPlane(z, c, t)with specific indices rather than downloading all planes. For spatial subsets, usegetTiles. -
Batch annotation writes to reduce server round-trips: Collect key-value pairs across multiple images and write them in a loop rather than making individual RPC calls per image per key.
-
Check image dimensions before processing: Always read
getSizeX(),getSizeY(),getSizeC(),getSizeZ(),getSizeT()before accessing pixel data to avoid index-out-of-bounds errors on unexpectedly shaped datasets.
Common Recipes
Recipe: List All Tags on an Image
When to use: Inspect existing annotations before adding new ones to avoid duplicates.
image = conn.getObject("Image", 12345)
for ann in image.listAnnotations():
if ann.OMERO_TYPE == "TagAnnotation":
print(f" Tag: '{ann.getValue()}' (ns={ann.getNs()})")
elif ann.OMERO_TYPE == "MapAnnotation":
for k, v in ann.getValue():
print(f" KV: {k} = {v}")
Recipe: Multi-Channel Max Projection
When to use: Create a maximum intensity projection across all Z-slices for a given channel.
import numpy as np
def max_projection(image, channel=0, timepoint=0):
pixels = image.getPrimaryPixels()
planes = [pixels.getPlane(z, channel, timepoint)
for z in range(image.getSizeZ())]
stack = np.stack(planes, axis=0)
return stack.max(axis=0)
image = conn.getObject("Image", 12345)
proj = max_projection(image, channel=1)
print(f"Max projection shape: {proj.shape}, max value: {proj.max()}")
Expected Outputs
- NumPy arrays from
getPlane(): shape(Y, X), dtype matching acquisition (uint8, uint16, float32) - Multi-channel stacks: shape
(C, Y, X)when stacking planes - Annotations attached to images visible in OMERO.web and OMERO Insight GUI
- ROI shapes returned as
omero.modelobjects with coordinate attributes
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Ice.ConnectionRefusedException |
Wrong host/port or server down | Verify host and port=4064; confirm server is running |
omero.SecurityViolation |
Insufficient permissions on object | Check group membership; ask server admin to grant access |
ImportError: omero |
omero-py not installed via conda | Use conda install -c ome -c conda-forge omero-py; pip install is unreliable |
AttributeError: 'NoneType' object |
Object ID not found on server | Verify the object exists with conn.getObject(type, id) returns non-None |
Ice.MemoryLimitException |
Downloading very large image all at once | Use getTiles() for spatial subsets or getPlane() per slice |
| Slow download speed | Downloading many small planes sequentially | Use getTiles() with a list of all coordinates for batch download |
| Session timeout mid-run | Long-running analysis exceeds server idle timeout | Call conn.keepAlive() periodically in long loops |
References
- OMERO Python API Documentation — official omero-py docs
- OMERO Developer Documentation — Python client guide
- OMERO GitHub (ome/omero-py) — source code and issue tracker
- Linkert et al. (2010), J Cell Biol — OMERO paper — original OMERO publication
skills/medical-imaging/pathml/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pathml -g -y
SKILL.md
Frontmatter
{
"name": "pathml",
"license": "GPL-2.0",
"description": "Computational pathology toolkit for whole-slide images (WSIs): load slides, extract tiles, stain normalization, nuclear segmentation, feature extraction, and ML training. Supports H&E and multiplex. For end-to-end pipelines from raw WSIs to quantitative outputs."
}
pathml
Overview
PathML is a Python toolkit designed for computational pathology workflows on whole-slide images (WSIs). It provides a unified pipeline from raw slide files (SVS, NDPI, MRXS, TIFF) through tile extraction, preprocessing (stain normalization, nuclear segmentation, tissue detection), feature extraction, and machine learning. PathML integrates with popular Python ML and image processing libraries while abstracting the complexity of WSI handling through its SlideData and Pipeline abstractions.
When to Use
- Processing whole-slide H&E images: Tiling a large WSI, normalizing staining variability across slides from different scanners or batches.
- Nuclear segmentation on pathology slides: Detecting and segmenting nuclei in H&E or DAPI-stained WSIs using built-in segmentation pipelines.
- Building ML training datasets from WSIs: Extracting tiles with associated labels for training tissue classifiers, tumor detectors, or survival prediction models.
- Multiplex immunofluorescence (mIF) image analysis: Processing multi-channel IF slides with channel-specific preprocessing and feature extraction.
- Stain normalization across cohorts: Applying Macenko or Vahadane stain normalization to harmonize H&E slides from multiple institutions.
- Feature extraction for downstream ML: Extracting handcrafted or deep learning features from tiles for patient-level prediction tasks.
- For standard 2D microscopy images (non-WSI), use
scikit-imageorcellposedirectly without PathML overhead.
Prerequisites
- Python packages:
pathml,torch,torchvision,numpy,scikit-image,openslide-python - System: OpenSlide C library (required for WSI reading)
- Data requirements: WSI files in SVS, NDPI, MRXS, or TIFF format; GPU recommended for segmentation
- Environment: Python 3.8+, CUDA-compatible GPU for deep learning preprocessing
# Install system dependency first
conda install -c conda-forge openslide
# Install PathML
pip install pathml
# For GPU support
pip install torch torchvision --extra-index-url https://download.pytorch.org/whl/cu118
Quick Start
from pathml.core import SlideData
from pathml.preprocessing import Pipeline
from pathml.preprocessing.transforms import BoxBlur, TissueDetectionHE
# Load → build pipeline → tile → preprocess
slide = SlideData("tumor.svs", name="demo")
pipeline = Pipeline([BoxBlur(kernel_size=3), TissueDetectionHE(mask_name="tissue")])
slide.run(pipeline, tile_size=256, tile_stride=256)
# Inspect tiles
from pathml.core import Tile
tiles = [t for t in slide.tiles if t.masks["tissue"].any()]
print(f"Tissue tiles: {len(tiles)} of {len(slide.tiles)}")
Workflow
Step 1: Load a Whole-Slide Image
from pathml.core import SlideData
# Load an H&E whole-slide image
slide = SlideData("path/to/slide.svs", name="tumor_slide_001")
print(f"Slide name: {slide.name}")
print(f"Slide shape: {slide.slide.shape}")
print(f"Slide properties: {slide.slide.properties}")
Step 2: Define a Preprocessing Pipeline
from pathml.preprocessing import Pipeline
from pathml.preprocessing.transforms import (
BoxBlur,
TissueDetectionHE,
HEStainNormalization,
)
# Build a preprocessing pipeline for H&E slides
pipeline = Pipeline([
BoxBlur(kernel_size=5), # smooth image
TissueDetectionHE(mask_name="tissue"), # detect tissue regions
HEStainNormalization(target="normalize"), # normalize H&E staining
])
print(f"Pipeline steps: {len(pipeline.transforms)}")
Step 3: Create a TileDataset
from pathml.core import TileDataset
# Tile the slide into 256x256 patches at 20x magnification
slide.generate_tiles(
shape=(256, 256),
stride=(256, 256),
pad=False,
level=0, # pyramid level 0 = highest resolution
coords_format="fractional",
)
print(f"Total tiles generated: {len(slide.tiles)}")
Step 4: Run the Preprocessing Pipeline
# Apply preprocessing pipeline to all tiles
slide.run(pipeline, distributed=False, tile_pad=False)
print("Pipeline complete — tiles preprocessed")
# Inspect a single tile
tile = slide.tiles[0]
print(f"Tile shape: {tile.image.shape}") # (256, 256, 3)
print(f"Tile masks: {list(tile.masks.keys())}")
Step 5: Nuclear Segmentation
from pathml.preprocessing.transforms import NuclearSegmentation
# Run Hematoxylin-channel nuclear segmentation
seg_pipeline = Pipeline([
TissueDetectionHE(mask_name="tissue"),
NuclearSegmentation(mask_name="nuclei"),
])
slide.run(seg_pipeline, distributed=False)
# Count nuclei per tile
for tile in list(slide.tiles)[:5]:
n_nuclei = tile.masks["nuclei"].max()
print(f"Tile {tile.coords}: {n_nuclei} nuclei detected")
Step 6: Feature Extraction
import numpy as np
from pathml.core import SlideDataset
features = []
for tile in slide.tiles:
if "tissue" in tile.masks and tile.masks["tissue"].any():
img = tile.image
feat = {
"mean_r": img[:, :, 0].mean(),
"mean_g": img[:, :, 1].mean(),
"mean_b": img[:, :, 2].mean(),
"std_r": img[:, :, 0].std(),
"n_nuclei": int(tile.masks["nuclei"].max()) if "nuclei" in tile.masks else 0,
"tile_x": tile.coords[0],
"tile_y": tile.coords[1],
}
features.append(feat)
import pandas as pd
df = pd.DataFrame(features)
df.to_csv("slide_features.csv", index=False)
print(f"Extracted features from {len(df)} tissue tiles -> slide_features.csv")
Step 7: Save and Export Processed Slide
import h5py
# Save slide data (tiles + masks) to HDF5
slide.write("processed_slide.h5")
print("Slide saved to processed_slide.h5")
# Reload for downstream use
from pathml.core import SlideData
slide_loaded = SlideData.read("processed_slide.h5")
print(f"Reloaded: {len(slide_loaded.tiles)} tiles")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
shape |
(256, 256) |
(64,64) – (1024,1024) |
Tile dimensions in pixels |
stride |
equals shape |
any tuple ≤ shape |
Step between tile centers; stride < shape gives overlapping tiles |
level |
0 |
0 – max pyramid level |
Pyramid resolution level (0 = full resolution) |
kernel_size |
5 |
odd integers 3–21 |
Smoothing kernel size in BoxBlur |
mask_name |
required | any string | Name of output mask stored in tile.masks |
distributed |
False |
True, False |
Enable Dask distributed processing for large slides |
pad |
False |
True, False |
Pad edge tiles to full shape size |
Common Recipes
Recipe: Tissue-Only Tile Filtering
When to use: Exclude background tiles to reduce memory and computation in downstream steps.
# Filter tiles to only tissue regions after running tissue detection pipeline
tissue_tiles = [t for t in slide.tiles if "tissue" in t.masks and t.masks["tissue"].mean() > 0.5]
print(f"Tissue tiles: {len(tissue_tiles)} / {len(slide.tiles)} total")
Recipe: Export Tiles as PNG Files
When to use: Create a labeled tile dataset for training a custom classifier in PyTorch.
from PIL import Image
import numpy as np
from pathlib import Path
output_dir = Path("tiles_png")
output_dir.mkdir(exist_ok=True)
for i, tile in enumerate(slide.tiles):
if "tissue" in tile.masks and tile.masks["tissue"].mean() > 0.5:
img = Image.fromarray(tile.image.astype(np.uint8))
img.save(output_dir / f"tile_{i:05d}_x{tile.coords[0]}_y{tile.coords[1]}.png")
print(f"Saved {i+1} tiles to {output_dir}/")
Recipe: Batch Process Multiple Slides
When to use: Running the same preprocessing pipeline on a directory of WSI files.
from pathlib import Path
from pathml.core import SlideData
from pathml.preprocessing import Pipeline
from pathml.preprocessing.transforms import TissueDetectionHE, HEStainNormalization
pipeline = Pipeline([
TissueDetectionHE(mask_name="tissue"),
HEStainNormalization(target="normalize"),
])
wsi_dir = Path("slides/")
for wsi_path in sorted(wsi_dir.glob("*.svs")):
slide = SlideData(str(wsi_path), name=wsi_path.stem)
slide.generate_tiles(shape=(256, 256), stride=(256, 256), level=0)
slide.run(pipeline, distributed=False)
slide.write(f"processed/{wsi_path.stem}.h5")
print(f"Processed {wsi_path.name}: {len(slide.tiles)} tiles")
Expected Outputs
slide.tiles— iterable ofTileobjects, each with.image(numpy array) and.masks(dict of numpy arrays)slide_features.csv— tabular per-tile features (color statistics, nucleus counts, coordinates)processed_slide.h5— HDF5 file with tiles, masks, and metadata for downstream use- PNG tile files (optional) — ready for PyTorch
ImageFolderdataset loading
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
openslide.lowlevel.OpenSlideUnsupportedFormatError |
OpenSlide C library not installed or WSI format unsupported | conda install -c conda-forge openslide; check format compatibility |
CUDA out of memory during segmentation |
Tile size too large for GPU | Reduce tile shape to (128, 128) or run with distributed=False on CPU |
slide.tiles is empty after generate_tiles |
Level index out of range or all tiles filtered | Use level=0; check slide pyramid with slide.slide.level_count |
| Stain normalization produces black tiles | Source slide too low contrast or failed tissue detection | Apply TissueDetectionHE before normalization; inspect tissue mask coverage |
KeyError: 'nuclei' in tile.masks |
Segmentation pipeline not yet run | Run the NuclearSegmentation pipeline with slide.run() before accessing masks |
| Very slow tile generation | High-resolution level 0 on large SVS | Use a lower pyramid level (level=1 or level=2) for faster prototyping |
AttributeError: SlideData has no attribute 'write' |
Old PathML version | pip install --upgrade pathml to get HDF5 save/load support |
References
- PathML Documentation — official docs with tutorials
- PathML GitHub (Dana-Farber/PathML) — source code and examples
- Rosenthal et al. (2022), Cell Systems — PathML paper — original publication
- OpenSlide Documentation — WSI reading library underlying PathML
skills/medical-imaging/pydicom-medical-imaging/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pydicom-medical-imaging -g -y
SKILL.md
Frontmatter
{
"name": "pydicom-medical-imaging",
"license": "MIT",
"description": "Pure Python DICOM for medical imaging (CT, MRI, X-ray, ultrasound). Read\/write DICOM, pixels as NumPy, edit tags, windowing (VOI LUT), PHI anonymization, build DICOM, series→3D volumes. Use histolab for WSI pathology; nibabel for NIfTI."
}
Pydicom Medical Imaging
Overview
Pydicom is a pure Python library for reading, writing, and modifying DICOM (Digital Imaging and Communications in Medicine) files. It provides access to DICOM metadata tags and pixel data as NumPy arrays, supporting CT, MRI, X-ray, ultrasound, and other medical imaging modalities. The library handles compressed and uncompressed transfer syntaxes with optional codec plugins.
When to Use
- Reading DICOM files and extracting metadata (patient info, study parameters, imaging settings)
- Extracting pixel data from DICOM images for analysis or visualization
- Converting DICOM images to standard formats (PNG, JPEG, TIFF)
- Anonymizing DICOM files by removing Protected Health Information (PHI)
- Modifying DICOM metadata tags for relabeling or correction
- Creating DICOM files from scratch (e.g., wrapping NumPy arrays as DICOM)
- Processing CT/MRI series into 3D volumetric arrays for reconstruction
- Extracting frames from multi-frame DICOM (cine/video)
- For whole-slide pathology images (SVS, NDPI), use
histolab-wsi-processinginstead - For NIfTI neuroimaging volumes (.nii/.nii.gz), use
nibabelinstead
Prerequisites
- Python packages:
pydicom,numpy,pillow - Optional codecs:
pylibjpeg+pylibjpeg-libjpeg(JPEG),pylibjpeg-openjpeg(JPEG 2000),python-gdcm(most formats) - Data format: DICOM files (.dcm, .ima, or extensionless) per NEMA PS3.10
pip install pydicom numpy pillow
# Optional: compression codec handlers (install as needed)
pip install pylibjpeg pylibjpeg-libjpeg # JPEG Baseline/Lossless
pip install pylibjpeg-openjpeg # JPEG 2000
pip install python-gdcm # Comprehensive codec support
Quick Start
import pydicom
import numpy as np
# Read a DICOM file
ds = pydicom.dcmread("scan.dcm")
# Access metadata
print(f"Patient: {ds.PatientName}, Modality: {ds.Modality}")
print(f"Size: {ds.Rows}x{ds.Columns}, Bits: {ds.BitsAllocated}")
# Extract pixel data as NumPy array
pixels = ds.pixel_array
print(f"Pixel array shape: {pixels.shape}, dtype: {pixels.dtype}")
# Apply windowing for display (CT/MR)
from pydicom.pixel_data_handlers.util import apply_voi_lut
display = apply_voi_lut(pixels, ds)
print(f"Windowed range: [{display.min()}, {display.max()}]")
Core API
Module 1: Reading and Metadata Access
Read DICOM files and access metadata using attribute names or tag notation.
import pydicom
# Read DICOM file (defer_size delays loading large elements)
ds = pydicom.dcmread("scan.dcm")
ds_lazy = pydicom.dcmread("large.dcm", defer_size="1 KB")
# Access by attribute name (standard DICOM keywords)
print(f"Patient Name: {ds.PatientName}")
print(f"Study Date: {ds.StudyDate}")
print(f"Modality: {ds.Modality}")
print(f"Image Size: {ds.Rows} x {ds.Columns}")
# Access by tag number (group, element)
print(f"Patient ID: {ds[0x0010, 0x0020].value}")
# Safe access with getattr (avoids AttributeError)
slice_thick = getattr(ds, 'SliceThickness', 'N/A')
print(f"Slice Thickness: {slice_thick}")
# Iterate all elements
for elem in ds:
if elem.VR != 'SQ': # Skip sequences
print(f" {elem.tag} {elem.keyword}: {elem.value}")
# Read DICOM directory (DICOMDIR)
from pydicom.filereader import dcmread
dicomdir = pydicom.dcmread("DICOMDIR")
for record in dicomdir.DirectoryRecordSequence:
if record.DirectoryRecordType == "IMAGE":
ref_file = record.ReferencedFileID
# ref_file is a list of path components
print(f"Image file: {'/'.join(ref_file)}")
Module 2: Pixel Data Extraction
Extract pixel data as NumPy arrays with support for grayscale, color, windowing, and multi-frame.
import pydicom
import numpy as np
from pydicom.pixel_data_handlers.util import apply_voi_lut, apply_modality_lut
ds = pydicom.dcmread("ct_scan.dcm")
# Basic pixel extraction
pixels = ds.pixel_array # NumPy ndarray
print(f"Shape: {pixels.shape}, dtype: {pixels.dtype}")
# Apply Modality LUT (rescale to Hounsfield Units for CT)
hu_pixels = apply_modality_lut(pixels, ds)
print(f"HU range: [{hu_pixels.min()}, {hu_pixels.max()}]")
# Apply VOI LUT (windowing for display contrast)
display = apply_voi_lut(hu_pixels, ds)
print(f"Display range: [{display.min()}, {display.max()}]")
# Manual windowing (when VOI LUT metadata is absent)
center, width = 40, 400 # Soft tissue window
lower = center - width / 2
upper = center + width / 2
windowed = np.clip(hu_pixels, lower, upper)
print(f"Manual window [{lower}, {upper}]")
# Color images (ultrasound, photos) — handle YBR color space
import pydicom
ds = pydicom.dcmread("ultrasound.dcm")
pixels = ds.pixel_array
print(f"Color shape: {pixels.shape}") # (rows, cols, 3)
# Convert YBR to RGB if needed
photo_interp = ds.PhotometricInterpretation
if "YBR" in photo_interp:
from pydicom.pixel_data_handlers.util import convert_color_space
rgb = convert_color_space(pixels, photo_interp, "RGB")
print(f"Converted {photo_interp} -> RGB")
# Multi-frame (cine/video DICOM)
ds_multi = pydicom.dcmread("cine.dcm")
frames = ds_multi.pixel_array # Shape: (num_frames, rows, cols)
print(f"Frames: {frames.shape[0]}, Frame size: {frames.shape[1:]}")
Module 3: Image Conversion
Convert DICOM pixel data to standard image formats for visualization and export.
import pydicom
import numpy as np
from PIL import Image
from pydicom.pixel_data_handlers.util import apply_voi_lut
ds = pydicom.dcmread("scan.dcm")
pixels = ds.pixel_array
# Apply windowing
display = apply_voi_lut(pixels, ds)
# Normalize to 8-bit for standard image formats
if display.dtype != np.uint8:
dmin, dmax = display.min(), display.max()
if dmax > dmin:
normalized = ((display - dmin) / (dmax - dmin) * 255).astype(np.uint8)
else:
normalized = np.zeros_like(display, dtype=np.uint8)
else:
normalized = display
# Save as PNG
img = Image.fromarray(normalized)
img.save("output.png")
print(f"Saved output.png ({img.size[0]}x{img.size[1]})")
# Save as JPEG with quality control
img.save("output.jpg", quality=95)
# Batch conversion: directory of DICOM files to PNG
import pydicom
import numpy as np
from PIL import Image
from pathlib import Path
from pydicom.pixel_data_handlers.util import apply_voi_lut
def dicom_to_image(dcm_path, out_path, fmt="PNG"):
"""Convert a single DICOM file to standard image format."""
ds = pydicom.dcmread(str(dcm_path))
pixels = apply_voi_lut(ds.pixel_array, ds)
dmin, dmax = float(pixels.min()), float(pixels.max())
if dmax > dmin:
norm = ((pixels - dmin) / (dmax - dmin) * 255).astype(np.uint8)
else:
norm = np.zeros_like(pixels, dtype=np.uint8)
Image.fromarray(norm).save(str(out_path))
dcm_dir = Path("dicom_files/")
out_dir = Path("images/")
out_dir.mkdir(exist_ok=True)
for dcm_file in sorted(dcm_dir.glob("*.dcm")):
out_file = out_dir / f"{dcm_file.stem}.png"
dicom_to_image(dcm_file, out_file)
print(f"Converted: {dcm_file.name} -> {out_file.name}")
Module 4: Metadata Modification and Anonymization
Modify DICOM attributes and remove Protected Health Information for de-identification.
import pydicom
from pydicom.uid import generate_uid
ds = pydicom.dcmread("original.dcm")
# Modify attributes
ds.PatientName = "Anonymous"
ds.PatientID = "ANON001"
ds.InstitutionName = "Research Lab"
# Add new attribute
ds.add_new(0x00081030, 'LO', 'Research Study') # Study Description
# Delete attribute
if 'PatientBirthDate' in ds:
del ds.PatientBirthDate
# Generate new UIDs for de-identification
ds.StudyInstanceUID = generate_uid()
ds.SeriesInstanceUID = generate_uid()
ds.SOPInstanceUID = generate_uid()
# Save modified file (preserves original)
ds.save_as("modified.dcm")
print(f"Saved modified.dcm with new UIDs")
# PHI anonymization: remove patient-identifying tags (DICOM PS3.15 Annex E)
import pydicom
from pydicom.uid import generate_uid
PHI_TAGS = [ # Core set — extend per institutional policy
'PatientName', 'PatientID', 'PatientBirthDate', 'PatientSex',
'PatientAge', 'PatientWeight', 'PatientAddress',
'OtherPatientIDs', 'OtherPatientNames',
'InstitutionName', 'InstitutionAddress',
'ReferringPhysicianName', 'PerformingPhysicianName',
'OperatorsName', 'StudyID', 'AccessionNumber',
]
def anonymize_dicom(ds, prefix="ANON"):
"""Remove PHI tags and assign anonymous identifiers."""
for tag in PHI_TAGS:
if hasattr(ds, tag): delattr(ds, tag)
ds.PatientName, ds.PatientID = f"{prefix}_Patient", f"{prefix}_ID"
ds.StudyInstanceUID = generate_uid()
ds.SeriesInstanceUID = generate_uid()
ds.SOPInstanceUID = generate_uid()
return ds
ds = pydicom.dcmread("patient_scan.dcm")
anonymize_dicom(ds, prefix="STUDY001").save_as("anonymized.dcm")
print("Anonymized: PHI tags removed, UIDs replaced")
Module 5: Writing DICOM from Scratch
Create new DICOM files from NumPy arrays with proper metadata.
import pydicom, numpy as np, datetime
from pydicom.dataset import FileDataset, FileMetaDataset
from pydicom.uid import ExplicitVRLittleEndian, generate_uid
# File meta header
file_meta = FileMetaDataset()
file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.2' # CT Image Storage
file_meta.MediaStorageSOPInstanceUID = generate_uid()
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
# Dataset with required attributes
ds = FileDataset("new.dcm", {}, file_meta=file_meta, preamble=b"\x00" * 128)
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
ds.StudyInstanceUID, ds.SeriesInstanceUID = generate_uid(), generate_uid()
ds.Modality, ds.Manufacturer = 'CT', 'Research'
ds.is_little_endian, ds.is_implicit_VR = True, False
dt = datetime.datetime.now()
ds.ContentDate, ds.ContentTime = dt.strftime('%Y%m%d'), dt.strftime('%H%M%S.%f')
# Pixel data from NumPy array
pixels = np.random.randint(0, 4096, (512, 512), dtype=np.uint16)
ds.Rows, ds.Columns = pixels.shape
ds.BitsAllocated, ds.BitsStored, ds.HighBit = 16, 12, 11
ds.PixelRepresentation = 0 # Unsigned
ds.SamplesPerPixel, ds.PhotometricInterpretation = 1, 'MONOCHROME2'
ds.PixelData = pixels.tobytes()
ds.save_as("new.dcm")
print(f"Created DICOM: {ds.Rows}x{ds.Columns}, {ds.BitsStored}-bit")
Module 6: Series Processing and 3D Volumes
Load a DICOM series, sort by spatial position, and stack into a 3D NumPy array.
import pydicom
import numpy as np
from pathlib import Path
def load_dicom_series(series_dir):
"""Load and sort a DICOM series by slice position."""
dcm_files = []
for f in Path(series_dir).iterdir():
try:
ds = pydicom.dcmread(str(f))
dcm_files.append(ds)
except Exception:
continue # Skip non-DICOM files
if not dcm_files:
raise ValueError(f"No DICOM files found in {series_dir}")
# Sort by ImagePositionPatient (z-coordinate) or InstanceNumber
try:
dcm_files.sort(key=lambda x: float(x.ImagePositionPatient[2]))
except (AttributeError, IndexError):
dcm_files.sort(key=lambda x: int(x.InstanceNumber))
print(f"Loaded {len(dcm_files)} slices, "
f"Series: {getattr(dcm_files[0], 'SeriesDescription', 'N/A')}")
return dcm_files
def series_to_volume(dcm_files):
"""Stack sorted DICOM slices into a 3D NumPy array."""
from pydicom.pixel_data_handlers.util import apply_modality_lut
slices = []
for ds in dcm_files:
pixels = apply_modality_lut(ds.pixel_array, ds)
slices.append(pixels)
volume = np.stack(slices, axis=0)
# Calculate voxel spacing
pixel_spacing = dcm_files[0].PixelSpacing
if len(dcm_files) > 1:
try:
z0 = float(dcm_files[0].ImagePositionPatient[2])
z1 = float(dcm_files[1].ImagePositionPatient[2])
slice_spacing = abs(z1 - z0)
except (AttributeError, IndexError):
slice_spacing = float(getattr(dcm_files[0], 'SliceThickness', 1.0))
else:
slice_spacing = float(getattr(dcm_files[0], 'SliceThickness', 1.0))
spacing = (slice_spacing, float(pixel_spacing[0]), float(pixel_spacing[1]))
print(f"Volume shape: {volume.shape}, Spacing (z,y,x): {spacing} mm")
return volume, spacing
# Usage
dcm_files = load_dicom_series("ct_series/")
volume, spacing = series_to_volume(dcm_files)
print(f"HU range: [{volume.min()}, {volume.max()}]")
Key Concepts
DICOM Data Model
DICOM organizes medical imaging data in a four-level hierarchy:
| Level | Key UID | Description |
|---|---|---|
| Patient | PatientID | A single individual |
| Study | StudyInstanceUID | One imaging session (may contain multiple modalities) |
| Series | SeriesInstanceUID | One acquisition sequence (e.g., T1-weighted MRI) |
| Instance (Image) | SOPInstanceUID | One image/frame (one DICOM file) |
Each DICOM file contains one Instance with metadata tags organized by group. Tags use (group, element) notation (e.g., (0010,0010) for PatientName).
Transfer Syntax and Compression
Transfer Syntax defines how DICOM data is encoded (byte order, VR encoding, pixel compression):
| Transfer Syntax | UID | Compression | Handler Needed |
|---|---|---|---|
| Implicit VR Little Endian | 1.2.840.10008.1.2 | None | No |
| Explicit VR Little Endian | 1.2.840.10008.1.2.1 | None | No |
| Explicit VR Big Endian | 1.2.840.10008.1.2.2 | None | No |
| JPEG Baseline | 1.2.840.10008.1.2.4.50 | Lossy JPEG | pylibjpeg |
| JPEG Lossless | 1.2.840.10008.1.2.4.70 | Lossless JPEG | pylibjpeg |
| JPEG 2000 Lossless | 1.2.840.10008.1.2.4.90 | Lossless J2K | pylibjpeg-openjpeg |
| JPEG 2000 | 1.2.840.10008.1.2.4.91 | Lossy J2K | pylibjpeg-openjpeg |
| RLE Lossless | 1.2.840.10008.1.2.5 | RLE | pydicom (built-in) |
Check transfer syntax: ds.file_meta.TransferSyntaxUID. Install the appropriate handler before accessing pixel_array on compressed files.
Essential DICOM Tags
Most commonly accessed tags (full catalog in references/dicom_standards.md):
| Tag | Keyword | VR | Description |
|---|---|---|---|
| (0008,0060) | Modality | CS | CT, MR, US, CR, DX, PT, NM |
| (0010,0010) | PatientName | PN | Patient's full name |
| (0010,0020) | PatientID | LO | Patient identifier |
| (0008,0020) | StudyDate | DA | Date of study (YYYYMMDD) |
| (0020,000D) | StudyInstanceUID | UI | Unique study identifier |
| (0020,0013) | InstanceNumber | IS | Image number in series |
| (0020,0032) | ImagePositionPatient | DS | x,y,z position (mm) |
| (0028,0010) | Rows | US | Image height in pixels |
| (0028,0011) | Columns | US | Image width in pixels |
| (0028,0030) | PixelSpacing | DS | Row,column spacing (mm) |
| (0028,1050) | WindowCenter | DS | Display window center |
| (0028,0004) | PhotometricInterpretation | CS | MONOCHROME1/2, RGB, YBR_FULL |
| (0028,0100) | BitsAllocated | US | 8 or 16 |
Value Representations (VR)
VR defines the data type for each element. Most common types (full table in references/dicom_standards.md):
| VR | Name | Python Type | Example |
|---|---|---|---|
| CS | Code String | str | "CT", "MR" |
| DA | Date | str | "20240115" |
| DS | Decimal String | DSfloat | "1.5" |
| IS | Integer String | IS | "42" |
| LO | Long String | str | "Study description" |
| PN | Person Name | PersonName | "Doe^John" |
| SQ | Sequence | Sequence | Nested datasets |
| UI | Unique Identifier | UID | "1.2.840..." |
| US | Unsigned Short | int | 512 |
Common Workflows
Workflow 1: Batch Metadata Extraction to CSV
Goal: Walk a directory of DICOM files, extract key metadata fields, and export to a CSV manifest.
import pydicom
import pandas as pd
from pathlib import Path
FIELDS = ['PatientID', 'Modality', 'StudyDate', 'SeriesDescription',
'StudyInstanceUID', 'SeriesInstanceUID', 'InstanceNumber',
'Rows', 'Columns', 'SliceThickness', 'BitsStored']
def extract_metadata(dcm_path):
"""Extract key metadata from a DICOM file."""
try:
ds = pydicom.dcmread(str(dcm_path), stop_before_pixels=True)
except Exception as e:
return {"file": str(dcm_path), "error": str(e)}
rec = {"file": str(dcm_path)}
for f in FIELDS:
rec[f] = str(getattr(ds, f, ''))
return rec
# Scan directory recursively
dicom_dir = Path("dicom_archive/")
records = [extract_metadata(f) for f in sorted(dicom_dir.rglob("*")) if f.is_file()]
df = pd.DataFrame(records)
df.to_csv("dicom_manifest.csv", index=False)
print(f"Extracted metadata from {len(df)} files")
print(f"Modalities: {df['Modality'].value_counts().to_dict()}")
print(f"Unique patients: {df['PatientID'].nunique()}")
Workflow 2: CT Series to 3D Volume with Visualization
Goal: Load a CT series, build a 3D volume in Hounsfield Units, and display axial/sagittal/coronal views.
import pydicom, numpy as np, matplotlib.pyplot as plt
from pathlib import Path
from pydicom.pixel_data_handlers.util import apply_modality_lut
# Load and sort series by z-position
dcm_files = []
for f in sorted(Path("ct_series/").glob("*")):
try: dcm_files.append(pydicom.dcmread(str(f)))
except Exception: continue
dcm_files.sort(key=lambda x: float(x.ImagePositionPatient[2]))
# Stack into 3D volume (Hounsfield Units)
volume = np.stack([apply_modality_lut(ds.pixel_array, ds) for ds in dcm_files])
ps = dcm_files[0].PixelSpacing
z_sp = abs(float(dcm_files[1].ImagePositionPatient[2])
- float(dcm_files[0].ImagePositionPatient[2]))
print(f"Volume: {volume.shape}, spacing: {z_sp:.2f}x{float(ps[0]):.2f}x{float(ps[1]):.2f} mm")
# Display orthogonal views (soft tissue window)
vmin, vmax = -160, 240 # center=40, width=400
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
mid = [s // 2 for s in volume.shape]
axes[0].imshow(volume[mid[0]], cmap='gray', vmin=vmin, vmax=vmax)
axes[0].set_title(f"Axial (slice {mid[0]})")
axes[1].imshow(volume[:, mid[1], :], cmap='gray', vmin=vmin, vmax=vmax,
aspect=z_sp/float(ps[1]))
axes[1].set_title(f"Coronal")
axes[2].imshow(volume[:, :, mid[2]], cmap='gray', vmin=vmin, vmax=vmax,
aspect=z_sp/float(ps[0]))
axes[2].set_title(f"Sagittal")
for ax in axes: ax.axis('off')
plt.tight_layout()
plt.savefig("orthogonal_views.png", dpi=150, bbox_inches='tight')
print("Saved orthogonal_views.png")
Workflow 3: Batch Anonymization Pipeline
Goal: Anonymize all DICOM files in a directory, preserving series structure with new UIDs.
import pydicom
from pydicom.uid import generate_uid
from pathlib import Path
PHI_TAGS = [
'PatientName', 'PatientID', 'PatientBirthDate', 'PatientSex',
'PatientAge', 'PatientWeight', 'PatientAddress',
'OtherPatientIDs', 'OtherPatientNames',
'InstitutionName', 'InstitutionAddress',
'ReferringPhysicianName', 'PerformingPhysicianName',
'OperatorsName', 'PhysiciansOfRecord', 'StudyID', 'AccessionNumber',
]
uid_map = {} # Preserves study/series relationships across files
def get_mapped_uid(uid):
if uid not in uid_map: uid_map[uid] = generate_uid()
return uid_map[uid]
input_dir, output_dir = Path("original_dicoms/"), Path("anonymized_dicoms/")
output_dir.mkdir(exist_ok=True)
count, errors = 0, 0
for dcm_path in sorted(input_dir.rglob("*")):
if not dcm_path.is_file(): continue
try: ds = pydicom.dcmread(str(dcm_path))
except Exception: errors += 1; continue
for tag in PHI_TAGS:
if hasattr(ds, tag): delattr(ds, tag)
ds.PatientName, ds.PatientID = "ANONYMOUS", "ANON"
ds.StudyInstanceUID = get_mapped_uid(ds.StudyInstanceUID)
ds.SeriesInstanceUID = get_mapped_uid(ds.SeriesInstanceUID)
ds.SOPInstanceUID = generate_uid()
ds.save_as(str(output_dir / f"anon_{count:06d}.dcm"))
count += 1
print(f"Anonymized {count} files, {errors} errors, {len(uid_map)} UIDs mapped")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
defer_size |
dcmread | None |
"1 KB", "1 MB", int bytes |
Defer loading elements larger than size |
stop_before_pixels |
dcmread | False |
True/False |
Skip pixel data loading (metadata only) |
force |
dcmread | False |
True/False |
Force read even if missing DICOM preamble |
WindowCenter |
Windowing | from file | Any numeric | Center of display window (HU for CT) |
WindowWidth |
Windowing | from file | > 0 |
Width of display window |
BitsAllocated |
Writing | 16 |
8, 16, 32 |
Bits allocated per pixel |
BitsStored |
Writing | 12 |
1-BitsAllocated |
Actual significant bits |
PixelRepresentation |
Writing | 0 |
0 (unsigned), 1 (signed) |
Pixel value signedness |
PhotometricInterpretation |
Writing | "MONOCHROME2" |
MONOCHROME1, MONOCHROME2, RGB, YBR_FULL |
Color space |
TransferSyntaxUID |
Writing | Explicit VR LE | See Transfer Syntax table | Encoding format |
Best Practices
-
Use
stop_before_pixels=Truefor metadata-only operations: Avoids loading large pixel arrays when only reading tags. Dramatically faster for batch metadata extraction.ds = pydicom.dcmread("scan.dcm", stop_before_pixels=True) -
Always use
getattr()with defaults for optional tags: DICOM files vary widely in which tags are present. Direct attribute access raisesAttributeErroron missing tags.# Good thickness = getattr(ds, 'SliceThickness', None) # Bad — will crash on files without SliceThickness thickness = ds.SliceThickness -
Apply Modality LUT before windowing for CT data: Raw pixel values are stored values; apply
apply_modality_lut()first to convert to Hounsfield Units, thenapply_voi_lut()for display. -
Install compression handlers before accessing compressed pixel data: Check
ds.file_meta.TransferSyntaxUIDand install the appropriate handler. Attemptingpixel_arraywithout the handler raisesRuntimeError. -
Generate new UIDs for every modified file: Never reuse original SOPInstanceUID after modifications. Use
pydicom.uid.generate_uid()to ensure global uniqueness. -
Use
save_as()instead of overwriting originals: Always save to a new path to preserve original data. DICOM archives may have integrity checks that fail if originals are modified in-place. -
Sort series by ImagePositionPatient for 3D reconstruction:
InstanceNumberis not always reliable.ImagePositionPatient[2](z-coordinate) gives correct physical ordering for axial CT/MR series.
Common Recipes
Recipe: Compression and Decompression Handling
When to use: Read compressed DICOM files or compress uncompressed ones.
import pydicom
ds = pydicom.dcmread("compressed.dcm")
ts = ds.file_meta.TransferSyntaxUID
# Check if compressed
print(f"Transfer Syntax: {ts}")
print(f"Compressed: {ts.is_compressed}")
# Decompress in-place (requires appropriate handler installed)
if ts.is_compressed:
ds.decompress()
print(f"Decompressed to {ds.file_meta.TransferSyntaxUID}")
# Access pixel data (works after decompression)
pixels = ds.pixel_array
print(f"Pixel shape: {pixels.shape}")
Recipe: Working with DICOM Sequences
When to use: Access nested data structures like referenced series, procedure codes, or protocol elements.
import pydicom
ds = pydicom.dcmread("structured.dcm")
# Access sequence elements (SQ VR = list of datasets)
if hasattr(ds, 'ReferencedStudySequence'):
for item in ds.ReferencedStudySequence:
print(f" Referenced Study: {item.ReferencedSOPInstanceUID}")
# Access procedure code sequence
if hasattr(ds, 'ProcedureCodeSequence'):
for code in ds.ProcedureCodeSequence:
print(f" Procedure: {code.CodeMeaning} ({code.CodeValue})")
# Create a sequence when writing DICOM
from pydicom.dataset import Dataset
from pydicom.sequence import Sequence
ref_item = Dataset()
ref_item.ReferencedSOPClassUID = '1.2.840.10008.5.1.4.1.1.2'
ref_item.ReferencedSOPInstanceUID = pydicom.uid.generate_uid()
ds.ReferencedImageSequence = Sequence([ref_item])
Recipe: Multi-Frame Extraction
When to use: Extract individual frames from cine, video, or enhanced multi-frame DICOM files.
import pydicom
import numpy as np
from PIL import Image
from pathlib import Path
ds = pydicom.dcmread("multiframe.dcm")
frames = ds.pixel_array # Shape: (num_frames, rows, cols) or (num_frames, rows, cols, 3)
num_frames = frames.shape[0]
print(f"Total frames: {num_frames}, Frame size: {frames.shape[1:]}")
# Extract all frames as images
out_dir = Path("frames/")
out_dir.mkdir(exist_ok=True)
for i in range(num_frames):
frame = frames[i]
# Normalize to uint8
if frame.dtype != np.uint8:
fmin, fmax = frame.min(), frame.max()
if fmax > fmin:
frame = ((frame - fmin) / (fmax - fmin) * 255).astype(np.uint8)
else:
frame = np.zeros_like(frame, dtype=np.uint8)
Image.fromarray(frame).save(out_dir / f"frame_{i:04d}.png")
print(f"Extracted {num_frames} frames to {out_dir}/")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
RuntimeError: No available image handler |
Compressed transfer syntax without codec | Install appropriate handler: pip install pylibjpeg pylibjpeg-libjpeg (JPEG), pip install pylibjpeg-openjpeg (JPEG 2000), or pip install python-gdcm (all formats) |
AttributeError: 'Dataset' has no attribute 'X' |
Optional tag not present in file | Use getattr(ds, 'X', default) or check 'X' in ds before access |
InvalidDicomError: File is missing DICOM preamble |
Non-standard DICOM file or non-DICOM file | Try pydicom.dcmread(path, force=True) to skip preamble check |
| Wrong pixel values (no negative HU) | Missing Modality LUT application | Apply apply_modality_lut(pixels, ds) before analysis — raw stored values differ from actual HU |
| Image appears inverted (bright/dark swapped) | MONOCHROME1 photometric interpretation | Check ds.PhotometricInterpretation; invert with np.max(pixels) - pixels for MONOCHROME1 |
MemoryError loading large series |
All slices loaded into memory at once | Process slices in batches; use stop_before_pixels=True for metadata scans; use defer_size for large elements |
| Inconsistent slice ordering in 3D volume | Sorted by InstanceNumber instead of position | Sort by ImagePositionPatient[2] for correct physical ordering |
| Garbled text in PatientName | Character encoding mismatch | Check SpecificCharacterSet tag; pydicom auto-decodes but some files have incorrect charset declarations |
TypeError when setting PixelData |
Wrong byte format for pixel array | Use pixel_array.tobytes() and ensure dtype matches BitsAllocated (uint16 for 16-bit) |
Bundled Resources
references/dicom_standards.md
Consolidated DICOM tag catalogs and transfer syntax reference. Complete tag tables for patient demographics, study/series identification, image geometry, pixel data encoding, windowing parameters, and modality-specific tags (CT Hounsfield parameters, MR sequence parameters, equipment identification, timing). Full transfer syntax UID table with compression types and handler installation. Value Representation (VR) type reference.
- Covers: All tag categories from common_tags.md (patient, study, series, image, pixel, windowing, CT-specific, MR-specific, equipment, timing); all transfer syntax UIDs from transfer_syntaxes.md with compression formats and handler mapping; VR type catalog
- Relocated inline: Essential tags table (20 most common) moved to Key Concepts; transfer syntax summary table (8 most common) moved to Key Concepts; VR summary table moved to Key Concepts
- Omitted: Step-by-step handler installation tutorials (covered in Prerequisites and Troubleshooting); verbose prose descriptions of each tag (tables are self-documenting)
Original file disposition (5 files):
- SKILL.md (434 lines) -- Migrated: overview, workflows, best practices, common issues restructured into new SKILL.md format
- references/common_tags.md (229 lines) -- (b) Consolidated: essential 20-tag table into Key Concepts; full catalog into references/dicom_standards.md
- references/transfer_syntaxes.md (353 lines) -- (b) Consolidated: summary table (8 syntaxes) into Key Concepts; full UID table and handler details into references/dicom_standards.md
- scripts/anonymize_dicom.py (138 lines) -- (c) Absorbed: PHI tag list and anonymize function into Core API Module 4; batch pipeline into Workflow 3
- scripts/dicom_to_image.py (173 lines) -- (c) Absorbed: conversion function into Core API Module 3 (dicom_to_image helper); batch conversion into Module 3 second code block
- scripts/extract_metadata.py (174 lines) -- (c) Absorbed: metadata extraction function into Workflow 1 (batch extraction to CSV)
Related Skills
- histolab-wsi-processing -- whole-slide pathology image processing (SVS, NDPI); use for digital pathology tile extraction
- nibabel (planned) -- NIfTI neuroimaging format for brain MRI volumetric analysis
- matplotlib-scientific-plotting -- publication-quality visualization of DICOM images and 3D volume slices
References
- Pydicom documentation -- official API reference and user guide
- Pydicom GitHub repository -- source code, examples, and issue tracker
- DICOM Standard Browser -- interactive DICOM tag and IOD reference
- pylibjpeg documentation -- JPEG codec plugin for pydicom
skills/medical-imaging/simpleitk-image-registration/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill simpleitk-image-registration -g -y
SKILL.md
Frontmatter
{
"name": "simpleitk-image-registration",
"license": "Apache-2.0",
"description": "Register, segment, filter, resample 3D medical images (MRI, CT, microscopy) via SimpleITK Python; DICOM, NIfTI, multi-modal. Rigid\/affine\/deformable registration, threshold\/region-growing segmentation, Gaussian\/morph filtering, label stats, format conversion. Use to align volumes across timepoints\/modalities, segment fluorescence, or convert DICOM→NIfTI."
}
SimpleITK Image Registration and Analysis
Overview
SimpleITK is a simplified, high-level interface to the Insight Toolkit (ITK) for medical image processing. It provides Python-native access to registration (rigid, affine, B-spline, Demons), segmentation (thresholding, region growing, watershed, level sets), filtering (smoothing, morphology, gradients), and resampling for 3D/4D images from MRI, CT, ultrasound, and fluorescence microscopy. SimpleITK images carry physical space metadata (spacing, origin, direction cosines) which is critical for correct anatomical interpretation and multi-modal alignment.
When to Use
- Registering MRI volumes across timepoints (longitudinal studies) or to a standard atlas for normalization
- Segmenting cells or nuclei from fluorescence microscopy using Otsu thresholding with morphological cleanup
- Converting DICOM series (CT, MRI scanner output) to NIfTI format for downstream analysis with FSL or ANTs
- Applying pre-computed transforms to resample images to a common resolution or field of view
- Computing region statistics (volume, mean intensity, surface area) from binary label masks
- Running multi-modal registration (e.g., aligning PET to MRI) using mutual information metrics
- Use ANTs (via
antspyx) instead when you need state-of-the-art diffeomorphic registration with multi-atlas label fusion for neuroimaging research; SimpleITK is better for Python-native scriptable pipelines without native dependencies - Use scikit-image (
scikit-image-processing) instead for 2D bioimage analysis withregionprops, morphological operations, and watershed on non-volumetric fluorescence microscopy data
Prerequisites
- Python packages:
SimpleITK>=2.3,numpy,matplotlib - Optional:
SimpleITK-SimpleElastixfor additional registration algorithms (Elastix) - Data requirements: DICOM series (CT/MRI), NIfTI files (.nii or .nii.gz), or any ITK-supported format (MetaImage, NRRD, PNG, TIFF stacks)
- Environment: Python 3.8+; no GPU required; 8 GB RAM recommended for typical 3D volumes
pip install SimpleITK numpy matplotlib
# For additional Elastix-based registration algorithms:
pip install SimpleITK-SimpleElastix
Quick Start
import SimpleITK as sitk
# Read a NIfTI file, apply Gaussian smoothing, and save
image = sitk.ReadImage("brain_t1.nii.gz")
print(f"Size: {image.GetSize()}, Spacing: {image.GetSpacing()}")
smoothed = sitk.SmoothingRecursiveGaussian(image, sigma=1.0)
# Otsu threshold to create a brain mask
mask = sitk.OtsuThreshold(smoothed, 0, 1, 200)
print(f"Voxels in mask: {sitk.GetArrayFromImage(mask).sum()}")
sitk.WriteImage(mask, "brain_mask.nii.gz")
print("Saved brain_mask.nii.gz")
Core API
Module 1: Image I/O
Reading and writing DICOM series, NIfTI, and other formats with full metadata preservation.
import SimpleITK as sitk
# Read a NIfTI file
image = sitk.ReadImage("subject_t1.nii.gz", sitk.sitkFloat32)
print(f"Size (x,y,z): {image.GetSize()}")
print(f"Spacing (mm): {image.GetSpacing()}")
print(f"Origin: {image.GetOrigin()}")
print(f"Direction: {image.GetDirection()}")
# Write as compressed NIfTI
sitk.WriteImage(image, "output.nii.gz")
print("Saved output.nii.gz")
import SimpleITK as sitk
import os
# Read a DICOM series from a directory
dicom_dir = "DICOM/series_001/"
series_ids = sitk.ImageSeriesReader.GetGDCMSeriesIDs(dicom_dir)
print(f"Found {len(series_ids)} DICOM series")
reader = sitk.ImageSeriesReader()
reader.SetFileNames(sitk.ImageSeriesReader.GetGDCMSeriesFileNames(dicom_dir, series_ids[0]))
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
volume = reader.Execute()
print(f"DICOM volume size: {volume.GetSize()}")
print(f"Pixel spacing: {volume.GetSpacing()}")
# Save the 3D volume as NIfTI
sitk.WriteImage(volume, "ct_volume.nii.gz")
print("DICOM series → ct_volume.nii.gz")
Module 2: Image Filtering
Gaussian smoothing, median filtering, gradient magnitude, and edge-preserving filters.
import SimpleITK as sitk
import numpy as np
image = sitk.ReadImage("fluorescence_cells.nii.gz", sitk.sitkFloat32)
# Gaussian smoothing — reduces noise before segmentation
smoothed = sitk.SmoothingRecursiveGaussian(image, sigma=1.5)
# Median filter — removes salt-and-pepper noise (preserves edges better than Gaussian)
median_filtered = sitk.Median(image, [3, 3, 3])
# Gradient magnitude — highlights edges/boundaries
gradient = sitk.GradientMagnitude(smoothed)
arr = sitk.GetArrayFromImage(gradient)
print(f"Gradient range: {arr.min():.2f} – {arr.max():.2f}")
print(f"Mean gradient: {arr.mean():.4f}")
sitk.WriteImage(smoothed, "smoothed.nii.gz")
sitk.WriteImage(gradient, "gradient.nii.gz")
import SimpleITK as sitk
image = sitk.ReadImage("ct_volume.nii.gz", sitk.sitkFloat32)
# Normalize intensity to [0, 1] range using RescaleIntensity
rescaled = sitk.RescaleIntensity(image, outputMinimum=0.0, outputMaximum=1.0)
# Histogram equalization — improves contrast for registration
equalized = sitk.AdaptiveHistogramEqualization(rescaled)
# N4 bias field correction for MRI (removes B1 field inhomogeneity)
# Cast to float32 for bias correction
image_f32 = sitk.Cast(image, sitk.sitkFloat32)
mask_otsu = sitk.OtsuThreshold(image_f32, 0, 1, 200)
corrected = sitk.N4BiasFieldCorrection(image_f32, mask_otsu)
print("Applied: rescaling, histogram equalization, N4 bias correction")
sitk.WriteImage(corrected, "bias_corrected.nii.gz")
Module 3: Image Registration
Rigid, affine, and deformable (B-spline, Demons) registration using ImageRegistrationMethod.
import SimpleITK as sitk
# Load fixed (reference/atlas) and moving (subject to align) images
fixed = sitk.ReadImage("atlas_t1.nii.gz", sitk.sitkFloat32)
moving = sitk.ReadImage("subject_t1.nii.gz", sitk.sitkFloat32)
# Set up rigid registration
registration_method = sitk.ImageRegistrationMethod()
# Similarity metric: Mattes mutual information (works for same-modality)
registration_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
registration_method.SetMetricSamplingStrategy(registration_method.RANDOM)
registration_method.SetMetricSamplingPercentage(0.01)
# Optimizer: gradient descent with line search
registration_method.SetOptimizerAsGradientDescent(
learningRate=1.0, numberOfIterations=100,
convergenceMinimumValue=1e-6, convergenceWindowSize=10
)
registration_method.SetOptimizerScalesFromPhysicalShift()
# Multi-resolution pyramid: 3 levels → faster convergence
registration_method.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1])
registration_method.SetSmoothingSigmasPerLevel(smoothingSigmas=[2, 1, 0])
registration_method.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
# Initialize with center of geometry
initial_transform = sitk.CenteredTransformInitializer(
fixed, moving,
sitk.Euler3DTransform(),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
registration_method.SetInitialTransform(initial_transform, inPlace=False)
registration_method.SetInterpolator(sitk.sitkLinear)
# Execute registration
final_transform = registration_method.Execute(fixed, moving)
print(f"Optimizer stop: {registration_method.GetOptimizerStopConditionDescription()}")
print(f"Final metric: {registration_method.GetMetricValue():.4f}")
# Apply transform and save
resampled = sitk.Resample(
moving, fixed, final_transform,
sitk.sitkLinear, 0.0, moving.GetPixelID()
)
sitk.WriteImage(resampled, "subject_registered.nii.gz")
sitk.WriteTransform(final_transform, "rigid_transform.tfm")
print("Saved: subject_registered.nii.gz, rigid_transform.tfm")
import SimpleITK as sitk
# Deformable B-spline registration for non-linear alignment
fixed = sitk.ReadImage("atlas_t1.nii.gz", sitk.sitkFloat32)
moving = sitk.ReadImage("subject_t1.nii.gz", sitk.sitkFloat32)
# Start with affine pre-registration
affine_method = sitk.ImageRegistrationMethod()
affine_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
affine_method.SetMetricSamplingStrategy(affine_method.RANDOM)
affine_method.SetMetricSamplingPercentage(0.01)
affine_method.SetOptimizerAsGradientDescent(
learningRate=1.0, numberOfIterations=100,
convergenceMinimumValue=1e-6, convergenceWindowSize=10
)
affine_method.SetShrinkFactorsPerLevel([4, 2, 1])
affine_method.SetSmoothingSigmasPerLevel([2, 1, 0])
affine_method.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
affine_init = sitk.CenteredTransformInitializer(
fixed, moving, sitk.AffineTransform(3),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
affine_method.SetInitialTransform(affine_init, inPlace=False)
affine_method.SetInterpolator(sitk.sitkLinear)
affine_transform = affine_method.Execute(fixed, moving)
print(f"Affine complete: metric = {affine_method.GetMetricValue():.4f}")
# B-spline deformable refinement
bspline_method = sitk.ImageRegistrationMethod()
bspline_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
bspline_method.SetMetricSamplingStrategy(bspline_method.RANDOM)
bspline_method.SetMetricSamplingPercentage(0.01)
bspline_method.SetOptimizerAsGradientDescent(
learningRate=0.5, numberOfIterations=50,
convergenceMinimumValue=1e-6, convergenceWindowSize=10
)
bspline_method.SetShrinkFactorsPerLevel([2, 1])
bspline_method.SetSmoothingSigmasPerLevel([1, 0])
bspline_method.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
mesh_size = [8] * fixed.GetDimension()
bspline_init = sitk.BSplineTransformInitializer(image1=fixed, transformDomainMeshSize=mesh_size, order=3)
composite = sitk.CompositeTransform(3)
composite.AddTransform(affine_transform)
composite.AddTransform(bspline_init)
bspline_method.SetInitialTransform(composite, inPlace=True)
bspline_method.SetInterpolator(sitk.sitkLinear)
deformable_transform = bspline_method.Execute(fixed, moving)
resampled = sitk.Resample(moving, fixed, deformable_transform, sitk.sitkLinear, 0.0)
sitk.WriteImage(resampled, "subject_deformable_registered.nii.gz")
print("Saved: subject_deformable_registered.nii.gz")
Module 4: Segmentation
Otsu thresholding, region growing, watershed, and morphological post-processing.
import SimpleITK as sitk
import numpy as np
# Read fluorescence microscopy image
image = sitk.ReadImage("cells_gfp.nii.gz", sitk.sitkFloat32)
# Gaussian smoothing before thresholding
smoothed = sitk.SmoothingRecursiveGaussian(image, sigma=1.0)
# Otsu thresholding: automatically finds optimal foreground/background threshold
# Returns binary label image: 1=foreground (cells), 0=background
binary = sitk.OtsuThreshold(smoothed, insideValue=0, outsideValue=1, numberOfHistogramBins=200)
# Count segmented voxels
arr = sitk.GetArrayFromImage(binary)
n_foreground = arr.sum()
total = arr.size
print(f"Foreground: {n_foreground} voxels ({100*n_foreground/total:.1f}%)")
sitk.WriteImage(binary, "cells_binary_otsu.nii.gz")
print("Saved: cells_binary_otsu.nii.gz")
import SimpleITK as sitk
image = sitk.ReadImage("mri_brain.nii.gz", sitk.sitkFloat32)
smoothed = sitk.SmoothingRecursiveGaussian(image, sigma=0.5)
# Region growing from a seed point: ConnectedThreshold
# Seeds must be inside the region of interest; lower/upper bound in image intensity units
seed = (128, 128, 64) # (x, y, z) in image coordinates
lower_threshold = 50.0
upper_threshold = 200.0
region_grown = sitk.ConnectedThreshold(
smoothed,
seedList=[seed],
lower=lower_threshold,
upper=upper_threshold,
replaceValue=1
)
print(f"Region growing: {sitk.GetArrayFromImage(region_grown).sum()} voxels segmented")
# ConfidenceConnected: adaptive thresholding based on neighborhood statistics
confidence_seg = sitk.ConfidenceConnected(
smoothed,
seedList=[seed],
numberOfIterations=2,
multiplier=2.5, # number of standard deviations from mean
initialNeighborhoodRadius=2,
replaceValue=1
)
print(f"Confidence connected: {sitk.GetArrayFromImage(confidence_seg).sum()} voxels")
sitk.WriteImage(region_grown, "region_grown.nii.gz")
sitk.WriteImage(confidence_seg, "confidence_seg.nii.gz")
import SimpleITK as sitk
# Morphological operations for binary mask cleanup
binary = sitk.ReadImage("cells_binary_otsu.nii.gz")
# Fill small holes
filled = sitk.BinaryFillhole(binary)
# Erosion: remove thin protrusions and separate touching objects
eroded = sitk.BinaryErode(filled, kernelRadius=(2, 2, 1), kernelType=sitk.sitkBall)
# Dilation: restore true boundary after erosion
dilated = sitk.BinaryDilate(eroded, kernelRadius=(2, 2, 1), kernelType=sitk.sitkBall)
# Opening = erosion + dilation: removes small objects
opened = sitk.BinaryMorphologicalOpening(binary, kernelRadius=(1, 1, 1), kernelType=sitk.sitkBall)
# Connected component labeling: assign unique integer to each object
labeled = sitk.ConnectedComponent(opened)
n_objects = sitk.GetArrayFromImage(labeled).max()
print(f"Connected components (objects): {n_objects}")
# Remove objects smaller than 100 voxels
relabeled = sitk.RelabelComponent(labeled, minimumObjectSize=100)
n_kept = sitk.GetArrayFromImage(relabeled).max()
print(f"Objects remaining after size filter: {n_kept}")
sitk.WriteImage(relabeled, "cells_labeled.nii.gz")
Module 5: Resampling and Transform Application
Applying transforms, resampling to a reference grid, and converting between array and image representations.
import SimpleITK as sitk
import numpy as np
# Resample moving image to match reference grid
reference = sitk.ReadImage("reference_volume.nii.gz")
moving = sitk.ReadImage("subject_volume.nii.gz")
# Load a pre-computed transform
transform = sitk.ReadTransform("rigid_transform.tfm")
# Resample with linear interpolation (use nearest neighbor for label images)
resampled = sitk.Resample(
moving,
reference,
transform,
sitk.sitkLinear, # interpolator; use sitk.sitkNearestNeighbor for labels
0.0, # default pixel value for regions outside the moving image
moving.GetPixelID()
)
print(f"Resampled size: {resampled.GetSize()} (matches reference: {reference.GetSize()})")
sitk.WriteImage(resampled, "resampled_to_reference.nii.gz")
import SimpleITK as sitk
import numpy as np
# Convert between SimpleITK image and NumPy array
image = sitk.ReadImage("ct_volume.nii.gz", sitk.sitkFloat32)
# SimpleITK uses (x, y, z) ordering; NumPy gets (z, y, x) ordering
arr = sitk.GetArrayFromImage(image)
print(f"NumPy array shape (z,y,x): {arr.shape}")
print(f"Value range: {arr.min():.1f} – {arr.max():.1f}")
# Apply NumPy operations
clipped = np.clip(arr, -1000, 3000) # CT Hounsfield unit clipping
normalized = (clipped - clipped.mean()) / clipped.std()
# Convert back to SimpleITK image (preserving spatial metadata)
out_image = sitk.GetImageFromArray(normalized)
out_image.CopyInformation(image) # copy spacing, origin, direction from original
print(f"Output size: {out_image.GetSize()}, Spacing: {out_image.GetSpacing()}")
# Resample to isotropic 1mm spacing
new_spacing = [1.0, 1.0, 1.0]
orig_spacing = image.GetSpacing()
orig_size = image.GetSize()
new_size = [
int(round(orig_size[i] * orig_spacing[i] / new_spacing[i]))
for i in range(3)
]
resampled_iso = sitk.Resample(
image,
new_size,
sitk.Transform(), # identity transform
sitk.sitkLinear,
image.GetOrigin(),
new_spacing,
image.GetDirection(),
0.0,
image.GetPixelID()
)
print(f"Isotropic resampled size: {resampled_iso.GetSize()}")
sitk.WriteImage(resampled_iso, "isotropic_1mm.nii.gz")
Module 6: Statistics and Measurement
Label shape statistics (volume, surface area, centroid, bounding box) and intensity statistics per region.
import SimpleITK as sitk
import pandas as pd
# Load intensity image and binary label mask
intensity = sitk.ReadImage("fluorescence_cells.nii.gz", sitk.sitkFloat32)
labels = sitk.ReadImage("cells_labeled.nii.gz", sitk.sitkUInt32)
# Shape statistics: geometric measurements per label
shape_filter = sitk.LabelShapeStatisticsImageFilter()
shape_filter.ComputeOrientedBoundingBoxOn()
shape_filter.Execute(labels)
label_ids = shape_filter.GetLabels()
print(f"Number of labeled objects: {len(label_ids)}")
rows = []
for lbl in label_ids:
rows.append({
"label": lbl,
"volume_voxels": shape_filter.GetNumberOfPixels(lbl),
"volume_mm3": shape_filter.GetPhysicalSize(lbl),
"centroid_x": shape_filter.GetCentroid(lbl)[0],
"centroid_y": shape_filter.GetCentroid(lbl)[1],
"centroid_z": shape_filter.GetCentroid(lbl)[2],
"elongation": shape_filter.GetElongation(lbl),
"roundness": shape_filter.GetRoundness(lbl),
})
shape_df = pd.DataFrame(rows)
print(shape_df.head())
print(f"\nMean volume: {shape_df['volume_mm3'].mean():.1f} mm³")
shape_df.to_csv("cell_shape_stats.csv", index=False)
print("Saved: cell_shape_stats.csv")
import SimpleITK as sitk
import pandas as pd
intensity = sitk.ReadImage("fluorescence_cells.nii.gz", sitk.sitkFloat32)
labels = sitk.ReadImage("cells_labeled.nii.gz", sitk.sitkUInt32)
# Intensity statistics per label
intensity_filter = sitk.LabelIntensityStatisticsImageFilter()
intensity_filter.Execute(labels, intensity)
label_ids = intensity_filter.GetLabels()
rows = []
for lbl in label_ids:
rows.append({
"label": lbl,
"mean_intensity": intensity_filter.GetMean(lbl),
"std_intensity": intensity_filter.GetSigma(lbl),
"min_intensity": intensity_filter.GetMinimum(lbl),
"max_intensity": intensity_filter.GetMaximum(lbl),
"median_intensity": intensity_filter.GetMedian(lbl),
"sum_intensity": intensity_filter.GetSum(lbl),
})
intensity_df = pd.DataFrame(rows)
print(intensity_df.describe().round(2))
intensity_df.to_csv("cell_intensity_stats.csv", index=False)
print(f"\nSaved: cell_intensity_stats.csv ({len(rows)} cells)")
Key Concepts
Physical Space vs. Pixel Space
SimpleITK images store spatial metadata (spacing in mm, origin, direction cosines) that defines how pixel coordinates map to physical (world) coordinates. Operations like Resample and registration work in physical space, ensuring anatomically correct alignment even when images have different voxel sizes or orientations. Always use CopyInformation() when creating output images from NumPy arrays to preserve physical metadata.
import SimpleITK as sitk
import numpy as np
image = sitk.ReadImage("mri.nii.gz")
print(f"Pixel space size: {image.GetSize()}") # (x, y, z) in voxels
print(f"Physical spacing mm: {image.GetSpacing()}") # mm per voxel
print(f"Physical origin mm: {image.GetOrigin()}") # world coords of first voxel
print(f"Direction cosines: {image.GetDirection()}") # patient orientation matrix
# Convert pixel index to physical point
pixel_idx = (64, 64, 32)
physical_pt = image.TransformIndexToPhysicalPoint(pixel_idx)
print(f"Pixel {pixel_idx} → physical {physical_pt}")
# NumPy array has REVERSED axis order: (z, y, x)
arr = sitk.GetArrayFromImage(image)
print(f"NumPy shape: {arr.shape}") # (z, y, x)
Transform Composition
SimpleITK transforms can be composed using CompositeTransform to apply a sequence of transformations (e.g., affine pre-alignment followed by deformable B-spline refinement). Transforms are applied in the order they were added when calling Resample, enabling modular pipeline construction.
Common Workflows
Workflow 1: Fluorescence Microscopy Cell Segmentation
Goal: Segment individual cells from a 3D fluorescence microscopy volume, apply morphological cleanup, and extract per-cell measurements.
import SimpleITK as sitk
import pandas as pd
import numpy as np
# 1. Load fluorescence volume
image = sitk.ReadImage("cells_gfp.nii.gz", sitk.sitkFloat32)
print(f"Image size: {image.GetSize()}, Spacing: {image.GetSpacing()}")
# 2. Denoise with Gaussian smoothing
smoothed = sitk.SmoothingRecursiveGaussian(image, sigma=0.8)
# 3. Otsu threshold to get binary foreground mask
binary = sitk.OtsuThreshold(smoothed, insideValue=0, outsideValue=1, numberOfHistogramBins=200)
n_fg = sitk.GetArrayFromImage(binary).sum()
print(f"Foreground voxels after Otsu: {n_fg}")
# 4. Morphological cleanup
filled = sitk.BinaryFillhole(binary)
opened = sitk.BinaryMorphologicalOpening(filled, kernelRadius=(1, 1, 1))
# 5. Label individual connected components (cells)
labeled = sitk.ConnectedComponent(opened)
relabeled = sitk.RelabelComponent(labeled, minimumObjectSize=200)
n_cells = int(sitk.GetArrayFromImage(relabeled).max())
print(f"Detected cells (>200 voxels): {n_cells}")
# 6. Compute shape statistics
shape_filter = sitk.LabelShapeStatisticsImageFilter()
shape_filter.Execute(relabeled)
intensity_filter = sitk.LabelIntensityStatisticsImageFilter()
intensity_filter.Execute(relabeled, image)
rows = []
for lbl in shape_filter.GetLabels():
rows.append({
"cell_id": lbl,
"volume_mm3": shape_filter.GetPhysicalSize(lbl),
"roundness": shape_filter.GetRoundness(lbl),
"elongation": shape_filter.GetElongation(lbl),
"mean_gfp_intensity": intensity_filter.GetMean(lbl),
"total_gfp_intensity": intensity_filter.GetSum(lbl),
})
df = pd.DataFrame(rows)
print(df.describe().round(3))
# 7. Save outputs
sitk.WriteImage(relabeled, "cells_labeled.nii.gz")
df.to_csv("cell_measurements.csv", index=False)
print(f"\nSaved: cells_labeled.nii.gz, cell_measurements.csv ({n_cells} cells)")
Workflow 2: MRI Brain Atlas Registration
Goal: Register a subject's T1 MRI to a standard brain atlas using rigid + affine registration, then transfer atlas labels to subject space.
import SimpleITK as sitk
import numpy as np
# 1. Load atlas (fixed) and subject (moving) T1 MRI
atlas_t1 = sitk.ReadImage("mni152_t1.nii.gz", sitk.sitkFloat32)
subject_t1 = sitk.ReadImage("subject_t1.nii.gz", sitk.sitkFloat32)
atlas_labels = sitk.ReadImage("mni152_labels.nii.gz", sitk.sitkUInt16)
print(f"Atlas size: {atlas_t1.GetSize()}, Subject size: {subject_t1.GetSize()}")
# 2. Normalize intensities for better registration convergence
atlas_norm = sitk.RescaleIntensity(atlas_t1, 0.0, 1.0)
subject_norm = sitk.RescaleIntensity(subject_t1, 0.0, 1.0)
# 3. Initialize with center-of-geometry alignment
initial_transform = sitk.CenteredTransformInitializer(
atlas_norm, subject_norm,
sitk.AffineTransform(3),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
# 4. Configure and run affine registration
reg = sitk.ImageRegistrationMethod()
reg.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
reg.SetMetricSamplingStrategy(reg.RANDOM)
reg.SetMetricSamplingPercentage(0.01)
reg.SetOptimizerAsGradientDescent(
learningRate=1.0, numberOfIterations=200,
convergenceMinimumValue=1e-6, convergenceWindowSize=10
)
reg.SetOptimizerScalesFromPhysicalShift()
reg.SetShrinkFactorsPerLevel([4, 2, 1])
reg.SetSmoothingSigmasPerLevel([2, 1, 0])
reg.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
reg.SetInitialTransform(initial_transform, inPlace=False)
reg.SetInterpolator(sitk.sitkLinear)
final_transform = reg.Execute(atlas_norm, subject_norm)
print(f"Registration metric: {reg.GetMetricValue():.4f}")
print(f"Optimizer: {reg.GetOptimizerStopConditionDescription()}")
# 5. Apply transform to subject T1 (aligned to atlas space)
subject_registered = sitk.Resample(
subject_t1, atlas_t1, final_transform,
sitk.sitkLinear, 0.0, subject_t1.GetPixelID()
)
# 6. Invert transform to bring atlas labels to subject space
inverse_transform = final_transform.GetInverse()
labels_in_subject_space = sitk.Resample(
atlas_labels, subject_t1, inverse_transform,
sitk.sitkNearestNeighbor, 0, atlas_labels.GetPixelID()
)
# 7. Save results
sitk.WriteImage(subject_registered, "subject_in_atlas_space.nii.gz")
sitk.WriteImage(labels_in_subject_space, "atlas_labels_in_subject_space.nii.gz")
sitk.WriteTransform(final_transform, "subject_to_atlas.tfm")
n_labels = int(sitk.GetArrayFromImage(labels_in_subject_space).max())
print(f"Transferred {n_labels} atlas regions to subject space")
print("Saved: subject_in_atlas_space.nii.gz, atlas_labels_in_subject_space.nii.gz")
Workflow 3: DICOM Series to NIfTI Batch Conversion
Goal: Convert multiple DICOM series from a scanner directory to NIfTI with isotropic resampling.
import SimpleITK as sitk
from pathlib import Path
def convert_dicom_series(dicom_dir: str, output_path: str, target_spacing_mm: float = 1.0) -> dict:
"""Convert a DICOM series directory to NIfTI with optional isotropic resampling."""
series_ids = sitk.ImageSeriesReader.GetGDCMSeriesIDs(dicom_dir)
if not series_ids:
return {"error": f"No DICOM series in {dicom_dir}"}
reader = sitk.ImageSeriesReader()
reader.SetFileNames(sitk.ImageSeriesReader.GetGDCMSeriesFileNames(dicom_dir, series_ids[0]))
volume = reader.Execute()
orig_size = volume.GetSize()
orig_spacing = volume.GetSpacing()
# Resample to isotropic voxels
new_spacing = [target_spacing_mm] * 3
new_size = [int(round(orig_size[i] * orig_spacing[i] / target_spacing_mm)) for i in range(3)]
resampled = sitk.Resample(
volume, new_size, sitk.Transform(),
sitk.sitkLinear, volume.GetOrigin(),
new_spacing, volume.GetDirection(),
0.0, volume.GetPixelID()
)
sitk.WriteImage(resampled, output_path)
return {
"input": dicom_dir, "output": output_path,
"orig_size": orig_size, "orig_spacing": orig_spacing,
"new_size": new_size, "n_series": len(series_ids)
}
# Batch convert all subject directories
input_root = Path("DICOM_data/")
output_root = Path("NIfTI_converted/")
output_root.mkdir(exist_ok=True)
results = []
for subject_dir in sorted(input_root.iterdir()):
if subject_dir.is_dir():
out_file = output_root / f"{subject_dir.name}_t1.nii.gz"
info = convert_dicom_series(str(subject_dir), str(out_file), target_spacing_mm=1.0)
results.append(info)
print(f" {subject_dir.name}: {info.get('orig_size')} → {info.get('new_size')}")
print(f"\nConverted {len(results)} subjects to {output_root}/")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
sigma |
SmoothingRecursiveGaussian | — | 0.5–5.0 mm |
Gaussian smoothing width; larger values blur more and reduce noise |
numberOfHistogramBins |
OtsuThreshold, MutualInfo | 128 |
50–256 |
Histogram resolution for threshold/metric calculation |
numberOfIterations |
ImageRegistrationMethod | 100 |
50–500 |
Max optimizer steps; increase for complex/fine registration |
learningRate |
GradientDescent optimizer | 1.0 |
0.01–2.0 |
Step size per optimizer iteration; too large causes divergence |
shrinkFactors |
Multi-resolution pyramid | [4,2,1] |
lists of [8,4,2,1] |
Image downsampling per resolution level; larger = faster but coarser |
minimumObjectSize |
RelabelComponent | 0 |
1–10000 voxels |
Remove connected components smaller than this size in voxels |
kernelRadius |
BinaryErode/Dilate | (1,1,1) |
(1,1,1) – (5,5,5) |
Morphological kernel radius per axis in voxels |
interpolator |
Resample | sitk.sitkLinear |
sitkLinear, sitkNearestNeighbor, sitkBSpline |
Interpolation method; use NearestNeighbor for integer label images |
multiplier |
ConfidenceConnected | 2.5 |
1.5–4.0 |
Number of std devs from neighborhood mean to accept a voxel |
meshSize |
BSplineTransformInitializer | [8]*ndim |
[4]*ndim – [20]*ndim |
B-spline control point grid; larger = more deformation degrees of freedom |
Best Practices
-
Always cast to float32 before registration or filtering: Many ITK filters expect float input. Integers from DICOM (typically
sitk.sitkInt16) can cause silent errors or precision loss.image = sitk.ReadImage("ct.nii.gz", sitk.sitkFloat32) # explicit cast at load time # Or: image_f32 = sitk.Cast(image, sitk.sitkFloat32) -
Use
CopyInformation()when creating images from NumPy arrays: Without this, the output image loses physical spacing and origin, making it impossible to overlay with the source in a viewer.arr = sitk.GetArrayFromImage(image) modified = some_numpy_operation(arr) out = sitk.GetImageFromArray(modified) out.CopyInformation(image) # preserve spacing, origin, direction -
Use
sitkNearestNeighborfor label/mask resampling: Linear or B-spline interpolation on integer label images creates fractional label values that corrupt the mask. Always use nearest-neighbor for binary masks and multi-label segmentations. -
Run multi-resolution registration for speed and robustness: Set
SetShrinkFactorsPerLevel([4,2,1])andSetSmoothingSigmasPerLevel([2,1,0])to prevent the optimizer from getting trapped in local minima on large 3D volumes. -
Validate registration visually with checkerboard comparison: Before trusting a registration result, verify alignment using
sitk.CheckerBoardbetween fixed and registered-moving.checker = sitk.CheckerBoard(fixed, resampled_moving, [5, 5, 5]) sitk.WriteImage(checker, "registration_checker.nii.gz")
Common Recipes
Recipe: Multi-Modal Registration (MRI to CT)
When to use: Aligning different imaging modalities (e.g., PET to MRI, CT to MRI) where intensities are incompatible, requiring mutual information as metric.
import SimpleITK as sitk
# Load images from different modalities
ct = sitk.ReadImage("patient_ct.nii.gz", sitk.sitkFloat32)
mri = sitk.ReadImage("patient_mri.nii.gz", sitk.sitkFloat32)
# Mutual information metric handles multi-modal intensity relationships
reg = sitk.ImageRegistrationMethod()
reg.SetMetricAsMattesMutualInformation(numberOfHistogramBins=100)
reg.SetMetricSamplingStrategy(reg.RANDOM)
reg.SetMetricSamplingPercentage(0.05)
reg.SetOptimizerAsGradientDescent(
learningRate=1.0, numberOfIterations=150,
convergenceMinimumValue=1e-6, convergenceWindowSize=10
)
reg.SetOptimizerScalesFromPhysicalShift()
reg.SetShrinkFactorsPerLevel([4, 2, 1])
reg.SetSmoothingSigmasPerLevel([2, 1, 0])
reg.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
initial = sitk.CenteredTransformInitializer(
ct, mri, sitk.AffineTransform(3),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
reg.SetInitialTransform(initial, inPlace=False)
reg.SetInterpolator(sitk.sitkLinear)
transform = reg.Execute(ct, mri)
mri_aligned = sitk.Resample(mri, ct, transform, sitk.sitkLinear, 0.0)
sitk.WriteImage(mri_aligned, "mri_aligned_to_ct.nii.gz")
print(f"Multi-modal registration complete. Metric: {reg.GetMetricValue():.4f}")
Recipe: Batch Apply Transform to Label Masks
When to use: After registering one image, apply the same transform to segmentation masks or atlas labels (using nearest-neighbor interpolation).
import SimpleITK as sitk
from pathlib import Path
# Load the transform computed during registration
transform = sitk.ReadTransform("subject_to_atlas.tfm")
reference = sitk.ReadImage("atlas_t1.nii.gz")
# Apply to all label files in a directory
label_dir = Path("subject_labels/")
output_dir = Path("atlas_labels/")
output_dir.mkdir(exist_ok=True)
for label_file in sorted(label_dir.glob("*.nii.gz")):
label = sitk.ReadImage(str(label_file), sitk.sitkUInt16)
registered_label = sitk.Resample(
label, reference, transform,
sitk.sitkNearestNeighbor, # critical: integer labels need nearest-neighbor
0, label.GetPixelID()
)
out_path = output_dir / label_file.name
sitk.WriteImage(registered_label, str(out_path))
n_labels = int(sitk.GetArrayFromImage(registered_label).max())
print(f" {label_file.name}: {n_labels} labels → {out_path.name}")
print(f"Batch transform applied to {len(list(label_dir.glob('*.nii.gz')))} files")
Recipe: Demons Deformable Registration
When to use: Fast deformable registration for images of the same modality with small deformations (e.g., longitudinal brain MRI with slight atrophy).
import SimpleITK as sitk
fixed = sitk.ReadImage("baseline_mri.nii.gz", sitk.sitkFloat32)
moving = sitk.ReadImage("followup_mri.nii.gz", sitk.sitkFloat32)
# Normalize intensities
fixed_n = sitk.RescaleIntensity(fixed, 0.0, 1.0)
moving_n = sitk.RescaleIntensity(moving, 0.0, 1.0)
# Demons registration filter
demons = sitk.DemonsRegistrationFilter()
demons.SetNumberOfIterations(50)
demons.SetStandardDeviations(1.0) # displacement field smoothing
displacement_field = demons.Execute(fixed_n, moving_n)
print(f"Demons complete: RMS change = {demons.GetRMSChange():.6f}")
# Apply displacement field transform
displacement_transform = sitk.DisplacementFieldTransform(displacement_field)
warped = sitk.Resample(moving, fixed, displacement_transform, sitk.sitkLinear, 0.0)
sitk.WriteImage(warped, "followup_registered_demons.nii.gz")
sitk.WriteImage(
sitk.Cast(sitk.VectorMagnitude(displacement_field), sitk.sitkFloat32),
"deformation_magnitude.nii.gz"
)
print("Saved: followup_registered_demons.nii.gz, deformation_magnitude.nii.gz")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
TypeError: in method 'ImageRegistrationMethod_Execute' |
Image pixel type is not float | Cast to float32 before registration: sitk.Cast(image, sitk.sitkFloat32) |
| Registration diverges (metric increases) | Learning rate too high or images not pre-aligned | Reduce learningRate to 0.1; use CenteredTransformInitializer for initial alignment; check that images overlap |
| Label image has fractional values after resample | Wrong interpolator used for mask | Set interpolator to sitk.sitkNearestNeighbor when resampling integer label images |
RuntimeError: Exception thrown in SimpleITK ReadImage |
Unsupported format or corrupt DICOM | Verify file with dcmdump; for DICOM series use ImageSeriesReader.GetGDCMSeriesFileNames() |
| N4 bias correction very slow | Large image or too many iterations | Downsample first: resample to 2mm iso before correction, then apply transform to original-resolution image |
| Connected component labels too many or too few | Object size threshold wrong or insufficient preprocessing | Adjust minimumObjectSize in RelabelComponent; increase Gaussian sigma to merge touching objects |
| NumPy array shape does not match expected (z,y,x) | Axis ordering confusion | SimpleITK images are (x,y,z); GetArrayFromImage() returns (z,y,x) NumPy arrays; use arr.transpose(2,1,0) to convert |
| Otsu threshold segments too much background | Background is bright (e.g., confocal reflection artifacts) | Use sitk.OtsuMultipleThresholds with numberOfThresholds=2; or apply a foreground mask before thresholding |
Related Skills
- cellpose-cell-segmentation — deep learning cell segmentation from fluorescence microscopy; use when classical thresholding fails on heterogeneous staining or touching cells
- scikit-image-processing — 2D image analysis with
regionprops,watershed, and filters; prefer for non-volumetric 2D microscopy analysis - napari-image-viewer — interactive visualization of 3D volumes and label masks; use alongside SimpleITK for visual inspection of registration and segmentation results
- pydicom-medical-imaging — direct DICOM tag access, anonymization, and metadata editing; use alongside SimpleITK which handles volume reconstruction but not PHI management
- nnunet-segmentation — automated deep learning segmentation for medical images; use when SimpleITK classical segmentation is insufficient for complex anatomical structures
References
- SimpleITK documentation — complete API reference and Jupyter notebook tutorials
- SimpleITK GitHub — source code, examples, and issue tracker
- SimpleITK Notebooks — 50+ Jupyter notebooks covering registration, segmentation, and I/O
- Lowekamp BC et al. (2013) Front Neuroinform — SimpleITK original paper describing design and capabilities
- Yaniv Z et al. (2018) Frontiers in Neuroinformatics — SimpleITK for the biomedical image processing community
skills/molecular-biology/plannotate-plasmid-annotation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill plannotate-plasmid-annotation -g -y
SKILL.md
Frontmatter
{
"name": "plannotate-plasmid-annotation",
"license": "GPL-3.0",
"description": "Auto-annotate plasmids with features (promoters, terminators, resistance, origins, tags, fluorescent proteins) via BLAST against curated DBs (Addgene, fpbase, SnapGene). FASTA or raw sequence in; annotated GenBank, interactive HTML maps, CSV tables out. Handles circular topology. Use to verify synthetic constructs, prep Addgene submissions, share maps, or batch-annotate cloning libraries."
}
pLannotate Plasmid Annotation
Overview
pLannotate annotates plasmid sequences by running BLAST searches against a curated library of over 5,000 features sourced from Addgene, NCBI, and fpbase. It identifies promoters, terminators, antibiotic resistance genes, origins of replication, tags, and fluorescent proteins while correctly handling circular plasmid topology — avoiding split-feature artifacts that arise from naive linear alignment. Results are written as annotated GenBank files for downstream use in SnapGene, Benchling, or BioPython, as interactive HTML plasmid maps for sharing and review, and as CSV tables for programmatic filtering. Both a Python API and a command-line interface are provided; a Streamlit web app is also bundled for exploratory use.
When to Use
- Annotating a plasmid sequence received from a collaborator or downloaded from Addgene with no accompanying map
- Verifying that all expected elements (promoter, insert, resistance marker, origin) are present after assembly or mutagenesis
- Preparing a GenBank submission or Addgene deposit that requires a complete feature table
- Batch-annotating a library of synthetic constructs produced by combinatorial cloning
- Generating a shareable interactive plasmid map (HTML) without requiring SnapGene or Benchling licenses
- Checking a de-novo synthesized gene block for unintended regulatory elements or cryptic ORFs before cloning
- Use SnapGene or Benchling instead when you need a full-featured GUI plasmid editor with primer design and cloning simulation workflows; pLannotate is best for automated, scriptable annotation
- Use Prokka instead when annotating a complete bacterial genome or a large linear chromosomal sequence; pLannotate is optimized for plasmid-sized sequences up to ~50 kb
Prerequisites
- Python packages:
plannotate,biopython(optional, for GenBank parsing) - System dependency: BLAST+ must be available on PATH (installed automatically via conda; manual install needed for pip)
- Input: Plasmid sequence in FASTA format or as a plain Python string
- Data requirements: Sequences typically 1–20 kb; very large plasmids (>50 kb) may be slow
# Install via pip (requires BLAST+ on PATH)
pip install plannotate
# Install via conda (recommended — handles BLAST+ automatically)
conda install -c conda-forge -c bioconda plannotate
# Verify installation
plannotate --help
python -c "import plannotate; print('plannotate OK')"
Quick Start
from plannotate import annotate, write_genbank, create_bokeh_chart
from Bio import SeqIO
# Load plasmid from FASTA
record = next(SeqIO.parse("plasmid.fasta", "fasta"))
sequence = str(record.seq)
# Annotate (circular, against Addgene database)
results = annotate(sequence, linear=False, db="addgene")
print(f"Found {len(results)} features")
print(results[["Feature", "Feature_type", "pct_identity", "pct_query_cov"]].to_string())
# Export GenBank file
write_genbank(sequence, results, output_file="plasmid_annotated.gb")
# Generate interactive HTML map
create_bokeh_chart(sequence, results, output_file="plasmid_map.html")
print("Outputs: plasmid_annotated.gb, plasmid_map.html")
Workflow
Step 1: Load Plasmid Sequence
Load the plasmid sequence from a FASTA file, a GenBank file (stripping existing annotations for re-annotation), or a raw sequence string. Validate length and base composition before annotation.
from Bio import SeqIO
import os
# Option A: Load from FASTA
def load_fasta(path):
record = next(SeqIO.parse(path, "fasta"))
seq = str(record.seq).upper()
return seq, record.id
# Option B: Load from GenBank (strip annotations, keep sequence)
def load_genbank(path):
record = next(SeqIO.parse(path, "genbank"))
seq = str(record.seq).upper()
return seq, record.id
# Option C: Raw sequence string
raw_seq = "ATGCGTAAAGGAGAAGAACTTTTCACTGGAGTTGTCCCAATTCTTGTTGAATTAGATGGTGATGTT"
# Validate sequence
def validate_plasmid(seq, name="plasmid"):
valid_bases = set("ATGCNRYSWKMBDHV")
invalid = set(seq.upper()) - valid_bases
if invalid:
raise ValueError(f"Invalid bases in {name}: {invalid}")
if len(seq) < 100:
raise ValueError(f"Sequence too short ({len(seq)} bp); minimum 100 bp")
gc = (seq.count("G") + seq.count("C")) / len(seq) * 100
print(f"{name}: {len(seq):,} bp, GC={gc:.1f}%")
return seq
seq, plasmid_id = load_fasta("plasmid.fasta")
validate_plasmid(seq, plasmid_id)
Step 2: Run BLAST-Based Annotation
Run annotation using the selected database. The linear flag controls whether the sequence is treated as circular (default for plasmids) or linear (for gene blocks and linear fragments).
from plannotate import annotate
# Annotate circular plasmid against the Addgene database (most comprehensive for common vectors)
results = annotate(
seq,
linear=False, # False = circular plasmid (default)
db="addgene", # Database: "addgene", "fpbase", or "snapgene"
)
print(f"Total features detected: {len(results)}")
print(f"\nColumns: {list(results.columns)}")
# Preview feature table
cols = ["Feature", "Feature_type", "start", "end", "strand", "pct_identity", "pct_query_cov"]
print(results[cols].sort_values("start").to_string(index=False))
Step 3: Filter Features by Quality Thresholds
Review annotation confidence using BLAST identity and query coverage scores. High-confidence annotations have >95% identity and >90% coverage; partial hits may indicate truncated or mutated features.
import pandas as pd
# Inspect hit quality distribution
print("Identity percentile summary:")
print(results["pct_identity"].describe().round(1))
print("\nCoverage percentile summary:")
print(results["pct_query_cov"].describe().round(1))
# Separate high- and low-confidence hits
high_conf = results[
(results["pct_identity"] >= 95) &
(results["pct_query_cov"] >= 90)
].copy()
low_conf = results[
(results["pct_identity"] < 95) |
(results["pct_query_cov"] < 90)
].copy()
print(f"\nHigh-confidence features (identity>=95%, coverage>=90%): {len(high_conf)}")
print(f"Low-confidence / partial features: {len(low_conf)}")
if not low_conf.empty:
print("\nLow-confidence features (review manually):")
print(low_conf[["Feature", "Feature_type", "pct_identity", "pct_query_cov"]].to_string(index=False))
# Save filtered table
results.to_csv("all_features.csv", index=False)
high_conf.to_csv("high_confidence_features.csv", index=False)
print("\nSaved: all_features.csv, high_confidence_features.csv")
Step 4: Export Annotated GenBank File
Write the annotated sequence to GenBank format for import into plasmid editors (SnapGene, Benchling, Geneious, ApE) and for BioPython-based downstream analysis.
from plannotate import write_genbank
# Write full annotation (all features)
write_genbank(seq, results, output_file="plasmid_annotated.gb")
print("Written: plasmid_annotated.gb")
# Write with high-confidence features only
write_genbank(seq, high_conf, output_file="plasmid_highconf.gb")
print("Written: plasmid_highconf.gb")
# Verify using BioPython
from Bio import SeqIO
record = next(SeqIO.parse("plasmid_annotated.gb", "genbank"))
print(f"\nGenBank verification:")
print(f" Sequence length: {len(record.seq):,} bp")
print(f" Features: {len(record.features)}")
for feat in record.features:
label = feat.qualifiers.get("label", ["(unlabeled)"])[0]
print(f" [{feat.type:20s}] {label} @ {feat.location}")
Step 5: Generate Interactive HTML Visualization
Create a Bokeh-based interactive plasmid map. The HTML file is self-contained and can be shared without any server infrastructure.
from plannotate import create_bokeh_chart
# Generate interactive circular plasmid map
create_bokeh_chart(
seq,
results,
output_file="plasmid_map.html",
)
print("Interactive map saved: plasmid_map.html")
print("Open in any browser — no server required")
# Tip: open automatically in the default browser
import webbrowser, os
webbrowser.open(f"file://{os.path.abspath('plasmid_map.html')}")
Step 6: Parse GenBank Output with BioPython
Extract annotated features programmatically for downstream analysis — restriction site mapping, primer design, or construct verification reports.
from Bio import SeqIO
from Bio.SeqFeature import FeatureLocation
import pandas as pd
record = next(SeqIO.parse("plasmid_annotated.gb", "genbank"))
# Build a feature DataFrame from the GenBank record
rows = []
for feat in record.features:
label = feat.qualifiers.get("label", [""])[0]
note = feat.qualifiers.get("note", [""])[0]
rows.append({
"type": feat.type,
"label": label,
"note": note,
"start": int(feat.location.start),
"end": int(feat.location.end),
"strand": feat.location.strand,
"length": len(feat.location),
})
feat_df = pd.DataFrame(rows)
print(feat_df.to_string(index=False))
# Example: find antibiotic resistance genes
resistance = feat_df[feat_df["label"].str.contains(
r"AmpR|KanR|CmR|TetR|SpecR|HygR|ZeoR|BlastR|GentR",
case=False, na=False, regex=True
)]
print(f"\nAntibiotic resistance markers found: {len(resistance)}")
print(resistance[["label", "start", "end", "length"]].to_string(index=False))
Step 7: Batch Annotate Multiple Plasmids
Annotate an entire cloning library from a multi-FASTA file or a directory of individual FASTA files and aggregate results into a single summary table.
from plannotate import annotate, write_genbank, create_bokeh_chart
from Bio import SeqIO
import pandas as pd
import os
input_dir = "plasmids/" # directory of *.fasta files
output_dir = "annotated_results/"
os.makedirs(output_dir, exist_ok=True)
summary_rows = []
for fasta_file in sorted(f for f in os.listdir(input_dir) if f.endswith(".fasta")):
plasmid_name = fasta_file.replace(".fasta", "")
fasta_path = os.path.join(input_dir, fasta_file)
record = next(SeqIO.parse(fasta_path, "fasta"))
seq = str(record.seq).upper()
print(f"Annotating {plasmid_name} ({len(seq):,} bp)...", end=" ")
results = annotate(seq, linear=False, db="addgene")
print(f"{len(results)} features")
# Save per-plasmid outputs
write_genbank(
seq, results,
output_file=os.path.join(output_dir, f"{plasmid_name}.gb")
)
create_bokeh_chart(
seq, results,
output_file=os.path.join(output_dir, f"{plasmid_name}.html")
)
results.to_csv(os.path.join(output_dir, f"{plasmid_name}_features.csv"), index=False)
# Accumulate for summary
results["plasmid"] = plasmid_name
summary_rows.append(results)
# Consolidated summary table
summary = pd.concat(summary_rows, ignore_index=True)
summary.to_csv(os.path.join(output_dir, "all_plasmids_features.csv"), index=False)
print(f"\nBatch complete. {summary['plasmid'].nunique()} plasmids annotated.")
print(f"Summary table: {output_dir}all_plasmids_features.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
linear |
False |
True, False |
Treat sequence as linear (True) or circular (False); circular mode handles split features at the origin correctly |
db |
"addgene" |
"addgene", "fpbase", "snapgene" |
Feature database to search; addgene is broadest (promoters, resistance genes, origins, tags); fpbase adds fluorescent protein variants; snapgene includes SnapGene-curated features |
min_len |
0 |
0–500 bp |
Minimum feature length in bp; increase to suppress short spurious matches |
blast_identity_threshold |
95 |
70–100 % |
Minimum BLAST % identity to report a hit; lower values detect diverged homologs but increase false positives |
--html (CLI) |
off | flag | Generate interactive HTML plasmid map alongside GenBank output |
--csv (CLI) |
off | flag | Write CSV feature table to the output directory |
--linear (CLI) |
off | flag | Treat input as linear sequence (default is circular) |
--file / --input (CLI) |
required | FASTA path | Input plasmid sequence in FASTA format |
Common Recipes
Recipe: Launch Web App for Interactive Use
When to use: exploring a single plasmid interactively without writing code, or sharing with wet-lab collaborators who prefer a browser interface.
# Launch the pLannotate Streamlit web app (opens in browser at localhost:5000)
plannotate streamlit
# Or specify a custom port
plannotate streamlit --port 8501
Recipe: CLI Batch Annotation
When to use: annotating multiple plasmids in a scripted workflow or on a remote server without Python scripting.
# Single plasmid
plannotate batch \
--input plasmid.fasta \
--output results/ \
--html \
--csv
# Multiple plasmids via shell glob
for f in plasmids/*.fasta; do
name=$(basename "$f" .fasta)
plannotate batch \
--input "$f" \
--output "annotated/${name}/" \
--html --csv
done
echo "Done. Outputs in annotated/"
Recipe: Compare Annotations Before and After Mutagenesis
When to use: verifying that a site-directed mutagenesis or insertion did not disrupt existing features or introduce unintended ones.
from plannotate import annotate
from Bio import SeqIO
import pandas as pd
def annotation_diff(seq_before, seq_after, db="addgene"):
res_before = annotate(seq_before, linear=False, db=db)
res_after = annotate(seq_after, linear=False, db=db)
features_before = set(res_before["Feature"])
features_after = set(res_after["Feature"])
gained = features_after - features_before
lost = features_before - features_after
shared = features_before & features_after
print(f"Shared features: {len(shared)}")
print(f"Gained features: {gained if gained else 'none'}")
print(f"Lost features: {lost if lost else 'none'}")
return res_before, res_after
seq_wt = str(next(SeqIO.parse("plasmid_wt.fasta", "fasta")).seq)
seq_mut = str(next(SeqIO.parse("plasmid_mut.fasta", "fasta")).seq)
res_before, res_after = annotation_diff(seq_wt, seq_mut)
Recipe: Export Feature Table to Excel with Conditional Formatting
When to use: sharing annotation results with collaborators who prefer spreadsheets over GenBank files.
from plannotate import annotate
from Bio import SeqIO
import pandas as pd
seq = str(next(SeqIO.parse("plasmid.fasta", "fasta")).seq)
results = annotate(seq, linear=False, db="addgene")
# Tidy column selection and renaming
export = results[[
"Feature", "Feature_type", "start", "end", "strand",
"pct_identity", "pct_query_cov", "database"
]].copy()
export.columns = [
"Feature Name", "Type", "Start (bp)", "End (bp)", "Strand",
"Identity (%)", "Coverage (%)", "Database"
]
export["Length (bp)"] = export["End (bp)"] - export["Start (bp)"]
export = export.sort_values("Start (bp)")
# Write to Excel with conditional formatting
with pd.ExcelWriter("plasmid_features.xlsx", engine="openpyxl") as writer:
export.to_excel(writer, sheet_name="Features", index=False)
print(f"Exported {len(export)} features to plasmid_features.xlsx")
Expected Outputs
| Output File | Format | Description |
|---|---|---|
plasmid_annotated.gb |
GenBank | Sequence with annotated features; importable into SnapGene, Benchling, Geneious, ApE, BioPython |
plasmid_map.html |
HTML | Self-contained interactive circular plasmid map (Bokeh); shareable without a server |
all_features.csv |
CSV | Tabular feature list with columns: Feature, Feature_type, start, end, strand, pct_identity, pct_query_cov, database |
high_confidence_features.csv |
CSV | Filtered subset with identity >= 95% and coverage >= 90% |
all_plasmids_features.csv |
CSV | Batch mode: aggregated features across all plasmids with a plasmid column |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FileNotFoundError: blastn not found |
BLAST+ not on PATH | Install via conda: conda install -c bioconda blast; or via package manager: brew install blast (macOS) / apt install ncbi-blast+ (Linux) |
No features detected |
Sequence is too short, wrong database, or non-standard bases | Verify sequence length >= 500 bp; try a different db (e.g., "fpbase" for fluorescent protein vectors); check for ambiguous bases with validate_plasmid() |
| Annotations wrap incorrectly at position 0 | Sequence treated as linear when it is circular | Set linear=False (default); this enables circular BLAST to catch features that span the sequence origin |
| HTML map renders blank | bokeh version mismatch |
Upgrade: pip install --upgrade bokeh; pLannotate requires Bokeh >=2.4 |
| Low identity hits for known features | Feature sequence has been mutated or codon-optimized | Lower blast_identity_threshold to 85–90%; add a note that these are diverged homologs |
MemoryError or very slow annotation |
Sequence > 50 kb or BLAST database not indexed | Split large sequences into sub-regions; ensure the internal pLannotate database index exists (reinstall if needed) |
| GenBank file not parsed by SnapGene | Non-standard feature type labels | Open in Geneious or BioPython first; check for special characters in feature qualifiers |
References
- pLannotate GitHub: mmcguffi/pLannotate — source code, issue tracker, and installation instructions
- McGuffi M et al. (2021) Nucleic Acids Research 49(W1): W516–W522 — original pLannotate paper describing the BLAST-based annotation pipeline and curated feature database
- pLannotate PyPI: plannotate — package metadata, version history, and pip install info
- Addgene plasmid repository — primary source for the curated feature library used by pLannotate
- BioPython SeqIO documentation — parsing and manipulating GenBank output files
skills/molecular-biology/sgrna-design-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill sgrna-design-guide -g -y
SKILL.md
Frontmatter
{
"name": "sgrna-design-guide",
"license": "open",
"description": "Three-tiered sgRNA design guide using validated Addgene sequences, CRISPick pre-computed datasets, or de novo design rules for CRISPR experiments"
}
sgRNA Design Guide: Three-Tiered Approach
Metadata
Short Description: Comprehensive guide for finding or designing sgRNAs using validated sequences, CRISPick datasets, or de novo design tools.
Authors: Ohagent Team
Version: 1.0
Last Updated: November 2025
License: CC BY 4.0
Commercial Use: Allowed
Citations and Acknowledgments
If you use validated sgRNAs from our database (Option 1):
- Database Source: Addgene (https://www.addgene.org)
- Citation: Always cite the original publication associated with each sgRNA using the PubMed ID provided in the database
- Acknowledgment: "Validated sgRNA sequences obtained from Addgene (https://www.addgene.org/crispr/reference/grna-sequence/)"
If you use CRISPick designs (Option 2):
- Acknowledgment Statement: "Guide designs provided by the CRISPick web tool of the GPP at the Broad Institute"
- Citation for Cas9 designs (SpCas9, SaCas9): Sanson KR, et al. Optimized libraries for CRISPR-Cas9 genetic screens with multiple modalities. Nat Commun. 2018;9(1):5416. PMID: 30575746
- Citation for Cas12a designs (AsCas12a, enAsCas12a): DeWeirdt PC, et al. Optimization of AsCas12a for combinatorial genetic screens in human cells. Nat Biotechnol. 2021;39(1):94-104. PMID: 32661438
- Note: This paper describes enAsCas12a optimization; specify which variant you used in your methods
Overview
This guide provides a three-tiered approach to sgRNA design, prioritizing validated sequences before moving to computational predictions. Always start with Option 1 and proceed to subsequent options only if needed.
Key Concepts
Validated sgRNA Databases (Tier 1)
Validated sgRNAs are guide sequences that have been experimentally tested in published work, with documented cell line, cutting efficiency, and (often) off-target characterization. Addgene curates such sequences alongside the deposited plasmids that carry them, and a downloadable CSV (addgene_grna_sequences.csv) provides a searchable index keyed by gene symbol, target species, and application (cut / activate / RNA targeting). Using a validated sgRNA is preferred because the largest source of CRISPR experimental failure is poor on-target activity that would have been detected during the original validation.
Computational sgRNA Scoring (Tier 2)
When no validated sgRNA exists, pre-computed genome-scale designs from the Broad Institute's CRISPick service are the next best option. CRISPick provides 238 datasets covering multiple genomes, Cas variants, and applications, with each candidate guide ranked by on-target efficiency (how reliably it cuts), off-target specificity (how unlikely it is to cut elsewhere), and a combined rank that balances both. The on-target models behind CRISPick are Sanson 2018 (Cas9) and DeWeirdt 2021 (Cas12a). Combined Rank is the recommended default; use On-Target or Off-Target rank only when the experiment specifically prioritizes one over the other.
Off-Target Stringency and PAM Compatibility
PAM (Protospacer Adjacent Motif) requirements differ by Cas variant: SpCas9 needs NGG (3'), SaCas9 needs NNGRRT (3'), AsCas12a and enAsCas12a need TTTV (5'). Critically, AsCas12a and enAsCas12a are different enzymes: enAsCas12a is an engineered variant with broadened activity, and guides optimized for one will not perform identically on the other. Always match the CRISPick dataset to the exact Cas variant used in the lab. Off-target stringency thresholds (typically Off-Target Rank or a CFD-style score) trade specificity against the size of the candidate pool — tighter thresholds yield fewer but cleaner guides.
De Novo Design Rules (Tier 3)
When neither validated sequences nor pre-computed datasets cover the target (non-model organism, custom locus, etc.), apply rule-based design: 20 bp protospacer for SpCas9/SaCas9 (23–25 bp for Cas12a), GC content 40–60%, avoid TTTT (Pol III terminator) and homopolymer runs >4. Target location matters: early exons (first 50% of coding sequence) for knockout, −200 to +1 from TSS for CRISPRa, −50 to +300 from TSS for CRISPRi.
Decision Framework
sgRNA design decision tree
└── Does Addgene database (Tier 1) contain a validated sgRNA for your gene + species + application?
├── Yes -> Use the validated sgRNA(s); cite the original PubMed reference
└── No -> Run advanced literature search (mandatory before Tier 2)
├── Found in literature -> Use the literature sgRNA; cite the paper
└── Still no match -> Is the target organism + Cas variant covered by a CRISPick dataset (Tier 2)?
├── Yes -> Download dataset, filter by gene, sort by Combined Rank
│ └── Pick top 3-4 sgRNAs (ideally from different exons for redundancy)
└── No -> De novo design (Tier 3) using 20 bp / PAM / GC / avoid-TTTT rules
└── Validate experimentally (Sanger / T7E1 / amplicon-seq)
| Situation | Recommended tier | Rationale |
|---|---|---|
| Common human/mouse gene with prior CRISPR publications | Tier 1 (Addgene + literature) | Validated sequences come with measured cutting efficiency; lowest experimental risk |
| Genome-scale screen of a model organism | Tier 2 (CRISPick) | Pre-computed datasets cover whole genomes with consistent scoring |
| Single human gene knockout, no Addgene hit | Tier 2 (CRISPick GRCh38 SpCas9 CRISPRko), filter by Combined Rank | Best balance of efficiency and specificity for one-off knockouts |
| CRISPRa / CRISPRi experiment | Tier 2 with the matching CRISPRa / CRISPRi dataset |
Activation/inhibition models target proximal-promoter windows, not coding exons |
| Cas12a (AsCas12a vs enAsCas12a) | Tier 2 with the exact Cas12a variant dataset | Guides for one variant are not interchangeable with the other |
| Non-model organism not covered by CRISPick | Tier 3 (de novo rules) | No high-throughput training data exists; rule-based design + experimental validation |
| Small custom locus (e.g., engineered cassette) | Tier 3 | Locus is not in any reference genome; design directly against the target sequence |
| Maximum specificity required (e.g., therapeutic application) | Tier 2 sorted by Off-Target Rank | Prioritize guides with the fewest predicted off-target sites |
| Maximum cutting efficiency required (e.g., difficult cell line) | Tier 2 sorted by On-Target Rank | Accept slightly higher off-target risk in exchange for activity |
Best Practices
- Always exhaust Tier 1 before moving to Tier 2. Even if
addgene_grna_sequences.csvreturns no rows, run the literature search step (Method 2). Many validated sgRNAs are published in supplementary materials but never deposited at Addgene. - Match the CRISPick dataset to the exact Cas variant. AsCas12a and enAsCas12a are distinct enzymes; SpCas9 and SaCas9 require different PAMs. Using the wrong dataset wastes experimental resources.
- Use Combined Rank as the default selector. It balances on-target efficiency and off-target specificity; reach for
On-Target RankorOff-Target Rankonly when the experiment has a specific reason to prioritize one. - Order 3–4 sgRNAs per target gene, ideally from different exons. A single guide can fail for reasons unrelated to its predicted score (chromatin context, secondary structure, PCR drop-out). Redundancy is cheap insurance.
- For knockouts, target the first 50% of the coding sequence. N-terminal cuts are most likely to produce a true loss-of-function via NMD; C-terminal cuts can leave partially functional protein.
- For CRISPRa, design within −200 to +1 bp of the TSS; for CRISPRi, within −50 to +300 bp. Effector activity drops sharply outside these windows.
- Validate experimentally. Even high-ranked guides fail ~20–30% of the time. Confirm editing with Sanger sequencing, T7E1, ICE, or amplicon-seq before committing to phenotype assays.
- Cite the original validation paper for Tier 1, and CRISPick + Sanson 2018 / DeWeirdt 2021 for Tier 2. Reproducibility depends on traceable provenance for every guide sequence.
Common Pitfalls
-
Pitfall: Skipping the literature search step (Method 2) when the Addgene CSV returns no hits.
- How to avoid: Treat Method 2 as mandatory; many validated guides only appear in published supplementary materials.
-
Pitfall: Using AsCas12a guides with enAsCas12a (or vice versa) because both share TTTV PAM.
- How to avoid: Match the CRISPick dataset filename's Cas tag (
AsCas12avsenAsCas12a) to the exact enzyme variant used at the bench.
- How to avoid: Match the CRISPick dataset filename's Cas tag (
-
Pitfall: Sorting by On-Target Rank only and ending up with high-activity, low-specificity guides that produce off-target editing.
- How to avoid: Use Combined Rank as the default; only deviate when the experiment explicitly requires extreme efficiency or specificity.
-
Pitfall: Designing a single sgRNA per gene and concluding "the guide doesn't work" when one fails.
- How to avoid: Always order and test 3–4 guides per gene from independent loci/exons.
-
Pitfall: Targeting late exons or 3' UTRs for knockout. Truncated proteins from late-exon edits are often partially functional and confound phenotype interpretation.
- How to avoid: For knockouts, restrict candidates to the first 50% of the coding sequence (or the first 3 exons).
-
Pitfall: Using a CRISPRko dataset for a CRISPRa or CRISPRi experiment. The protospacer windows are different — coding exons for knockout, proximal promoter for activation/inhibition.
- How to avoid: Download the dataset whose application tag (
CRISPRko/CRISPRa/CRISPRi) matches the experiment.
- How to avoid: Download the dataset whose application tag (
-
Pitfall: Designing guides containing
TTTT. This sequence terminates RNA Pol III transcription, so the sgRNA simply will not be expressed from a U6 promoter.- How to avoid: Always filter de novo designs (and double-check Tier 1/2 picks) against
TTTTand homopolymer runs >4.
- How to avoid: Always filter de novo designs (and double-check Tier 1/2 picks) against
-
Pitfall: Failing to cite the original publication for a validated sgRNA.
- How to avoid: Record the
PubMed_IDfrom the Addgene CSV (or the DOI from the literature search) as part of the design record and include it in the methods section.
- How to avoid: Record the
Option 1: Search Validated sgRNA Sequences (Recommended First)
1.1 Search for Validated sgRNAs
IMPORTANT: You MUST complete BOTH Method 1 AND Method 2 before proceeding to Option 2. Do not skip Method 2 even if Method 1 finds no results.
Method 1: Search Our Database (Fastest)
We maintain a curated database of 300+ validated sgRNA sequences from Addgene with experimental evidence.
Location: resource/addgene_grna_sequences.csv (relative to this skill directory)
Search the database:
import pandas as pd
# Load the database
df = pd.read_csv('addgene_grna_sequences.csv')
# Search for your gene
gene_name = "TP53"
results = df[df['Target_Gene'].str.upper() == gene_name.upper()]
# Filter by species and application
results_filtered = results[
(results['Target_Species'] == 'H. sapiens') &
(results['Application'] == 'cut') # or 'activate', 'RNA targeting'
]
# Display results with references
print(results_filtered[['Target_Gene', 'Target_Sequence',
'Plasmid_ID', 'PubMed_ID', 'Depositor']])
Database columns:
Target_Gene: Gene symbolTarget_Species: Organism (H. sapiens, M. musculus, etc.)Target_Sequence: 20bp sgRNA sequence (5' to 3')Application: cut (knockout), activate (CRISPRa), RNA targeting (CRISPRi)Cas9_Species: S. pyogenes, S. aureus, etc.Plasmid_ID: Addgene plasmid numberPlasmid_URL: Direct link to plasmid pagePubMed_ID: Publication reference (cite this in your work)PubMed_URL: Direct link to paperDepositor: Research lab that contributed the sequence
Method 2: Advanced Web Search (REQUIRED - Do Not Skip)
CRITICAL: Even if Method 1 found no results, you MUST perform this literature search before moving to Option 2. Many validated sgRNAs are published in literature but not in the Addgene database.
Use advanced_web_search_claude from ohagent.tool.literature to find validated sgRNAs from literature and databases:
from ohagent.tool.literature import advanced_web_search_claude
# Example usage
results = advanced_web_search_claude("sgRNA TP53 validated H. sapiens experimental")
Search queries to try (use multiple):
"sgRNA" OR "guide RNA" "[GENE_NAME]" validated experimental
"CRISPR knockout" "[GENE_NAME]" sgRNA sequence validated
"[GENE_NAME]" sgRNA "cutting efficiency" OR "on-target"
"[GENE_NAME]" "guide sequence" CRISPR validated
Example for TP53:
"sgRNA" "TP53" validated "H. sapiens" experimental
"CRISPR knockout" "TP53" guide sequence validated
What to search for in results:
- Published papers with validated sgRNA sequences
- Supplementary materials containing sgRNA sequences
- Other CRISPR databases (e.g., GenScript, Horizon Discovery)
- Laboratory protocols with specific sgRNA sequences
IMPORTANT: Spend adequate time searching literature. Look through at least the first 10-15 search results and check supplementary materials of relevant papers.
What to Do with Results:
If you find matching sgRNAs (from either method):
- Record the sgRNA sequence (20bp target sequence)
- Note the reference:
- From database: Use
PubMed_IDto cite the original paper - From web search: Record the publication DOI/PubMed ID
- From database: Use
- Record validation details: Cell line, cutting efficiency, any reported off-targets
Example result format:
Gene: TP53
sgRNA sequence: GAGGTTGTGAGGCGCTGCCC
Species: H. sapiens (human)
Application: Knockout (cut)
Reference: PubMed ID 24336569 (Ran et al., 2013)
Validation: Tested in HEK293T cells, 85% cutting efficiency
If no matches found in BOTH Method 1 AND Method 2: Only then proceed to Option 2: Download CRISPick Dataset
Option 2: Download Pre-Computed sgRNAs from CRISPick
When to Use This Option?
- No validated sgRNAs found in Addgene
- Need comprehensive coverage of a gene
- Want multiple sgRNA options with predicted scores
- Working with genome-scale screening
2.1 Overview of CRISPick
CRISPick (from Broad Institute GPP) provides pre-computed sgRNA designs for entire genomes with 238 available datasets covering:
- Human (GRCh38, GRCh37)
- Mouse (GRCm38, GRCm39)
- Dog, Cow, Monkey, and other model organisms
- Multiple Cas enzymes (SpCas9, SaCas9, AsCas12a, enAsCas12a)
- IMPORTANT: AsCas12a and enAsCas12a are DIFFERENT enzymes
- AsCas12a: Wild-type Acidaminococcus sp. Cas12a
- enAsCas12a: Enhanced variant with improved activity
- Critical: sgRNAs designed for enAsCas12a may NOT work with wild-type AsCas12a
- Always match the dataset to your specific Cas12a variant
- Different applications (knockout, activation, inhibition)
2.2 Find the Download Link
All 238 download links are available in: resource/CRISPick_download_links.txt (relative to this skill directory)
Step 1: Understand File Naming Convention
Files are named: sgRNA_design_{TAXID}_{GENOME}_{CAS}_{APPLICATION}_{ALGORITHM}_{SOURCE}_{DATE}.txt.gz
Common datasets:
| Organism | Genome | Cas9 | Application | Search Pattern |
|-|--||-|-|
| Human | GRCh38 | SpCas9 | Knockout | 9606_GRCh38_SpyoCas9_CRISPRko |
| Human | GRCh38 | SpCas9 | Activation | 9606_GRCh38_SpyoCas9_CRISPRa |
| Human | GRCh38 | SaCas9 | Knockout | 9606_GRCh38_SaurCas9_CRISPRko |
| Mouse | GRCm38 | SpCas9 | Knockout | 10090_GRCm38_SpyoCas9_CRISPRko |
| Mouse | GRCm38 | SpCas9 | Activation | 10090_GRCm38_SpyoCas9_CRISPRa |
Key components:
- TAXID:
9606(Human),10090(Mouse),9615(Dog),9913(Cow) - CAS:
SpyoCas9(SpCas9, NGG PAM)SaurCas9(SaCas9, NNGRRT PAM)AsCas12a(Wild-type Cas12a, TTTV PAM)enAsCas12a(Enhanced Cas12a, TTTV PAM)- WARNING: Do NOT use AsCas12a datasets if you have enAsCas12a, and vice versa
- APPLICATION:
CRISPRko(knockout),CRISPRa(activation),CRISPRi(inhibition)
Step 2: Find Your Download URL
# Search the download links file
grep "9606_GRCh38_SpyoCas9_CRISPRko" resource/CRISPick_download_links.txt
# Or for mouse
grep "10090_GRCm38_SpyoCas9_CRISPRko" resource/CRISPick_download_links.txt
Step 3: Download and Extract
# Copy the URL from download_links.txt, then:
wget [PASTE_URL_HERE]
# Extract the file
gunzip sgRNA_design_*.txt.gz
File sizes: Knockout (300-700 MB), Activation (50-100 MB), Summary files (1-3 MB)
2.4 Understanding the File Format
The .txt file is tab-delimited. Column names differ between knockout and activation/inhibition datasets.
Essential Columns (All files):
- sgRNA Sequence: The 20bp guide RNA sequence (5' to 3')
- Target Gene Symbol: Gene name (e.g., "TP53", "BRCA1")
- Combined Rank: Overall ranking (lower = better) - Use this by default
- On-Target Rank: Ranking by efficiency only (lower = better)
- Off-Target Rank: Ranking by specificity only (lower = better)
- PAM Sequence: The PAM sequence (e.g., "AGG", "TGG")
Knockout-specific columns:
- sgRNA Cut Position (1-based): Genomic coordinate of cut site
- Exon Number: Which exon is targeted
- Target Cut %: Percentage of protein affected
Activation/Inhibition-specific columns:
- sgRNA 'Cut' Position: Position relative to gene
- sgRNA 'Cut' Site TSS Offset: Distance from transcription start site (bp)
- DHS Score: DNase hypersensitivity score (for CRISPRa)
2.5 Extract and Select sgRNAs
Step 1: Load Data and Filter for Your Gene
import pandas as pd
# Load the dataset
df = pd.read_csv('sgRNA_design_9606_GRCh38_SpyoCas9_CRISPRko_*.txt',
sep='\t', low_memory=False)
# Filter for your gene
gene_name = "TP53"
gene_sgrnas = df[df['Target Gene Symbol'] == gene_name].copy()
print(f"Found {len(gene_sgrnas)} sgRNAs for {gene_name}")
Step 2: Select Top sgRNAs
Default: Use Combined Rank (balances efficiency and specificity)
# Sort by Combined Rank (lower is better)
top_sgrnas = gene_sgrnas.nsmallest(10, 'Combined Rank')
print(top_sgrnas[['sgRNA Sequence', 'Combined Rank',
'Exon Number', 'sgRNA Cut Position (1-based)']])
Option A: Prioritize On-Target Efficiency
# Sort by On-Target Rank (for maximum cutting efficiency)
efficient_sgrnas = gene_sgrnas.nsmallest(10, 'On-Target Rank')
Option B: Prioritize Off-Target Specificity
# Sort by Off-Target Rank (for maximum specificity)
specific_sgrnas = gene_sgrnas.nsmallest(10, 'Off-Target Rank')
Step 3: Filter by Custom Criteria (Optional)
Filter by Exon Number:
# Target specific exon (e.g., exon 5)
exon5_sgrnas = gene_sgrnas[gene_sgrnas['Exon Number'] == 5]
top_exon5 = exon5_sgrnas.nsmallest(5, 'Combined Rank')
Filter by Genomic Position:
# Target specific genomic range
position_filtered = gene_sgrnas[
(gene_sgrnas['sgRNA Cut Position (1-based)'] >= 7572000) &
(gene_sgrnas['sgRNA Cut Position (1-based)'] <= 7575000)
]
Target Early Exons for Knockout:
# Get sgRNAs from first 3 exons
early_exons = gene_sgrnas[gene_sgrnas['Exon Number'] <= 3]
top_early = early_exons.nsmallest(10, 'Combined Rank')
Filter by Target Cut Percentage:
# Target sgRNAs that affect significant portion of protein
high_impact = gene_sgrnas[gene_sgrnas['Target Cut %'] <= 50] # Cut in first 50%
top_high_impact = high_impact.nsmallest(10, 'Combined Rank')
Step 4: Select Multiple sgRNAs for Validation
# Get top 4 sgRNAs from different exons for redundancy
final_selection = gene_sgrnas.sort_values('Combined Rank').groupby('Exon Number').head(1).head(4)
# Save results
final_selection.to_csv(f'{gene_name}_selected_sgRNAs.csv', index=False)
print("\nSelected sgRNAs:")
print(final_selection[['sgRNA Sequence', 'Exon Number', 'Combined Rank']])
2.6 What to Do with Results
Once you have selected sgRNAs:
- Choose 3-4 sgRNAs (use Combined_Rank by default)
If dataset doesn't cover your gene or organism: Proceed to Option 3: De Novo sgRNA Design
Option 3: General sgRNA Design Guidelines (Last Resort)
When to Use This Option?
- Gene not covered in CRISPick datasets
- Non-model organism
- Custom design requirements
General Design Rules
Essential Requirements:
- Length:
- 20 bp for SpCas9 and SaCas9
- 23-25 bp for Cas12a variants (AsCas12a, enAsCas12a)
- PAM sequence:
- SpCas9: Requires NGG immediately after target (3' end)
- SaCas9: Requires NNGRRT immediately after target (3' end)
- AsCas12a (wild-type): Requires TTTV before target (5' end)
- enAsCas12a (enhanced): Requires TTTV before target (5' end)
- CRITICAL: Guides designed for enAsCas12a may not work with wild-type AsCas12a due to different activity profiles
- GC content: 40-60% is optimal
- Avoid:
- TTTT sequences (terminates transcription)
- Long runs of same nucleotide (>4 repeats)
Target Location:
- For knockout: Target early exons (first 50% of gene)
- For activation: Target -200 to +1 bp from transcription start site (TSS)
- For inhibition: Target -50 to +300 bp from TSS
Best Practices:
- Test 3-4 different sgRNAs per target gene
- Select sgRNAs with high efficiency scores (>0.5) and minimal off-targets
- Validate experimentally with Sanger sequencing
Quick Start Examples
Example 1: Knockout TP53 in Human Cells
Step 1: Check Addgene
df = pd.read_csv('addgene_grna_sequences.csv')
tp53_results = df[(df['Target_Gene'] == 'TP53') &
(df['Target_Species'] == 'H. sapiens') &
(df['Application'] == 'cut')]
# Result: Found 0 entries -> Proceed to Option 2
Step 2: Download CRISPick dataset
# Download human GRCh38 SpCas9 knockout dataset
wget https://portals.broadinstitute.org/gppx/public/sgrna_design/api/downloads/\
sgRNA_design_9606_GRCh38_SpyoCas9_CRISPRko_RS3seq-Chen2013+RS3target_NCBI_20241104.txt.gz
gunzip sgRNA_design_9606_GRCh38_SpyoCas9_CRISPRko_*.txt.gz
Step 3: Extract TP53 sgRNAs
df = pd.read_csv('sgRNA_design_9606_GRCh38_SpyoCas9_CRISPRko_*.txt', sep='\t')
tp53 = df[df['Gene_Symbol'] == 'TP53']
top_sgrnas = tp53[
(tp53['sgRNA_score'] > 0.6) &
(tp53['Off_target_stringency'] > 0.5)
].sort_values('sgRNA_score', ascending=False).head(4)
print(top_sgrnas[['sgRNA_sequence', 'sgRNA_score', 'Exon_ID']])
Example 2: Activate OCT4 in Human iPSCs
Step 1: Check Addgene
oct4_results = df[(df['Target_Gene'] == 'OCT4') &
(df['Application'] == 'activate')]
# Found 1 validated sgRNA!
print(oct4_results['Target_Sequence'].values[0])
# Use this sequence
Remember: Always start with validated sequences (Option 1), then move to pre-computed designs (Option 2), and only use de novo design (Option 3) when necessary. Testing 3-4 sgRNAs per gene is standard practice regardless of prediction scores.
References
- Addgene CRISPR sgRNA reference sequences: https://www.addgene.org/crispr/reference/grna-sequence/
- CRISPick (Broad Institute GPP) pre-computed sgRNA designs: https://portals.broadinstitute.org/gppx/crispick/public
- Sanson KR, Hanna RE, Hegde M, et al. "Optimized libraries for CRISPR-Cas9 genetic screens with multiple modalities." Nat Commun. 2018;9(1):5416. PMID: 30575746. https://doi.org/10.1038/s41467-018-07901-8
- DeWeirdt PC, Sanson KR, Sangree AK, et al. "Optimization of AsCas12a for combinatorial genetic screens in human cells." Nat Biotechnol. 2021;39(1):94-104. PMID: 32661438. https://doi.org/10.1038/s41587-020-0600-6
- Doench JG, Fusi N, Sullender M, et al. "Optimized sgRNA design to maximize activity and minimize off-target effects of CRISPR-Cas9." Nat Biotechnol. 2016;34(2):184-191. https://doi.org/10.1038/nbt.3437
- Ran FA, Hsu PD, Wright J, et al. "Genome engineering using the CRISPR-Cas9 system." Nat Protoc. 2013;8(11):2281-2308. PMID: 24336569. https://doi.org/10.1038/nprot.2013.143
- Zetsche B, Heidenreich M, Mohanraju P, et al. "Multiplex gene editing by CRISPR-Cpf1 using a single crRNA array." Nat Biotechnol. 2017;35(1):31-34. https://doi.org/10.1038/nbt.3737
skills/molecular-biology/viennarna-structure-prediction/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill viennarna-structure-prediction -g -y
SKILL.md
Frontmatter
{
"name": "viennarna-structure-prediction",
"license": "MIT",
"description": "Predict RNA secondary structure, MFE folding, base-pair probabilities, RNA-RNA interactions via ViennaRNA Python bindings. Pipeline: sequence → MFE → partition function and pair-probability matrix → dot-bracket → duplex. Use for siRNA\/sgRNA targeting, ribozyme design, RNA accessibility. Use RNAfold CLI for batch use without Python."
}
ViennaRNA Structure Prediction
Overview
ViennaRNA is the gold-standard toolkit for RNA secondary structure prediction based on thermodynamic nearest-neighbor parameters. It predicts the minimum free energy (MFE) structure and dot-bracket notation for a given RNA sequence, computes the full partition function to obtain base pair probabilities, and models RNA-RNA interactions via co-folding and duplex prediction. The Python bindings (import RNA) expose the full ViennaRNA C library with sequence-level and fold-compound APIs. Command-line programs (RNAfold, RNAalifold, RNAduplex) are also available and demonstrated here.
When to Use
- Predicting the minimum free energy secondary structure of an RNA sequence (mRNA, lncRNA, miRNA precursor, aptamer)
- Computing base pair probability matrices to assess structural uncertainty and identify well-defined stem-loops
- Designing or evaluating siRNA accessibility by folding the target mRNA region and checking for double-stranded structure
- Assessing sgRNA targeting efficiency by predicting guide RNA secondary structure that may reduce on-target activity
- Modeling RNA-RNA interactions (co-folding or duplex prediction) for miRNA-target binding or antisense oligonucleotide design
- Calculating folding free energies for a set of sequences to compare thermodynamic stability
- Use
mfold(web server) orRNAstructureinstead when you need Mfold algorithm predictions specifically or need the Efold partition function; ViennaRNA uses the Turner 2004 nearest-neighbor parameters and is the standard for research-grade thermodynamic prediction
Prerequisites
- Python packages:
ViennaRNA(Python bindings),matplotlib,numpy - Data requirements: RNA sequences as strings (ACGU alphabet; T is auto-converted to U by ViennaRNA)
- Environment: Python 3.8+; conda installation strongly recommended (handles C library dependencies)
# Install via conda (recommended)
conda install -c conda-forge -c bioconda viennarna
# Verify installation
python -c "import RNA; print(RNA.__version__)"
# 2.6.4
# Install additional Python dependencies
pip install matplotlib numpy pandas
# Optional: verify CLI tools are available
RNAfold --version
# RNAfold 2.6.4
Quick Start
import RNA
# Predict MFE structure for an RNA sequence
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
structure, mfe = RNA.fold(sequence)
print(f"Sequence: {sequence}")
print(f"Structure: {structure}")
print(f"MFE: {mfe:.2f} kcal/mol")
# Sequence: GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA
# Structure: (((((((..((((........)))).(((((.......))))).....(((((.......))))))))))))....
# MFE: -31.30 kcal/mol
Workflow
Step 1: Sequence Preparation and MFE Folding
Load an RNA sequence and compute its minimum free energy secondary structure using RNA.fold(). Validate the input and inspect the dot-bracket output.
import RNA
def prepare_sequence(seq: str) -> str:
"""Normalize sequence: uppercase, replace T→U, validate alphabet."""
seq = seq.upper().replace("T", "U").strip()
invalid = set(seq) - set("ACGUNX")
if invalid:
raise ValueError(f"Invalid characters in sequence: {invalid}")
return seq
# E. coli tRNA-Phe (GenBank: M10217)
raw_seq = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
sequence = prepare_sequence(raw_seq)
structure, mfe = RNA.fold(sequence)
print(f"Sequence length: {len(sequence)} nt")
print(f"Structure: {structure}")
print(f"MFE: {mfe:.2f} kcal/mol")
# Validate: structure length must equal sequence length
assert len(structure) == len(sequence), "Structure and sequence length mismatch"
# Count stems (paired bases)
n_paired = structure.count("(") + structure.count(")")
n_unpaired = structure.count(".")
print(f"Paired bases: {n_paired} | Unpaired bases: {n_unpaired}")
print(f"Stem fraction: {n_paired/len(sequence):.2f}")
Step 2: Create a Fold Compound for Advanced Analysis
The RNA.fold_compound object is the central API for partition function, base pair probabilities, and constrained folding.
import RNA
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
# Create fold compound (wraps the sequence with model parameters)
fc = RNA.fold_compound(sequence)
# Compute MFE structure via the fold compound API
structure, mfe = fc.mfe()
print(f"MFE structure: {structure}")
print(f"MFE: {mfe:.2f} kcal/mol")
# Evaluate free energy of an alternative structure
alt_structure = "." * len(sequence) # fully unfolded
energy = fc.eval_structure(alt_structure)
print(f"Fully unfolded energy: {energy:.2f} kcal/mol")
print(f"Folding stabilization: {energy - mfe:.2f} kcal/mol")
Step 3: Partition Function and Base Pair Probabilities
Compute the thermodynamic partition function to obtain ensemble-level base pair probabilities. High-probability pairs indicate well-defined structural elements.
import RNA
import numpy as np
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
n = len(sequence)
fc = RNA.fold_compound(sequence)
# Step 1: MFE folding (required before pf for proper initialization)
structure_mfe, mfe = fc.mfe()
# Step 2: Rescale Boltzmann factors for numerical stability (optional but recommended)
fc.exp_params_rescale(mfe)
# Step 3: Compute partition function
structure_pf, gibbs_free_energy = fc.pf()
print(f"Gibbs free energy (ensemble): {gibbs_free_energy:.2f} kcal/mol")
print(f"MFE structure: {structure_mfe}")
print(f"Centroid (pf): {structure_pf}")
# Step 4: Retrieve base pair probability matrix
bpp = fc.bpp() # returns (n+1)x(n+1) matrix; 1-indexed
# Convert to 0-indexed numpy array for analysis
probs = np.zeros((n, n))
for i in range(1, n + 1):
for j in range(i + 1, n + 1):
if bpp[i][j] > 0.0:
probs[i - 1][j - 1] = bpp[i][j]
probs[j - 1][i - 1] = bpp[i][j]
# Identify high-confidence pairs (p > 0.9)
high_conf = [(i, j, probs[i, j]) for i in range(n) for j in range(i + 1, n) if probs[i, j] > 0.9]
print(f"\nHigh-confidence base pairs (p > 0.9): {len(high_conf)}")
for i, j, p in high_conf[:5]:
print(f" {sequence[i]}{i+1} — {sequence[j]}{j+1}: p={p:.3f}")
Step 4: Visualize Base Pair Probability Matrix
Plot the base pair probability matrix as a heatmap to visualize structural regions.
import RNA
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
n = len(sequence)
fc = RNA.fold_compound(sequence)
structure_mfe, mfe = fc.mfe()
fc.exp_params_rescale(mfe)
fc.pf()
bpp = fc.bpp()
# Build matrix
probs = np.zeros((n, n))
for i in range(1, n + 1):
for j in range(i + 1, n + 1):
if bpp[i][j] > 0.0:
probs[i - 1][j - 1] = bpp[i][j]
probs[j - 1][i - 1] = bpp[i][j]
fig, ax = plt.subplots(figsize=(8, 7))
im = ax.imshow(probs, cmap="hot_r", vmin=0, vmax=1, origin="upper", aspect="equal")
plt.colorbar(im, ax=ax, label="Base pair probability")
ax.set_xlabel("Nucleotide position")
ax.set_ylabel("Nucleotide position")
ax.set_title(f"Base Pair Probability Matrix\n(n={n} nt, MFE={mfe:.2f} kcal/mol)")
plt.tight_layout()
plt.savefig("bpp_matrix.png", dpi=150, bbox_inches="tight")
print("Saved: bpp_matrix.png")
Step 5: RNA-RNA Duplex Prediction (Co-folding)
Use RNA.cofold() to predict the interaction between two RNA sequences by concatenating them with an & separator.
import RNA
# miRNA (hsa-miR-21-5p) and its target sequence in mRNA (PTEN 3'UTR region)
mirna_seq = "UAGCUUAUCAGACUGAUGUUGA"
target_seq = "UCAACAUCAGUCUGAUAAGCUA" # approximate complementary target
# Co-fold: concatenate with & separator
cofold_seq = mirna_seq + "&" + target_seq
structure, mfe = RNA.cofold(cofold_seq)
print(f"miRNA: {mirna_seq}")
print(f"Target: {target_seq}")
print(f"Co-fold MFE: {mfe:.2f} kcal/mol")
# Parse the structure — & is retained in output
n1, n2 = len(mirna_seq), len(target_seq)
struct_mirna = structure[:n1]
struct_ampersand = structure[n1]
struct_target = structure[n1 + 1:]
print(f"miRNA structure: {struct_mirna}")
print(f"Target structure: {struct_target}")
paired_in_duplex = struct_mirna.count("(") + struct_mirna.count(")")
print(f"Bases paired across the duplex: {paired_in_duplex}")
Step 6: RNA Accessibility Analysis for siRNA Design
Compute the accessibility of a target region within a longer mRNA sequence — critical for siRNA and antisense oligonucleotide efficiency.
import RNA
import numpy as np
def compute_accessibility(mrna_seq: str, window: int = 40) -> list:
"""
Compute per-position probability of being unpaired (accessible) using
a sliding-window approach on the partition function.
Returns list of (position, accessibility) tuples.
"""
n = len(mrna_seq)
fc = RNA.fold_compound(mrna_seq)
_, mfe = fc.mfe()
fc.exp_params_rescale(mfe)
fc.pf()
bpp = fc.bpp()
# Probability of being paired at each position
p_paired = np.zeros(n)
for i in range(1, n + 1):
for j in range(1, n + 1):
if i != j:
p = bpp[min(i,j)][max(i,j)]
p_paired[i - 1] += p
p_unpaired = 1.0 - np.clip(p_paired, 0, 1)
return p_unpaired
# Example: 80-nt mRNA segment with a known accessible region
mrna = "AUGCUAGCUAGCUAGCUAUGCUAGCUAGCUUUUUUUUUUUUAUGCUAGCUAGCUAGCUAGCUAGCUAGCUAGCUAGC"
p_unpaired = compute_accessibility(mrna)
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 3))
ax.bar(range(1, len(mrna) + 1), p_unpaired, color="#2166ac", alpha=0.8)
ax.axhline(0.5, color="red", lw=1, ls="--", label="50% unpaired")
ax.set_xlabel("Position (nt)")
ax.set_ylabel("P(unpaired)")
ax.set_title("RNA Accessibility Profile")
ax.legend()
plt.tight_layout()
plt.savefig("rna_accessibility.png", dpi=150, bbox_inches="tight")
print("Saved: rna_accessibility.png")
# Top 5 most accessible positions (siRNA target candidates)
best = sorted(enumerate(p_unpaired, 1), key=lambda x: -x[1])[:5]
print("\nMost accessible positions:")
for pos, prob in best:
print(f" Position {pos}: P(unpaired) = {prob:.3f} ({mrna[pos-1]})")
Step 7: Command-Line RNAfold and Output Parsing
Use the RNAfold CLI for batch folding via subprocess, then parse the output.
# Fold a single sequence from stdin
echo "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA" | RNAfold
# Output:
# GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA
# (((((((..((((........)))).(((((.......))))).....(((((.......)))))))))))).... (-31.30)
# Batch fold from FASTA file
RNAfold < sequences.fasta > structures.txt
# Generate base pair probability dot plot (PostScript)
RNAfold --noPS < sequences.fasta # suppress PostScript output
RNAfold -p < sequences.fasta # save dot plot as rna.ps
# Python: run RNAfold via subprocess and parse output
import subprocess
import re
def run_rnafold(sequence: str) -> tuple:
"""Run RNAfold CLI and return (structure, mfe) tuple."""
result = subprocess.run(
["RNAfold", "--noPS"],
input=sequence,
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
raise RuntimeError(f"RNAfold failed: {result.stderr}")
lines = result.stdout.strip().split("\n")
# Last line: structure and energy, e.g. "((....)) (-5.40)"
match = re.match(r"^([.()\[\]{}<>|]+)\s+\((-?\d+\.\d+)\)$", lines[-1])
if not match:
raise ValueError(f"Could not parse RNAfold output: {lines[-1]}")
structure = match.group(1)
mfe = float(match.group(2))
return structure, mfe
sequences = [
("tRNA-Phe", "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"),
("miR-21", "UAGCUUAUCAGACUGAUGUUGA"),
]
for name, seq in sequences:
struct, mfe = run_rnafold(seq)
print(f"{name}: {mfe:.2f} kcal/mol | {struct}")
Step 8: Constrained Folding and Suboptimal Structures
Apply hard constraints (force or forbid specific base pairs) and enumerate suboptimal structures.
import RNA
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
# --- Constrained folding: force specific pairs ---
fc = RNA.fold_compound(sequence)
# Add hard constraint: force positions 1-7 to be paired (known stem)
hc = RNA.hc_add_bp(fc, 1, 72) # force pair between position 1 and 72 (0-indexed in C API)
structure_c, mfe_c = fc.mfe()
print(f"Constrained MFE: {mfe_c:.2f} kcal/mol")
print(f"Constrained struct: {structure_c}")
# --- Enumerate suboptimal structures (delta_mfe threshold) ---
fc_sub = RNA.fold_compound(sequence)
_, mfe_opt = fc_sub.mfe()
# Get suboptimal structures within 5 kcal/mol of MFE
delta = 5.0 # kcal/mol window
subopt_list = RNA.subopt(sequence, int(delta * 100)) # energy in 10-cal units
print(f"\nSuboptimal structures within {delta} kcal/mol of MFE:")
print(f"Total suboptimal structures: {len(subopt_list)}")
for s in subopt_list[:3]:
e = s.energy / 100.0 # convert from 10-cal to kcal/mol
print(f" {s.structure} ({e:.2f} kcal/mol)")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
sequence (RNA.fold) |
— | ACGU string | Input RNA sequence; T is auto-converted to U |
temperature (model detail) |
37.0 °C |
0–100 °C |
Folding temperature; lower temp stabilizes structures |
dangles (model detail) |
2 |
0, 1, 2, 3 |
Dangling end treatment; 2=average, 0=none, use 2 for most applications |
noGU (model detail) |
False |
True/False |
Disallow G-U wobble pairs when True |
noLP (model detail) |
False |
True/False |
Disallow lonely base pairs (single-bp stems); reduces noise |
delta (RNA.subopt) |
— | float kcal/mol | Energy window above MFE for suboptimal structure enumeration |
window (sliding window) |
— | int nt | Sliding window size for long-sequence accessibility analysis |
Common Recipes
Recipe: Batch MFE Folding from a FASTA File
When to use: Fold a library of RNA sequences (e.g., candidate aptamers, guide RNAs) and compare energies.
import RNA
from pathlib import Path
def read_fasta(fasta_path: str) -> list:
"""Parse FASTA file, return list of (name, sequence) tuples."""
records = []
name, seq = None, []
for line in Path(fasta_path).read_text().splitlines():
if line.startswith(">"):
if name:
records.append((name, "".join(seq).upper().replace("T", "U")))
name, seq = line[1:].split()[0], []
else:
seq.append(line.strip())
if name:
records.append((name, "".join(seq).upper().replace("T", "U")))
return records
# Example: fold sequences from a FASTA file
sequences = [
("aptamer_1", "GGGUUUUGAAACUAAACUAGGCUCUAGCGCUGGUGUCCCUUCCCGGCUCUAGCCUCAGCAGAAGCUUGAAAAAACCC"),
("aptamer_2", "GGGAGACAAGAAUAAACGCUCAACGUCUACCAUGAUCGAAUGCUAGCCUUCUAGCUUGCUUCGGCAGCACUAUAGGG"),
("aptamer_3", "GGGCGACCCUGAUGAGUCCCAAGUCGAAACGAUUCCUUUUUAAACUCAUGGUGCCCAGCCUCGCUCAGCA"),
]
print(f"{'Name':<15} {'Length':>8} {'MFE':>10} {'Structure'}")
print("-" * 80)
for name, seq in sequences:
struct, mfe = RNA.fold(seq)
print(f"{name:<15} {len(seq):>8} {mfe:>10.2f} {struct[:50]}...")
Recipe: Check sgRNA Secondary Structure
When to use: Evaluate whether a CRISPR sgRNA guide sequence folds into secondary structures that reduce Cas9 binding efficiency.
import RNA
def assess_sgrna(guide_seq: str, scaffold: str = None) -> dict:
"""
Assess sgRNA secondary structure.
guide_seq: 20-nt spacer sequence (RNA)
scaffold: constant sgRNA scaffold sequence (default: SpCas9)
"""
if scaffold is None:
# SpCas9 sgRNA scaffold (Addgene standard)
scaffold = "GUUUUAGAGCUAGAAAUAGCAAGUUAAAAUAAGGCUAGUCCGUUAUCAACUUGAAAAAGUGGCACCGAGUCGGUGCUUU"
full_sgrna = guide_seq.upper().replace("T", "U") + scaffold
fc = RNA.fold_compound(full_sgrna)
structure, mfe = fc.mfe()
fc.exp_params_rescale(mfe)
fc.pf()
bpp = fc.bpp()
n_guide = len(guide_seq)
# Check if any guide bases are paired (bad for targeting)
guide_paired = sum(
bpp[min(i, j)][max(i, j)]
for i in range(1, n_guide + 1)
for j in range(1, n_guide + 1)
if i != j
)
guide_accessibility = 1.0 - min(1.0, guide_paired / n_guide)
return {
"guide_seq": guide_seq,
"mfe": mfe,
"structure": structure[:n_guide],
"guide_access": guide_accessibility,
"predicted_ok": guide_accessibility > 0.7,
}
guides = ["GCACUAGUGACGCAUGGCAC", "GGGCAUAGCUAGCUAGCUAU", "AAAUUCGCACUAGUGACGCA"]
for g in guides:
result = assess_sgrna(g)
status = "OK" if result["predicted_ok"] else "WARN (self-paired)"
print(f"{g}: accessibility={result['guide_access']:.2f}, MFE={result['mfe']:.1f} kcal/mol [{status}]")
Recipe: Mountain Plot Visualization of RNA Structure
When to use: Create a mountain plot — a classic RNA structure visualization showing stem height along the sequence.
import RNA
import matplotlib.pyplot as plt
def dot_bracket_to_mountain(structure: str) -> list:
"""Convert dot-bracket structure to mountain plot heights."""
heights = []
level = 0
for c in structure:
if c == "(":
level += 1
heights.append(level)
if c == ")":
level -= 1
return heights
sequence = "GCGGAUUUAGCUCAGUUGGGAGAGCGCCAGACUGAAGAUCUGGAGGUCCUGUGUUCGAUCCACAGAAUUCGCACCA"
structure, mfe = RNA.fold(sequence)
heights = dot_bracket_to_mountain(structure)
fig, axes = plt.subplots(2, 1, figsize=(12, 5), sharex=True,
gridspec_kw={"height_ratios": [1, 3]})
# Top: sequence text
axes[0].text(0.5, 0.5, "tRNA-Phe | E. coli", ha="center", va="center",
fontsize=10, transform=axes[0].transAxes)
axes[0].axis("off")
# Bottom: mountain plot
axes[1].fill_between(range(len(sequence)), heights, step="mid",
color="#2c7bb6", alpha=0.7, label=f"MFE = {mfe:.2f} kcal/mol")
axes[1].set_xlabel("Nucleotide position")
axes[1].set_ylabel("Stem height")
axes[1].set_title("Mountain Plot")
axes[1].legend()
plt.tight_layout()
plt.savefig("mountain_plot.png", dpi=150, bbox_inches="tight")
print(f"Saved: mountain_plot.png (MFE structure: {structure[:40]}...)")
Expected Outputs
| Output | Type | Description |
|---|---|---|
structure |
string | Dot-bracket notation of MFE secondary structure; ( and ) for paired bases, . for unpaired |
mfe |
float (kcal/mol) | Minimum free energy of the predicted structure; more negative = more stable |
bpp |
(n+1)×(n+1) matrix | Base pair probability matrix from partition function; element [i][j] = P(i paired with j), 1-indexed |
bpp_matrix.png |
PNG | Heatmap visualization of base pair probabilities |
rna_accessibility.png |
PNG | Per-position probability of being unpaired |
mountain_plot.png |
PNG | Mountain plot of stem heights along the sequence |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: No module named 'RNA' |
ViennaRNA Python bindings not installed | Install via conda install -c conda-forge viennarna; pip install alone may fail to link C library |
RuntimeError: RNAfold not found |
ViennaRNA CLI not in PATH | Confirm with which RNAfold; if using conda env, activate it before running scripts |
| MFE is unexpectedly positive (> 0) | Very short or repetitive sequence with no favorable pairs | Short sequences (< 10 nt) often have positive MFE; check sequence length and composition |
bpp matrix all zeros after fc.pf() |
fc.mfe() must be called before fc.pf() on the same fold compound |
Always call fc.mfe() first, then fc.exp_params_rescale(mfe), then fc.pf() |
| Suboptimal enumeration returns thousands of structures | Window too large for long sequences | Reduce delta to 2–3 kcal/mol for long sequences; very stable sequences have dense suboptimal ensembles |
Co-fold (RNA.cofold) shows unexpected pairing |
Intramolecular folding dominates in one strand | Inspect each strand separately first; low individual-strand MFE indicates strong self-structure interfering with duplex |
References
- ViennaRNA documentation — official documentation, parameter files, and Python API reference
- Lorenz et al., Algorithms Mol Biol 2011 — ViennaRNA Package 2.0 paper (primary citation for structure prediction)
- Turner & Mathews, Nucleic Acids Res 2010 — Nearest-neighbor thermodynamic parameters used by ViennaRNA
- ViennaRNA GitHub: ViennaRNA/ViennaRNA — source code, issue tracker, Python tutorial notebooks
skills/proteomics-protein-engineering/esm-protein-language-model/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill esm-protein-language-model -g -y
SKILL.md
Frontmatter
{
"name": "esm-protein-language-model",
"license": "MIT",
"description": "Protein language models (ESM3, ESM C) for sequence generation, structure prediction, inverse folding, and embeddings. Design novel proteins, extract ML features, or fold sequences. Local GPU or EvolutionaryScale Forge API. Use AlphaFold for traditional folding; RDKit for small molecules."
}
ESM — Protein Language Models
Overview
ESM (Evolutionary Scale Modeling) provides pretrained protein language models for generative protein design and representation learning. ESM3 is a multimodal generative model conditioned on sequence, structure, and function simultaneously. ESM C is an efficient embedding model optimized for extracting protein representations for downstream ML tasks.
When to Use
- Generating novel protein sequences conditioned on desired structure or function
- Extracting fixed-length embeddings from protein sequences for classification, clustering, or regression
- Predicting 3D structure from amino acid sequence
- Inverse folding: designing sequences that fold into a target structure
- Annotating proteins with functional keywords (GO terms, EC numbers)
- Comparing protein similarity via embedding distance instead of sequence alignment
- Chain-of-thought protein design: iterative refinement of sequence/structure/function
- For traditional physics-based structure prediction, use AlphaFold instead
- For sequence alignment and homology search, use BLAST/HMMER via BioPython instead
Prerequisites
- Python packages:
esm(EvolutionaryScale package) - Hardware: GPU recommended for local inference (ESM3: 8GB+ VRAM; ESM C: 4GB+ VRAM). CPU works for small batches
- Cloud alternative: EvolutionaryScale Forge API (requires API token from forge.evolutionaryscale.ai)
- Model weights: Downloaded automatically on first use (~1-4 GB depending on model)
pip install esm
# For Forge cloud API
pip install esm[forge]
Quick Start
from esm.models.esmc import ESMC
from esm.sdk.api import ESMProtein
# Load ESM C model for embeddings
model = ESMC.from_pretrained("esmc_600m")
# Create protein from sequence
protein = ESMProtein(sequence="MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKRQQIAATGFHIIPGDKPDNRAGGYDN")
# Get per-residue embeddings
output = model(protein)
embeddings = output.embeddings # shape: (1, seq_len, embedding_dim)
print(f"Embedding shape: {embeddings.shape}")
# Embedding shape: (1, 101, 1152)
Core API
1. Protein Sequence Generation (ESM3)
Generate novel protein sequences conditioned on structure, function, or partial sequence.
from esm.models.esm3 import ESM3
from esm.sdk.api import ESM3InferenceClient, ESMProtein, GenerationConfig
# Load ESM3 locally
model = ESM3.from_pretrained("esm3_sm_open_v1")
# Generate from partial sequence (fill in masked positions)
prompt = ESMProtein(sequence="MKTAYIAK____ISFVK____RQLEERLG") # ____ = positions to generate
config = GenerationConfig(track="sequence", num_steps=10, temperature=0.7)
generated = model.generate(prompt, config)
print(f"Generated sequence: {generated.sequence[:50]}...")
# Conditional generation: design sequence for a target structure
from esm.sdk.api import ESMProtein, GenerationConfig
from esm.utils.structure.protein_chain import ProteinChain
# Load target structure from PDB
chain = ProteinChain.from_pdb("target.pdb")
prompt = ESMProtein.from_protein_chain(chain)
prompt.sequence = None # Clear sequence, keep structure
config = GenerationConfig(track="sequence", num_steps=16, temperature=0.5)
designed = model.generate(prompt, config)
print(f"Designed sequence ({len(designed.sequence)} residues): {designed.sequence[:50]}...")
2. Protein Embeddings (ESM C)
Extract fixed-length representations for downstream ML tasks.
from esm.models.esmc import ESMC
from esm.sdk.api import ESMProtein
import torch
model = ESMC.from_pretrained("esmc_600m") # or "esmc_300m" for lighter model
sequences = [
"MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKRQQIAATGFHIIPGDKPDNRAGGYDN",
"MKWVTFISLLFLFSSAYSRGVFRRDAHKSEVAHRFKDLGEENFKALVLIAFAQYLQQCPFEDHVKLVNEVTEFAKTCVADESAENCDKS",
]
embeddings = []
for seq in sequences:
protein = ESMProtein(sequence=seq)
output = model(protein)
# Mean-pool per-residue embeddings to get fixed-length vector
mean_emb = output.embeddings.mean(dim=1) # shape: (1, embedding_dim)
embeddings.append(mean_emb)
emb_matrix = torch.cat(embeddings, dim=0)
print(f"Embedding matrix: {emb_matrix.shape}") # (2, 1152)
# Compute pairwise similarity
similarity = torch.cosine_similarity(emb_matrix[0:1], emb_matrix[1:2])
print(f"Cosine similarity: {similarity.item():.4f}")
3. Structure Prediction
Predict 3D coordinates from amino acid sequence.
from esm.models.esm3 import ESM3
from esm.sdk.api import ESMProtein, GenerationConfig
model = ESM3.from_pretrained("esm3_sm_open_v1")
protein = ESMProtein(sequence="MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKRQQIAATGFHIIPGDKPDNRAGGYDN")
# Generate structure from sequence
config = GenerationConfig(track="structure", num_steps=16)
result = model.generate(protein, config)
# Save predicted structure
result.to_pdb("predicted.pdb")
print(f"Saved structure: {len(result.sequence)} residues → predicted.pdb")
4. Inverse Folding
Design amino acid sequences that fold into a target 3D structure.
from esm.models.esm3 import ESM3
from esm.sdk.api import ESMProtein, GenerationConfig
from esm.utils.structure.protein_chain import ProteinChain
model = ESM3.from_pretrained("esm3_sm_open_v1")
# Load target structure
chain = ProteinChain.from_pdb("target_structure.pdb")
prompt = ESMProtein.from_protein_chain(chain)
# Clear sequence but keep structure coordinates
prompt.sequence = None
# Generate multiple designs
designs = []
for i in range(5):
config = GenerationConfig(track="sequence", num_steps=16, temperature=0.7)
designed = model.generate(prompt, config)
designs.append(designed.sequence)
print(f"Design {i+1}: {designed.sequence[:40]}...")
print(f"Generated {len(designs)} sequence designs for target structure")
5. Function Conditioning
Generate proteins with desired functional annotations (GO terms, enzyme activity).
from esm.models.esm3 import ESM3
from esm.sdk.api import ESMProtein, GenerationConfig
model = ESM3.from_pretrained("esm3_sm_open_v1")
# Condition on functional keywords
protein = ESMProtein(
sequence=None, # generate de novo
function_annotations=["ATP binding", "kinase activity", "protein phosphorylation"],
)
config = GenerationConfig(track="sequence", num_steps=32, temperature=0.7)
result = model.generate(protein, config)
print(f"Function-conditioned sequence: {result.sequence[:50]}...")
print(f"Length: {len(result.sequence)} residues")
6. Forge Cloud API
Use EvolutionaryScale's cloud inference for large models without local GPU.
from esm.sdk.forge import ESM3ForgeInferenceClient
from esm.sdk.api import ESMProtein, GenerationConfig
# Authenticate (requires FORGE_API_TOKEN env var or explicit token)
client = ESM3ForgeInferenceClient(model="esm3-open-2024-03", token="your_token_here")
protein = ESMProtein(sequence="MKTAYIAKQRQISFVKSHFSRQLEERLG")
config = GenerationConfig(track="structure", num_steps=16)
result = client.generate(protein, config)
result.to_pdb("forge_predicted.pdb")
print("Predicted structure via Forge API → forge_predicted.pdb")
Key Concepts
ESM3 vs ESM C: When to Use Which
| Feature | ESM3 | ESM C |
|---|---|---|
| Primary use | Generative protein design | Embedding extraction |
| Capabilities | Sequence generation, structure prediction, inverse folding, function conditioning | Per-residue and mean-pooled embeddings |
| Model sizes | esm3_sm_open_v1 (~1.4B params) | esmc_300m, esmc_600m |
| GPU requirement | 8GB+ VRAM | 4GB+ VRAM (esmc_300m: 2GB) |
| Use case | Design new proteins, predict structures | Downstream ML (classification, clustering, regression) |
| Cloud option | Forge API (larger models available) | Local only |
GenerationConfig Parameters
The GenerationConfig controls how ESM3 generates outputs:
track: Which modality to generate ("sequence","structure","function")num_steps: Number of iterative refinement steps (higher = better quality, slower)temperature: Sampling temperature (0.0 = greedy, 0.5-0.7 = diverse, 1.0 = maximum diversity)
ESMProtein Object
The central data container holding sequence, structure coordinates, and functional annotations:
.sequence— amino acid string (e.g., "MKTAY...").coordinates— 3D atom positions (Nx3 tensor).function_annotations— list of functional keywords- Use
ESMProtein.from_protein_chain()to load from PDB structures - Use
.to_pdb()to save predicted structures
Common Workflows
Workflow 1: Protein Embedding-Based Classification
Goal: Extract embeddings from protein sequences and train a downstream classifier.
from esm.models.esmc import ESMC
from esm.sdk.api import ESMProtein
import torch
import numpy as np
model = ESMC.from_pretrained("esmc_600m")
# Embed a set of sequences
sequences = ["MKTAY...", "MKWVT...", "MSGLI..."] # replace with actual sequences
labels = [0, 1, 0] # binary labels
embeddings = []
for seq in sequences:
protein = ESMProtein(sequence=seq)
output = model(protein)
mean_emb = output.embeddings.mean(dim=1).detach().cpu().numpy()
embeddings.append(mean_emb.squeeze())
X = np.array(embeddings)
y = np.array(labels)
print(f"Feature matrix: {X.shape}") # (n_samples, 1152)
# Train a simple classifier
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(max_iter=1000).fit(X, y)
print(f"Training accuracy: {clf.score(X, y):.2f}")
Workflow 2: Structure-Conditioned Protein Design
Goal: Design multiple novel sequences that fold into a target structure, then rank by predicted quality.
- Load target structure from PDB using
ProteinChain.from_pdb()(Core API module 4) - Create ESMProtein prompt with structure but no sequence
- Generate 10+ sequence designs with
temperature=0.7for diversity (Core API module 1) - For each design, predict structure from designed sequence (Core API module 3)
- Compare predicted structure to target structure (RMSD calculation) to rank designs
- Select top designs for experimental validation
Key Parameters
| Parameter | Module/Function | Default | Range / Options | Effect |
|---|---|---|---|---|
num_steps |
GenerationConfig |
varies | 1–64 |
Iterative refinement steps; more = higher quality, slower |
temperature |
GenerationConfig |
1.0 |
0.0–1.5 |
Sampling diversity; 0.0=greedy, 0.7=balanced, 1.0+=creative |
track |
GenerationConfig |
— | "sequence", "structure", "function" |
Which modality to generate |
| model name | from_pretrained |
— | "esm3_sm_open_v1", "esmc_300m", "esmc_600m" |
Model size/capability tradeoff |
token |
ESM3ForgeInferenceClient |
env var | API token string | Forge cloud authentication |
Best Practices
-
Use ESM C for embedding tasks, ESM3 for generation: ESM C is smaller, faster, and optimized for representation quality. Only use ESM3 when you need generative capabilities (sequence design, structure prediction, inverse folding).
-
Mean-pool per-residue embeddings for fixed-length representations: ESM C outputs per-residue embeddings (seq_len × dim). For downstream ML that requires fixed-length input, average across the sequence dimension:
embeddings.mean(dim=1). -
Use temperature 0.5–0.7 for protein design: Temperature 1.0 produces very diverse but potentially non-functional sequences. Temperature 0.5–0.7 balances diversity with quality. Use temperature 0.0 only for deterministic structure prediction.
-
Increase num_steps for higher-quality generation: More iterative refinement steps improve output quality at the cost of computation time. Use 8–16 steps for quick exploration, 32+ for final designs.
-
Batch sequences to maximize GPU utilization: Processing one sequence at a time underutilizes the GPU. When embedding many sequences, batch them (limited by VRAM).
-
Use Forge API for large-scale or large-model inference: The open-weight ESM3 is a smaller variant. For production-quality protein design, the Forge API provides access to larger models.
Common Recipes
Recipe: Pairwise Protein Similarity Matrix
from esm.models.esmc import ESMC
from esm.sdk.api import ESMProtein
import torch
import numpy as np
model = ESMC.from_pretrained("esmc_300m")
sequences = {
"Protein_A": "MKTAYIAKQRQISFVK...",
"Protein_B": "MKWVTFISLLFLFSSAYS...",
"Protein_C": "MSGLILQRAAVIAAGASSAG...",
}
# Extract embeddings
embs = {}
for name, seq in sequences.items():
protein = ESMProtein(sequence=seq)
output = model(protein)
embs[name] = output.embeddings.mean(dim=1).detach().squeeze()
# Compute similarity matrix
names = list(embs.keys())
sim_matrix = np.zeros((len(names), len(names)))
for i, n1 in enumerate(names):
for j, n2 in enumerate(names):
sim_matrix[i, j] = torch.cosine_similarity(embs[n1].unsqueeze(0), embs[n2].unsqueeze(0)).item()
print("Similarity matrix:")
for i, name in enumerate(names):
print(f" {name}: {sim_matrix[i].round(3)}")
Recipe: Save/Load Embeddings for Reuse
from esm.models.esmc import ESMC
from esm.sdk.api import ESMProtein
import torch
import numpy as np
model = ESMC.from_pretrained("esmc_600m")
# Generate and save
protein = ESMProtein(sequence="MKTAYIAKQRQISFVK...")
output = model(protein)
np.save("embedding.npy", output.embeddings.detach().cpu().numpy())
print("Saved embedding.npy")
# Load later (no GPU needed)
embedding = np.load("embedding.npy")
print(f"Loaded embedding: {embedding.shape}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
CUDA out of memory |
Model too large for GPU | Use smaller model (esmc_300m), reduce batch size, or use Forge cloud API |
RuntimeError: no CUDA device |
No GPU available | Models work on CPU (slower). Set device="cpu" or use Forge API |
| Slow generation | Too many num_steps or CPU inference |
Reduce num_steps (8 for drafts), use GPU, or use Forge API for large models |
ImportError: esm |
Package not installed | pip install esm (note: this is EvolutionaryScale's esm, not the older Facebook Research esm) |
| Low-quality generated sequences | Temperature too high or too few steps | Lower temperature to 0.5, increase num_steps to 32+ |
| Forge API authentication error | Invalid or missing API token | Set FORGE_API_TOKEN env var or pass token= explicitly; get token from forge.evolutionaryscale.ai |
KeyError loading model weights |
Wrong model name | Use exact names: "esm3_sm_open_v1", "esmc_300m", "esmc_600m" |
Related Skills
- alphafold-database-access — retrieve predicted structures from AlphaFold DB; use ESM for de novo structure prediction
- biopython — sequence I/O and alignment; preprocess sequences before ESM embedding
- scikit-learn — downstream ML on ESM embeddings (classification, clustering, regression)
- rdkit — cheminformatics for small-molecule drug design (complementary to protein design)
References
- ESM GitHub — source code and model weights
- EvolutionaryScale Forge — cloud inference API
- Hayes et al. (2024) "Simulating 500 million years of evolution with a language model" — bioRxiv
- Lin et al. (2023) "Evolutionary-scale prediction of atomic-level protein structure with a language model" — Science
skills/proteomics-protein-engineering/hmdb-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill hmdb-database -g -y
SKILL.md
Frontmatter
{
"name": "hmdb-database",
"license": "CC-BY-4.0",
"description": "Parse HMDB (Human Metabolome Database) local XML for metabolite info, chemical properties, biological context, disease links, spectra, and cross-DB mapping. No REST API — uses ~6 GB XML download. Use drugbank-database-access for drugs; pubchem-compound-search for live lookups."
}
HMDB Database — Local XML Access
Overview
Query the Human Metabolome Database (HMDB, 220,000+ metabolite entries) by parsing locally downloaded XML with Python's ElementTree. Covers metabolite lookup, chemical properties, biological context (pathways, enzymes, biofluids), disease/biomarker associations, spectral data for metabolite identification, and cross-database ID mapping to KEGG, PubChem, ChEBI, and DrugBank.
When to Use
- Looking up metabolite information (description, chemical class, cellular location) by HMDB ID or name
- Retrieving chemical properties (molecular weight, formula, SMILES, InChI, logP, PSA) for metabolomics analysis
- Finding pathway associations and enzyme links for a set of metabolites
- Identifying biofluid/tissue locations of metabolites (blood, urine, CSF, saliva)
- Querying disease associations and normal/abnormal concentration ranges for biomarker discovery
- Extracting NMR or MS spectral peak lists for metabolite identification
- Mapping HMDB IDs to KEGG, PubChem, ChEBI, DrugBank, or other databases
- For drug-specific data (interactions, targets, pharmacology) use
drugbank-database-accessinstead - For live compound property queries without downloading use
pubchem-compound-searchinstead
Prerequisites
- HMDB XML download: Register at https://hmdb.ca/downloads — download
hmdb_metabolites.xml.zip(~6 GB uncompressed) - Python packages:
lxml(faster XPath) or standardxml.etree.ElementTree,pandas - No public REST API: HMDB has no programmatic REST API. All access is via local XML parsing or the web interface
- R package (optional):
hmdbQueryon CRAN provides some query wrappers but is limited and outdated - Rate limits: N/A for local XML parsing. The web interface has no documented rate limits but is not intended for scraping
pip install lxml pandas
Quick Start
import xml.etree.ElementTree as ET
NS = {'hmdb': 'http://www.hmdb.ca'}
tree = ET.parse('hmdb_metabolites.xml') # 60-120s for full XML
root = tree.getroot()
# Build lookup index (HMDB ID + lowercase name -> element)
metabolite_index = {}
for met in root.findall('hmdb:metabolite', NS):
accession = met.find('hmdb:accession', NS)
name = met.find('hmdb:name', NS)
if accession is not None and accession.text:
metabolite_index[accession.text] = met
if name is not None and name.text:
metabolite_index[name.text.lower()] = met
def find_metabolite(query):
"""Find metabolite by HMDB ID or name (case-insensitive)."""
return metabolite_index.get(query) or metabolite_index.get(query.lower())
met = find_metabolite('HMDB0000122') # Glucose
name = met.find('hmdb:name', NS).text
formula = met.find('hmdb:chemical_formula', NS).text
print(f"{name}: {formula}")
# Glucose: C6H12O6
Core API
1. XML Setup and Metabolite Lookup
import xml.etree.ElementTree as ET
NS = {'hmdb': 'http://www.hmdb.ca'}
tree = ET.parse('hmdb_metabolites.xml')
root = tree.getroot()
print(f"Total metabolite entries: {len(root.findall('hmdb:metabolite', NS))}")
# Total metabolite entries: ~220000+
For memory-constrained environments, use iterparse:
met_names = {}
for event, elem in ET.iterparse('hmdb_metabolites.xml', events=('end',)):
if elem.tag == '{http://www.hmdb.ca}metabolite':
acc = elem.find('{http://www.hmdb.ca}accession')
name = elem.find('{http://www.hmdb.ca}name')
if acc is not None and name is not None and acc.text and name.text:
met_names[acc.text] = name.text
elem.clear()
print(f"Parsed {len(met_names)} metabolites via iterparse")
2. Chemical Properties
def get_chemical_properties(met_element):
"""Extract chemical properties from a metabolite entry."""
def txt(path):
el = met_element.find(path, NS)
return el.text if el is not None and el.text else None
# Fields: accession, name, chemical_formula, average_molecular_weight,
# monisotopic_molecular_weight, smiles, inchi, inchikey, state, iupac_name
return {tag: txt(f'hmdb:{tag}') for tag in [
'accession', 'name', 'chemical_formula', 'average_molecular_weight',
'monisotopic_molecular_weight', 'smiles', 'inchi', 'inchikey', 'state']}
props = get_chemical_properties(find_metabolite('HMDB0000158')) # L-Tyrosine
print(f"{props['name']}: MW={props['average_molecular_weight']}, SMILES={props['smiles']}")
# Extract taxonomy / chemical classification (ClassyFire ontology)
def get_classification(met_element):
tax = met_element.find('hmdb:taxonomy', NS)
if tax is None:
return {}
def txt(tag):
el = tax.find(f'hmdb:{tag}', NS)
return el.text if el is not None and el.text else None
return {'kingdom': txt('kingdom'), 'super_class': txt('super_class'),
'class': txt('class'), 'sub_class': txt('sub_class'),
'direct_parent': txt('direct_parent')}
print(get_classification(find_metabolite('HMDB0000122')))
# {'kingdom': 'Organic compounds', 'super_class': 'Organooxygen compounds', ...}
3. Biological Context
def get_pathways(met_element):
"""Extract metabolic pathway associations."""
pathways = []
for pw in met_element.findall('hmdb:pathways/hmdb:pathway', NS):
name = pw.find('hmdb:name', NS)
smpdb_id = pw.find('hmdb:smpdb_id', NS)
kegg_id = pw.find('hmdb:kegg_map_id', NS)
pathways.append({
'name': name.text if name is not None else None,
'smpdb_id': smpdb_id.text if smpdb_id is not None else None,
'kegg_map_id': kegg_id.text if kegg_id is not None else None,
})
return pathways
for pw in get_pathways(find_metabolite('HMDB0000122'))[:5]:
print(f" {pw['smpdb_id']}: {pw['name']}")
def get_biolocations(met_element):
"""Extract biofluid, tissue, and cellular locations."""
bp = 'hmdb:biological_properties/hmdb:'
def texts(path):
return [el.text for el in met_element.findall(path, NS) if el.text]
return {'biofluids': texts(f'{bp}biospecimen_locations/hmdb:biospecimen'),
'tissues': texts(f'{bp}tissue_locations/hmdb:tissue'),
'cellular': texts(f'{bp}cellular_locations/hmdb:cellular')}
locs = get_biolocations(find_metabolite('HMDB0000122'))
print(f"Glucose biofluids: {locs['biofluids']}")
def get_enzymes(met_element):
"""Extract associated enzymes/proteins with UniProt IDs."""
return [{'name': (p.find('hmdb:name', NS).text
if p.find('hmdb:name', NS) is not None else None),
'uniprot_id': (p.find('hmdb:uniprot_id', NS).text
if p.find('hmdb:uniprot_id', NS) is not None else None),
'gene_name': (p.find('hmdb:gene_name', NS).text
if p.find('hmdb:gene_name', NS) is not None else None)}
for p in met_element.findall('hmdb:protein_associations/hmdb:protein', NS)]
enz = get_enzymes(find_metabolite('HMDB0000122'))
print(f"Glucose-associated proteins: {len(enz)}")
for e in enz[:3]:
print(f" {e['gene_name']} ({e['uniprot_id']}): {e['name']}")
4. Disease and Biomarker Queries
def get_diseases(met_element):
"""Extract disease associations with OMIM IDs and PubMed references."""
diseases = []
for d in met_element.findall('hmdb:diseases/hmdb:disease', NS):
name = d.find('hmdb:name', NS)
omim = d.find('hmdb:omim_id', NS)
pmids = [r.find('hmdb:pubmed_id', NS).text
for r in d.findall('hmdb:references/hmdb:reference', NS)
if r.find('hmdb:pubmed_id', NS) is not None and r.find('hmdb:pubmed_id', NS).text]
diseases.append({'name': name.text if name is not None else None,
'omim_id': omim.text if omim is not None else None,
'pubmed_ids': pmids})
return diseases
diseases = get_diseases(find_metabolite('HMDB0000122'))
print(f"Glucose disease associations: {len(diseases)}")
for d in diseases[:3]:
print(f" {d['name']} (OMIM: {d['omim_id']})")
def get_concentrations(met_element, biospecimen='Blood'):
"""Extract normal/abnormal concentration data filtered by biospecimen."""
result = {'normal': [], 'abnormal': []}
for ctype, key in [('normal_concentrations', 'normal'),
('abnormal_concentrations', 'abnormal')]:
for c in met_element.findall(f'hmdb:{ctype}/hmdb:concentration', NS):
bio = c.find('hmdb:biospecimen', NS)
if bio is None or bio.text != biospecimen:
continue
def txt(tag):
el = c.find(f'hmdb:{tag}', NS)
return el.text if el is not None else None
result[key].append({'value': txt('concentration_value'),
'units': txt('concentration_units'),
'condition': txt('subject_condition')})
return result
conc = get_concentrations(find_metabolite('HMDB0000122'), 'Blood')
print(f"Glucose blood: {len(conc['normal'])} normal, {len(conc['abnormal'])} abnormal")
5. Spectral Data
def get_ms_spectra(met_element):
"""Extract MS/MS spectral peak lists (m/z + intensity)."""
spectra = []
for spec in met_element.findall('hmdb:spectra/hmdb:spectrum', NS):
stype = spec.find('hmdb:type', NS)
if stype is None or 'MS' not in (stype.text or ''):
continue
peaks = [{'mz': float(p.find('hmdb:mass_charge', NS).text),
'intensity': float(p.find('hmdb:intensity', NS).text or 0)}
for p in spec.findall('hmdb:ms_ms_peaks/hmdb:ms_ms_peak', NS)
if p.find('hmdb:mass_charge', NS) is not None
and p.find('hmdb:mass_charge', NS).text]
spectra.append({'type': stype.text, 'num_peaks': len(peaks), 'peaks': peaks})
return spectra
spectra = get_ms_spectra(find_metabolite('HMDB0000122'))
print(f"Glucose MS spectra: {len(spectra)}")
def get_nmr_spectra(met_element):
"""Extract NMR spectral peak lists (1H, 13C). Same pattern as MS above."""
spectra = []
for spec in met_element.findall('hmdb:spectra/hmdb:spectrum', NS):
stype = spec.find('hmdb:type', NS)
if stype is None or 'NMR' not in (stype.text or ''):
continue
nucleus = spec.find('hmdb:nucleus', NS)
shifts = [float(p.find('hmdb:chemical_shift', NS).text)
for p in spec.findall('hmdb:nmr_one_d_peaks/hmdb:nmr_one_d_peak', NS)
if p.find('hmdb:chemical_shift', NS) is not None
and p.find('hmdb:chemical_shift', NS).text]
spectra.append({'type': stype.text,
'nucleus': nucleus.text if nucleus is not None else None,
'num_peaks': len(shifts), 'chemical_shifts': shifts})
return spectra
nmr = get_nmr_spectra(find_metabolite('HMDB0000122'))
print(f"Glucose NMR spectra: {len(nmr)}")
6. Cross-Database Mapping
def get_external_ids(met_element):
"""Extract cross-database identifiers (KEGG, PubChem, ChEBI, DrugBank, CAS, etc.)."""
fields = {'kegg_id': 'KEGG', 'pubchem_compound_id': 'PubChem', 'chebi_id': 'ChEBI',
'drugbank_id': 'DrugBank', 'chemspider_id': 'ChemSpider',
'cas_registry_number': 'CAS', 'biocyc_id': 'BioCyc', 'pdb_id': 'PDB',
'foodb_id': 'FooDB', 'metlin_id': 'METLIN'}
ids = {}
for tag, label in fields.items():
el = met_element.find(f'hmdb:{tag}', NS)
if el is not None and el.text:
ids[label] = el.text
return ids
ids = get_external_ids(find_metabolite('HMDB0000122'))
print(f"Glucose cross-refs: {ids}")
# {'KEGG': 'C00031', 'PubChem': '5793', 'ChEBI': '17234', ...}
# Build cross-reference table for a metabolite list
import pandas as pd
queries = ['HMDB0000122', 'HMDB0000158', 'HMDB0000167', 'HMDB0000148']
rows = []
for q in queries:
m = find_metabolite(q)
if m is None: continue
ids = get_external_ids(m)
rows.append({'name': m.find('hmdb:name', NS).text, 'hmdb_id': q,
'kegg': ids.get('KEGG'), 'pubchem': ids.get('PubChem'),
'chebi': ids.get('ChEBI')})
print(pd.DataFrame(rows).to_string(index=False))
Key Concepts
HMDB XML Entry Structure
| Section | XPath | Content |
|---|---|---|
| Identity | hmdb:accession, hmdb:name, hmdb:iupac_name |
Primary identifiers |
| Chemical | hmdb:chemical_formula, hmdb:smiles, hmdb:inchi |
Structure descriptors |
| Properties | hmdb:average_molecular_weight, hmdb:state |
Physical properties |
| Taxonomy | hmdb:taxonomy/hmdb:kingdom, class, etc. |
Chemical classification |
| Biological | hmdb:biological_properties |
Biofluids, tissues, cellular locations |
| Pathways | hmdb:pathways/hmdb:pathway |
SMPDB + KEGG pathway links |
| Enzymes | hmdb:protein_associations/hmdb:protein |
Associated proteins/enzymes |
| Diseases | hmdb:diseases/hmdb:disease |
Disease associations + OMIM IDs |
| Concentrations | hmdb:normal_concentrations, hmdb:abnormal_concentrations |
Biomarker reference ranges |
| Spectra | hmdb:spectra/hmdb:spectrum |
NMR, MS/MS peak lists |
| External IDs | hmdb:kegg_id, hmdb:pubchem_compound_id, etc. |
Cross-database identifiers |
| Ontology | hmdb:ontology |
Physiological/disposition/process roles |
Data Field Completeness
Not all entries have all fields populated. Coverage varies by metabolite class:
| Field Category | Approximate Coverage | Notes |
|---|---|---|
| Accession, name, formula | ~100% | Always present |
| SMILES, InChI, MW | ~90% | Missing for some lipids and complex metabolites |
| Taxonomy/classification | ~85% | Chemical ontology from ClassyFire |
| Biofluid locations | ~60% | Best for common human metabolites |
| Pathways | ~40% | Curated SMPDB + KEGG links |
| Protein associations | ~35% | Enzyme-metabolite relationships |
| Disease associations | ~25% | Primarily for biomarker metabolites |
| Normal concentrations | ~20% | Reference ranges for clinical metabolites |
| MS/MS spectra | ~15% | Experimental spectral libraries |
| NMR spectra | ~10% | 1H and 13C chemical shift data |
File Format Comparison
| Format | File | Size | Best For |
|---|---|---|---|
| Full XML | hmdb_metabolites.xml |
~6 GB | Complete data access (all fields) |
| SDF | structures.sdf |
~200 MB | Chemical structures + basic properties |
| CSV | Various exports | ~50-500 MB | Tabular data (properties, concentrations) |
| FASTA | hmdb_proteins.fasta |
~50 MB | Protein sequence lookups |
Common Workflows
Workflow 1: Metabolite Identification from Mass Spec
Goal: Match an observed m/z value to candidate metabolites using molecular weight. Assumes root, NS from Quick Start.
import pandas as pd
observed_mz = 180.063 # [M+H]+ for glucose
adduct_mass = 1.00728 # H+ adduct
target_mw = observed_mz - adduct_mass
tolerance_da = 0.01
candidates = []
for met in root.findall('hmdb:metabolite', NS):
mw_el = met.find('hmdb:monisotopic_molecular_weight', NS)
if mw_el is None or not mw_el.text:
continue
mw = float(mw_el.text)
if abs(mw - target_mw) <= tolerance_da:
candidates.append({
'hmdb_id': met.find('hmdb:accession', NS).text,
'name': met.find('hmdb:name', NS).text,
'mw': mw, 'delta_da': abs(mw - target_mw),
'formula': (met.find('hmdb:chemical_formula', NS).text
if met.find('hmdb:chemical_formula', NS) is not None else None),
})
df = pd.DataFrame(candidates).sort_values('delta_da')
print(f"Candidates within {tolerance_da} Da of {target_mw:.3f}: {len(df)}")
print(df.head(10).to_string(index=False))
Workflow 2: Biomarker Discovery for a Disease
Goal: Find all metabolites associated with a disease and their concentration changes. Assumes root, NS from Quick Start.
import pandas as pd
disease_query = 'diabetes'
biomarkers = []
for met in root.findall('hmdb:metabolite', NS):
for d in met.findall('hmdb:diseases/hmdb:disease', NS):
dname = d.find('hmdb:name', NS)
if dname is None or not dname.text:
continue
if disease_query.lower() not in dname.text.lower():
continue
abnormal = [c for c in met.findall(
'hmdb:abnormal_concentrations/hmdb:concentration', NS)
if c.find('hmdb:subject_condition', NS) is not None
and disease_query.lower() in
(c.find('hmdb:subject_condition', NS).text or '').lower()]
biomarkers.append({
'hmdb_id': met.find('hmdb:accession', NS).text,
'metabolite': met.find('hmdb:name', NS).text,
'disease': dname.text,
'abnormal_measurements': len(abnormal),
})
df = pd.DataFrame(biomarkers).drop_duplicates(['hmdb_id', 'disease'])
print(f"Metabolites linked to '{disease_query}': {len(df)}")
print(df.sort_values('abnormal_measurements', ascending=False).head(15).to_string(index=False))
Workflow 3: Pathway Enrichment from Metabolite List
Goal: Given a list of identified metabolites, find over-represented pathways. Assumes metabolite_index from Quick Start.
from collections import Counter
import pandas as pd
hit_ids = ['HMDB0000122', 'HMDB0000158', 'HMDB0000167',
'HMDB0000148', 'HMDB0000064', 'HMDB0000161']
hit_pathways = Counter()
for hid in hit_ids:
met = metabolite_index.get(hid)
if met is None:
continue
for pw in met.findall('hmdb:pathways/hmdb:pathway', NS):
pw_name = pw.find('hmdb:name', NS)
if pw_name is not None and pw_name.text:
hit_pathways[pw_name.text] += 1
enriched = [(pw, count) for pw, count in hit_pathways.most_common() if count >= 2]
df = pd.DataFrame(enriched, columns=['Pathway', 'Hit_Count'])
print(f"Pathways with 2+ hits from {len(hit_ids)} metabolites:")
print(df.to_string(index=False))
Key Parameters
| Parameter | Function/Endpoint | Default | Description |
|---|---|---|---|
NS (namespace dict) |
All XPath queries | {'hmdb': 'http://www.hmdb.ca'} |
Required for all find/findall calls |
tolerance_da |
MW matching | 0.01 |
Mass tolerance in Daltons for metabolite ID |
biospecimen |
get_concentrations() |
'Blood' |
Filter: Blood, Urine, Cerebrospinal Fluid (CSF), Saliva, etc. |
events |
ET.iterparse() |
('end',) |
Parse events; use ('end',) to fire on closing tags |
| adduct mass | MS identification | varies | 1.00728 [M+H]+, 22.9892 [M+Na]+, -1.00728 [M-H]- |
target_type |
Spectral queries | 'MS' or 'NMR' |
Filter spectra by type string |
Best Practices
- Build an in-memory index on startup: Parse once (60-120s), build dict by HMDB ID + lowercase name. Never re-parse inside a loop
- Always pass the namespace dict: Every
find()/findall()needsNS = {'hmdb': 'http://www.hmdb.ca'}. Omitting it returns empty results - Use iterparse for memory constraints: Full XML requires ~8-12 GB RAM. Use
elem.clear()in iterparse to process incrementally - Guard against None and empty text: Many optional fields are absent. Always check
el is not None and el.textbefore accessing - Use monoisotopic weight for MS matching:
monisotopic_molecular_weightis correct for mass spec;average_molecular_weightfor other calculations - Pre-filter by chemical class for large searches: Use taxonomy/classification to narrow searches before iterating all 220K+ entries
Common Recipes
Recipe: Export All Metabolite Properties to CSV
When to use: Create a flat table of all metabolites with key properties.
import pandas as pd
records = []
for met in root.findall('hmdb:metabolite', NS):
def txt(p):
el = met.find(p, NS)
return el.text if el is not None and el.text else None
records.append({'hmdb_id': txt('hmdb:accession'), 'name': txt('hmdb:name'),
'formula': txt('hmdb:chemical_formula'),
'avg_mw': txt('hmdb:average_molecular_weight'),
'smiles': txt('hmdb:smiles')})
pd.DataFrame(records).to_csv('hmdb_properties.csv', index=False)
print(f"Exported {len(records)} metabolites")
Recipe: Find Metabolites by Biofluid
When to use: Get all metabolites detected in a specific biofluid (e.g., urine for clinical screening).
biofluid = 'Urine'
urine_mets = []
for met in root.findall('hmdb:metabolite', NS):
for bf in met.findall(
'hmdb:biological_properties/hmdb:biospecimen_locations/hmdb:biospecimen', NS):
if bf.text and bf.text == biofluid:
urine_mets.append({
'hmdb_id': met.find('hmdb:accession', NS).text,
'name': met.find('hmdb:name', NS).text,
})
break
print(f"Metabolites in {biofluid}: {len(urine_mets)}")
Recipe: Chemical Class Distribution
When to use: Summarize metabolite chemical classes for a hit list or the full database.
from collections import Counter
class_counts = Counter()
for met in root.findall('hmdb:metabolite', NS):
tax = met.find('hmdb:taxonomy', NS)
if tax is not None:
sc = tax.find('hmdb:super_class', NS)
if sc is not None and sc.text:
class_counts[sc.text] += 1
print(dict(class_counts.most_common(10)))
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
find() returns None for known elements |
Missing XML namespace | Always pass NS = {'hmdb': 'http://www.hmdb.ca'} |
MemoryError parsing full XML |
~8-12 GB needed in memory | Use ET.iterparse() with elem.clear() |
| Slow startup (>120s) | Parsing 6 GB XML | Parse once, build index dict; avoid re-parsing |
| Metabolite not found by name | Case sensitivity or synonym | Normalize to lowercase; try HMDB ID directly |
| Empty spectra for a metabolite | Not all entries have spectra (~10-15% coverage) | Check coverage table; use METLIN or MassBank for more spectra |
| Missing concentration data | Limited to well-studied clinical metabolites (~20%) | Cross-reference with MetaboAnalyst or literature |
| Duplicate entries for same compound | Secondary accessions (HMDB00XXXXX vs HMDB0000XXXX) | Use accession (primary), not secondary_accessions |
ET.iterparse missing data |
Premature elem.clear() |
Only clear after extracting all needed fields from the element |
Bundled Resources
Self-contained entry. The original reference file hmdb_data_fields.md (268 lines, field catalog with XML element names and descriptions) is consolidated into the Key Concepts "HMDB XML Entry Structure" table and the "Data Field Completeness" table above. The field catalog's per-element XML tag names are demonstrated in Core API code blocks. Omitted from original: web interface descriptions (not programmatic).
Related Skills
- drugbank-database-access -- Drug-specific data (interactions, targets, pharmacology) with similar local XML parsing pattern
- pubchem-compound-search -- Live compound property lookups without downloading; PubChemPy REST API
- kegg-database -- Pathway and compound database with REST API for cross-referencing HMDB pathway hits
- matchms-spectral-matching -- Spectral similarity matching against reference libraries; complementary to HMDB spectral data
- pyopenms-mass-spectrometry -- Full LC-MS/MS processing pipeline; use HMDB for metabolite identification step
References
- HMDB website and downloads: https://hmdb.ca/
- Wishart DS et al. (2022). HMDB 5.0: the Human Metabolome Database for 2022. Nucleic Acids Res. 50(D1):D801-D816. https://doi.org/10.1093/nar/gkab1062
- HMDB data download page: https://hmdb.ca/downloads
- MetaboAnalyst (complementary tool): https://www.metaboanalyst.ca/
skills/proteomics-protein-engineering/interpro-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill interpro-database -g -y
SKILL.md
Frontmatter
{
"name": "interpro-database",
"license": "CC-BY-4.0",
"description": "Query InterPro REST API for protein domain architecture, family classification, and member-DB integration. Search entries, retrieve a protein's domains, list family members, get taxonomic distribution, link to PDB. Unifies Pfam, PANTHER, PIRSF, PRINTS, PROSITE, SMART, CDD, NCBIfam. Use uniprot-protein-database for sequences; pdb-database for 3D structures."
}
InterPro Database
Overview
InterPro is the EBI's integrated protein family, domain, and functional site database. It consolidates signatures from 13 member databases (Pfam, PANTHER, PIRSF, PRINTS, PROSITE, SMART, CDD, NCBIfam, and others) into unified InterPro entries, each describing a homologous superfamily, domain, family, repeat, or conserved site. The REST API at https://www.ebi.ac.uk/interpro/api/ is free and requires no authentication.
When to Use
- Identifying all domains and families present in a protein by UniProt accession (domain architecture)
- Searching for proteins that contain a specific domain or belong to a specific family
- Finding the taxonomic distribution of organisms that encode a given domain or family
- Cross-linking a domain to experimental 3D structures in the PDB
- Checking which source databases (Pfam, PANTHER, SMART, etc.) cover an InterPro entry
- Discovering InterPro entries by keyword (e.g., "kinase domain") when you do not yet know the accession
- For protein sequence retrieval, functional annotations (GO, pathways, active sites), and ID mapping use
uniprot-protein-database - For downloading domain-aligned sequences or building HMM profiles use
Pfamdirectly; InterPro is the meta-layer
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: UniProt accessions (e.g.,
P04637) or InterPro accessions (e.g.,IPR011009) - Environment: internet connection; no API key required
- Rate limits: no published hard limit; use
time.sleep(1.0)between requests for batch queries; paginate with?cursor=or?page_size=
pip install requests pandas matplotlib
Quick Start
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def interpro_get(path: str, params: dict = None) -> dict:
"""Send a GET request to the InterPro API and return parsed JSON."""
r = requests.get(
f"{INTERPRO_BASE}/{path}",
params=params,
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
return r.json()
# Get domain architecture for TP53 (P04637)
# Note: `protein/uniprot/{acc}/` returns only {metadata}; the entries-per-protein
# data lives at `entry/interpro/protein/uniprot/{acc}/` and is keyed `results`.
data = interpro_get("entry/interpro/protein/uniprot/P04637/")
entries = data.get("results", [])
print(f"InterPro entries for TP53: {data.get('count')} (this page: {len(entries)})")
for e in entries[:4]:
m = e["metadata"]
print(f" {m['accession']} {m['type']:<25} {m['name']}")
# InterPro entries for TP53: 9
# IPR002117 family p53 tumour suppressor family
# IPR036674 homologous_superfamily p53-like tetramerisation domain superfamily
Core API
Query 1: Entry Search
Search for InterPro entries by name keyword or fetch a specific entry by accession.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def search_entries(query: str, entry_type: str = None,
page_size: int = 20) -> list:
"""Search InterPro entries by keyword; optionally filter by type."""
params = {"search": query, "page_size": page_size}
if entry_type:
params["type"] = entry_type # family, domain, homologous_superfamily, repeat, site
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/",
params=params,
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
return r.json().get("results", [])
hits = search_entries("serine kinase", entry_type="domain")
print(f"InterPro domain entries matching 'serine kinase': {len(hits)}")
for h in hits[:5]:
m = h["metadata"]
print(f" {m['accession']} {m['type']:<10} {m['name']}")
# InterPro domain entries matching 'serine kinase': 8
# IPR000719 domain Protein kinase domain
# IPR008271 domain Serine/threonine/tyrosine kinase, active site
# Fetch a specific InterPro entry by accession
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/IPR000719/",
headers={"Accept": "application/json"},
timeout=30
)
r.raise_for_status()
meta = r.json()["metadata"]
print(f"Accession : {meta['accession']}")
print(f"Name : {meta['name']}")
print(f"Type : {meta['type']}")
print(f"Member DBs : {list(meta.get('member_databases', {}).keys())}")
go_terms = meta.get("go_terms", [])
print(f"GO terms : {[g['identifier'] for g in go_terms[:3]]}")
# Accession : IPR000719
# Name : Protein kinase domain
# Type : domain
# Member DBs : ['pfam', 'smart', 'cdd', 'ncbifam', 'panther']
# GO terms : ['GO:0004672', 'GO:0005524', 'GO:0006468']
Query 2: Protein Domain Architecture
Retrieve all InterPro entries (domains, families, sites) matched in a protein by UniProt accession.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_protein_domain_architecture(uniprot_acc: str) -> dict:
"""Return all InterPro entry matches for a protein. Uses the
`entry/interpro/protein/uniprot/{acc}/` endpoint, which returns
{count, next, previous, results}. Each result has metadata + a
nested `proteins[0].entry_protein_locations` for the per-protein match."""
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/protein/uniprot/{uniprot_acc}/",
headers={"Accept": "application/json"},
timeout=60
)
r.raise_for_status()
return r.json()
data = get_protein_domain_architecture("P04637") # TP53
results = data.get("results", [])
# Pull length/source from the first match's nested protein record
prot0 = results[0]["proteins"][0] if results and results[0].get("proteins") else {}
print(f"Protein length : {prot0.get('protein_length')}")
print(f"Source DB : {prot0.get('source_database')}")
print(f"InterPro entries: {data.get('count')}")
for entry in results[:6]:
m = entry["metadata"]
# Locations are nested under proteins[0].entry_protein_locations
locs = entry["proteins"][0].get("entry_protein_locations", []) if entry.get("proteins") else []
loc_str = ", ".join(
f"{frag['start']}-{frag['end']}"
for loc in locs for frag in loc.get("fragments", [])
)
print(f" {m['accession']} {m['type']:<25} {m['name'][:35]:<35} [{loc_str}]")
# Compare domain architectures of two proteins side-by-side
import pandas as pd
def domain_set(uniprot_acc: str) -> set:
data = get_protein_domain_architecture(uniprot_acc)
return {e["metadata"]["accession"] for e in data.get("results", [])}
brca1_domains = domain_set("P38398") # BRCA1
tp53_domains = domain_set("P04637") # TP53
shared = brca1_domains & tp53_domains
unique_brca1 = brca1_domains - tp53_domains
unique_tp53 = tp53_domains - brca1_domains
print(f"Shared InterPro entries: {len(shared)}")
print(f"BRCA1-unique : {len(unique_brca1)}")
print(f"TP53-unique : {len(unique_tp53)}")
Query 3: Entry Proteins
List proteins that contain a specific InterPro entry (family or domain).
import requests, time
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_entry_proteins(interpro_acc: str, reviewed_only: bool = True,
page_size: int = 50) -> list:
"""Return proteins (UniProt) containing a given InterPro entry.
Path order is `/protein/{db}/entry/interpro/{IPR}/` — the inverse
`entry/interpro/{IPR}/protein/{db}/` times out (408) on large families."""
db = "reviewed" if reviewed_only else "uniprot"
r = requests.get(
f"{INTERPRO_BASE}/protein/{db}/entry/interpro/{interpro_acc}/",
params={"page_size": page_size},
headers={"Accept": "application/json"},
timeout=60
)
r.raise_for_status()
return r.json().get("results", [])
proteins = get_entry_proteins("IPR011009") # Protein kinase-like domain SF
print(f"Reviewed proteins with IPR011009 (page 1): {len(proteins)}")
for p in proteins[:4]:
m = p["metadata"]
# metadata fields: accession, gene, length, name, source_database, source_organism
print(f" {m['accession']} {(m.get('gene') or ''):<8} "
f"len={m.get('length', '?')} "
f"org={(m.get('source_organism') or {}).get('scientificName', '')[:30]}")
# Paginate all proteins for a family using cursor
def get_all_entry_proteins(interpro_acc: str,
reviewed_only: bool = True) -> list:
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
db = "reviewed" if reviewed_only else "uniprot"
# Path-inverted: /protein/{db}/entry/interpro/{IPR}/ is the working order
url = f"{INTERPRO_BASE}/protein/{db}/entry/interpro/{interpro_acc}/"
all_proteins = []
params = {"page_size": 200}
while url:
r = requests.get(url, params=params,
headers={"Accept": "application/json"}, timeout=60)
r.raise_for_status()
data = r.json()
all_proteins.extend(data.get("results", []))
url = data.get("next")
params = None # next URL already has params encoded
if url:
time.sleep(1.0)
return all_proteins
proteins = get_all_entry_proteins("IPR000719") # Protein kinase domain
print(f"Total reviewed proteins with protein kinase domain: {len(proteins)}")
Query 4: Entry Taxonomy
Get the taxonomic distribution of proteins annotated with a given InterPro entry.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_entry_taxonomy(interpro_acc: str,
page_size: int = 50) -> list:
"""Return taxonomic summary for proteins in a given InterPro entry.
Path-inverted: `/taxonomy/uniprot/entry/interpro/{IPR}/`. Each result
has `metadata` (taxon: accession=taxId, name, parent, children, rank)
and `entries[]` (representative protein-match locations for that taxon)."""
r = requests.get(
f"{INTERPRO_BASE}/taxonomy/uniprot/entry/interpro/{interpro_acc}/",
params={"page_size": page_size},
headers={"Accept": "application/json"},
timeout=90
)
r.raise_for_status()
return r.json().get("results", [])
# Use a smaller entry (p53 DBD); IPR000719 (kinase) has ~270k taxa and times out.
taxa = get_entry_taxonomy("IPR011615")
print(f"Top taxa for IPR011615 (p53 DNA-binding domain):")
for t in taxa[:8]:
m = t["metadata"]
print(f" taxId={m['accession']:>10} {m.get('name', ''):<30} "
f"rank={m.get('rank') or 'n/a'}")
Query 5: Structure Integration
Retrieve PDB structures associated with an InterPro entry.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_entry_structures(interpro_acc: str, page_size: int = 25) -> list:
"""Return PDB structures that include a match to a given InterPro entry.
Path-inverted: `/structure/pdb/entry/interpro/{IPR}/`. The flat form with
`?entry_interpro=...` is silently slow / 408s on this resource."""
r = requests.get(
f"{INTERPRO_BASE}/structure/pdb/entry/interpro/{interpro_acc}/",
params={"page_size": page_size},
headers={"Accept": "application/json"},
timeout=60
)
r.raise_for_status()
return r.json().get("results", [])
structures = get_entry_structures("IPR011009") # Protein kinase-like SF
print(f"PDB structures linked to IPR011009 (page 1): {len(structures)}")
for s in structures[:5]:
m = s["metadata"]
print(f" {m['accession'].upper()} resolution={m.get('resolution', 'N/A')} Å "
f"experiment={m.get('experiment_type', 'N/A')}")
# PDB structures linked to IPR011009: ~8,000+
# 1A06 resolution=2.5 Å experiment=x-ray
# ...
Query 6: Domain Sequence Retrieval
Download the FASTA sequences of proteins in an InterPro family for alignment or phylogenetics.
import requests, time
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_family_fasta(interpro_acc: str,
reviewed_only: bool = True,
max_sequences: int = 100) -> str:
"""Retrieve FASTA sequences for proteins in an InterPro entry."""
db = "reviewed" if reviewed_only else "uniprot"
proteins = []
# Path-inverted: protein-list-for-entry is /protein/{db}/entry/interpro/{IPR}/
url = f"{INTERPRO_BASE}/protein/{db}/entry/interpro/{interpro_acc}/"
params = {"page_size": min(max_sequences, 200)}
while url and len(proteins) < max_sequences:
r = requests.get(url, params=params,
headers={"Accept": "application/json"}, timeout=60)
r.raise_for_status()
data = r.json()
proteins.extend(data.get("results", []))
url = data.get("next") if len(proteins) < max_sequences else None
params = None
if url:
time.sleep(1.0)
# Fetch FASTA from UniProt for each accession
accessions = [p["metadata"]["accession"] for p in proteins[:max_sequences]]
fasta_url = "https://rest.uniprot.org/uniprotkb/stream"
query = " OR ".join(f"accession:{acc}" for acc in accessions)
r = requests.get(fasta_url,
params={"query": query, "format": "fasta"},
timeout=120)
r.raise_for_status()
return r.text
fasta = get_family_fasta("IPR000719", reviewed_only=True, max_sequences=20)
seq_count = fasta.count(">")
print(f"FASTA sequences retrieved: {seq_count}")
print(fasta[:300]) # preview first sequence header + start
Key Concepts
InterPro Entry Types
InterPro classifies entries into five types. The type determines what biological relationship the match implies:
| Type | Description | Example |
|---|---|---|
family |
Homologous group of proteins sharing common ancestry and function | IPR000719 (Protein kinase) |
domain |
Discrete structural and functional unit that can occur in multiple protein contexts | IPR011009 (Protein kinase-like SF) |
homologous_superfamily |
Structurally similar domains that may have diverged in sequence | IPR011993 (Pleckstrin-like) |
repeat |
Short, repeated sequence unit that occurs multiple times within a protein | IPR001440 (TPR repeat) |
site |
Short conserved motif: active site, binding site, or post-translational modification site | IPR008271 (Ser/Thr kinase active site) |
Member Database Hierarchy
Each InterPro entry integrates signatures from one or more member databases. The InterPro accession (IPR...) is the unified meta-entry; member database accessions point to the underlying models:
| Member DB | Accession prefix | Modeling approach |
|---|---|---|
| Pfam | PF | Hidden Markov Models (profile HMMs) |
| PANTHER | PTHR | Phylogenetic trees + HMMs |
| PIRSF | PIRSF | Full-length HMMs |
| PRINTS | PR | Fingerprint motif groups |
| PROSITE | PS | Patterns and profiles |
| SMART | SM | HMMs with database integration |
| CDD | cd | Position-specific scoring matrices (PSSMs) |
| NCBIfam | NF | NCBI-curated HMMs |
Pagination
The InterPro API paginates results at the collection level. Each response includes a next URL (or null when exhausted) and a count field. For large families (e.g., kinases: 10,000+ proteins) always iterate using the next cursor.
import requests, time
def iterate_interpro(url: str, page_size: int = 200) -> list:
"""Generic paginator for any InterPro list endpoint."""
results = []
params = {"page_size": page_size}
while url:
r = requests.get(url, params=params,
headers={"Accept": "application/json"}, timeout=60)
r.raise_for_status()
data = r.json()
results.extend(data.get("results", []))
url = data.get("next")
params = None
if url:
time.sleep(1.0)
return results
Common Workflows
Workflow 1: Domain Architecture Report for a Protein Set
Goal: Retrieve all InterPro domains for a list of proteins and produce a summary table showing which domains each protein carries.
import requests, time, pandas as pd
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_domains(uniprot_acc: str) -> list:
"""List InterPro entries for a protein. Uses the
`entry/interpro/protein/uniprot/{acc}/` endpoint (keyed `results`)."""
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/protein/uniprot/{uniprot_acc}/",
headers={"Accept": "application/json"}, timeout=60
)
if r.status_code == 404:
return []
r.raise_for_status()
data = r.json()
return [
{
"protein": uniprot_acc,
"accession": e["metadata"]["accession"],
"name": e["metadata"]["name"],
"type": e["metadata"]["type"],
"source_db": list(e["metadata"].get("member_databases", {}).keys()),
}
for e in data.get("results", [])
]
proteins = ["P04637", "P38398", "Q00987", "P10415"] # TP53, BRCA1, MDM2, BCL2
rows = []
for acc in proteins:
rows.extend(get_domains(acc))
time.sleep(1.0)
df = pd.DataFrame(rows)
print(f"Total domain matches: {len(df)}")
print(df.groupby(["protein", "type"])["accession"].count().unstack(fill_value=0))
# Pivot: proteins × domain accessions
pivot = df[df["type"] == "domain"].pivot_table(
index="protein", columns="accession", aggfunc="size", fill_value=0
)
pivot.to_csv("domain_architecture_matrix.csv")
print(f"\nDomain × protein matrix: {pivot.shape}")
Workflow 2: Find Kinase Family Members with PDB Structures
Goal: Retrieve proteins in a kinase domain family that have experimental structures in the PDB, ranked by resolution.
import requests, time, pandas as pd
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
# Step 1: Get PDB structures linked to the protein kinase-like SF entry.
# Use the path-inverted form; the flat `?entry_interpro=` filter 408s.
r = requests.get(
f"{INTERPRO_BASE}/structure/pdb/entry/interpro/IPR011009/",
params={"page_size": 200},
headers={"Accept": "application/json"}, timeout=60
)
r.raise_for_status()
structures = r.json().get("results", [])
print(f"PDB structures with IPR011009 (kinase-like SF, page 1): {len(structures)}")
rows = []
for s in structures:
m = s["metadata"]
rows.append({
"pdb_id": m["accession"],
"resolution": m.get("resolution"),
"experiment": m.get("experiment_type", ""),
"name": m.get("name", ""),
})
df = pd.DataFrame(rows)
df = df.dropna(subset=["resolution"]).sort_values("resolution")
print(f"\nTop 10 highest-resolution kinase structures:")
print(df[["pdb_id", "resolution", "experiment", "name"]].head(10).to_string(index=False))
df.to_csv("kinase_structures.csv", index=False)
print(f"\nSaved kinase_structures.csv ({len(df)} X-ray / cryo-EM structures)")
Workflow 3: Taxonomic Coverage Bar Chart for a Domain
Goal: Visualize how many reviewed proteins in each major kingdom carry a given InterPro domain.
import requests, time
import pandas as pd
import matplotlib.pyplot as plt
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_taxonomy_counts(interpro_acc: str, page_size: int = 100,
max_pages: int = 5) -> pd.DataFrame:
"""Walk the taxonomy results for an InterPro entry. The API does not
expose a per-taxon protein count at this endpoint — instead each
paged record is one (taxon × representative protein-match) row.
Aggregate client-side by taxon name to approximate frequency."""
rows, url = [], f"{INTERPRO_BASE}/taxonomy/uniprot/entry/interpro/{interpro_acc}/"
params = {"page_size": page_size}
for _ in range(max_pages):
if not url:
break
r = requests.get(url, params=params,
headers={"Accept": "application/json"}, timeout=90)
r.raise_for_status()
data = r.json()
for t in data.get("results", []):
m = t["metadata"]
rows.append({
"taxon_id": m["accession"],
"name": m.get("name", ""),
"rank": m.get("rank") or "",
})
url = data.get("next")
params = None
if url:
time.sleep(1.0)
return pd.DataFrame(rows)
IPR_ACC = "IPR011615" # p53 DNA-binding domain (smaller; kinase 408s)
df = get_taxonomy_counts(IPR_ACC, max_pages=3)
print(f"Tax entries pulled for {IPR_ACC}: {len(df)}")
# Aggregate by name and take top 15 (each row = one rep. protein-match)
top = (df.groupby("name").size().sort_values(ascending=False).head(15)
.reset_index(name="rep_matches"))
fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(top["name"], top["rep_matches"], color="#2171B5")
ax.bar_label(bars, fmt="%d", padding=3, fontsize=8)
ax.set_xlabel("Representative protein-matches")
ax.set_title(f"Taxonomic distribution of {IPR_ACC} (p53 DNA-binding domain)")
ax.invert_yaxis()
plt.tight_layout()
plt.savefig(f"{IPR_ACC}_taxonomy.png", dpi=150, bbox_inches="tight")
print(f"Saved {IPR_ACC}_taxonomy.png")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
search |
entry/interpro/ |
— | free-text string | Keyword filter on entry name and short name |
type |
entry/interpro/ |
all types | family, domain, homologous_superfamily, repeat, site |
Filter entries by InterPro type |
page_size |
all list endpoints | 20 |
1–200 |
Results returned per page |
entry_interpro |
structure/pdb/ |
— | IPR###### |
Filter structures by linked InterPro entry |
source_database |
protein/ |
— | reviewed, uniprot, trembl |
Filter proteins by UniProt curation level |
reviewed (URL path) |
entry/{ipr}/{acc}/protein/ |
uniprot | reviewed, uniprot |
Swiss-Prot reviewed only vs all UniProtKB |
relations |
entry/interpro/{acc}/ |
— | contains, contained_by, child_of, parent_of |
Navigate the InterPro hierarchy |
next |
all list endpoints | — | URL from response | Cursor-based pagination; use the full URL from the next field |
Best Practices
-
Use
reviewedproteins for curated domain lists: The unreviewed TrEMBL set is 5–10× larger and contains automated predictions. For benchmarking, family analysis, or training sets, restrict toreviewed(Swiss-Prot) entries to avoid noise from unreviewed predictions. -
Chunk large taxonomy or protein lists: Retrieving all 10,000+ proteins for a broad family like the protein kinase superfamily can take minutes and produce large payloads. Limit queries with
page_size=200and thenextcursor; store intermediate results to disk. -
Add
time.sleep(1.0)between paginated calls: The InterPro API is shared EBI infrastructure with no published rate limit. A 1-second pause per page is a safe minimum for batch scripts. -
Prefer InterPro accessions over member DB accessions for cross-database queries: A Pfam PF00069 and PANTHER PTHR24340 both model kinase domains but with different protein coverage. Using the parent InterPro
IPR000719gives the union of all member DB matches in one query. -
Check
typebefore interpretingentry_protein_locations: Onlydomain,repeat, andsiteentries carry meaningful position information.familyandhomologous_superfamilyentries typically span the full protein and their coordinates are less informative.
Common Recipes
Recipe: Quick Domain Check for a Protein
When to use: Given a UniProt accession, rapidly list which InterPro domains it contains.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def list_protein_domains(uniprot_acc: str) -> list:
"""Return list of (accession, type, name) tuples for a protein."""
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/protein/uniprot/{uniprot_acc}/",
headers={"Accept": "application/json"}, timeout=60
)
r.raise_for_status()
return [
(e["metadata"]["accession"], e["metadata"]["type"], e["metadata"]["name"])
for e in r.json().get("results", [])
]
domains = list_protein_domains("P00533") # EGFR
print(f"InterPro entries in EGFR (P00533): {len(domains)}")
for acc, etype, name in domains:
print(f" {acc} {etype:<25} {name}")
# InterPro entries in EGFR (P00533): 10
# IPR009030 homologous_superfamily Growth factor receptor, cysteine-rich
# IPR000719 domain Protein kinase domain
Recipe: Find All Proteins in a Family with Source DB Coverage
When to use: Map how many proteins in a domain family are covered by each member database (Pfam vs PANTHER vs SMART, etc.).
import requests, time
import pandas as pd
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
interpro_acc = "IPR000719" # Protein kinase domain
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/{interpro_acc}/",
headers={"Accept": "application/json"}, timeout=30
)
r.raise_for_status()
member_dbs = r.json()["metadata"].get("member_databases", {})
print(f"Member databases for {interpro_acc}:")
for db, details in member_dbs.items():
print(f" {db}: {details}")
# Visualize member database source breakdown
labels = list(member_dbs.keys())
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(labels, [1] * len(labels), color="#4472C4") # presence/absence per DB
ax.set_ylabel("Integrated (1=yes)")
ax.set_title(f"Member databases in {interpro_acc}")
plt.tight_layout()
plt.savefig(f"{interpro_acc}_member_dbs.png", dpi=150, bbox_inches="tight")
Recipe: Get GO Terms for an InterPro Entry
When to use: Bridge from structural domain to functional GO annotation.
import requests
INTERPRO_BASE = "https://www.ebi.ac.uk/interpro/api"
def get_go_terms_for_entry(interpro_acc: str) -> list:
"""Return GO terms associated with an InterPro entry."""
r = requests.get(
f"{INTERPRO_BASE}/entry/interpro/{interpro_acc}/",
headers={"Accept": "application/json"}, timeout=30
)
r.raise_for_status()
go_terms = r.json()["metadata"].get("go_terms", [])
return [
{"id": g["identifier"], "name": g["name"],
"category": g.get("category", {}).get("name", "")}
for g in go_terms
]
go_terms = get_go_terms_for_entry("IPR000719")
print(f"GO terms for IPR000719 (protein kinase domain): {len(go_terms)}")
for g in go_terms:
print(f" {g['id']} [{g['category'][:2].upper()}] {g['name']}")
# GO terms for IPR000719 (protein kinase domain): 3
# GO:0004672 [MO] protein kinase activity
# GO:0005524 [MO] ATP binding
# GO:0006468 [BI] protein phosphorylation
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 on protein lookup |
Accession not found in InterPro | Verify the UniProt accession exists; isoform accessions (P12345-2) may not be indexed separately |
| Empty entries list for a protein | Protein has no InterPro matches (e.g., intrinsically disordered) | Check UniProt directly; not all proteins have classified domains |
protein/uniprot/{acc}/ returns only metadata (no entries) |
That endpoint is protein-only; entry matches live elsewhere | Use entry/interpro/protein/uniprot/{acc}/ and read the results[] key |
entry/interpro/{IPR}/protein/{db}/ returns 408 / hangs |
The path with entry/... first does a slow join |
Invert the path: protein/{db}/entry/interpro/{IPR}/ |
structure/pdb/?entry_interpro={IPR} times out (408) |
Same join order issue | Use structure/pdb/entry/interpro/{IPR}/ |
entry/interpro/{IPR}/taxonomy/uniprot/ 408s for large families |
Same | Use taxonomy/uniprot/entry/interpro/{IPR}/; for very large entries (e.g. IPR000719 kinase) the inverted form may still 408 — fall back to a more specific sub-family entry |
HTTP 400 on entry search |
Invalid query parameters or unsupported type value |
Use one of: family, domain, homologous_superfamily, repeat, site |
| Pagination stops early | next is null before expected count |
This is correct; all results have been returned |
| Very slow response for large families | Protein set has thousands of members | Increase page_size to 200; persist results after each page |
ConnectionError or Timeout |
Transient network or server issue | Retry with exponential backoff; EBI services occasionally have brief downtimes |
| Member DB accessions missing | Entry is new and member DB integration is pending | Use the InterPro accession for queries; member DB-level details update with each release |
Related Skills
uniprot-protein-database— UniProt REST API for protein sequences, Swiss-Prot functional annotations (active sites, PTMs, disease associations), and ID mappingesm-protein-language-model— Generate protein language model embeddings for sequences; useful after identifying a protein family with InterPropdb-database— Retrieve and download experimental 3D structures by PDB ID; cross-reference structure IDs discovered via InterPro structure queries
References
- InterPro REST API documentation — Endpoint reference, filters, and example queries
- Blum et al., Nucleic Acids Research 2021 — InterPro flagship paper describing member database integration
- InterPro web portal — Interactive protein domain browser
- Paysan-Lafosse et al., Nucleic Acids Research 2023 — InterPro 2023 update describing new entry types and member databases
skills/proteomics-protein-engineering/matchms-spectral-matching/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill matchms-spectral-matching -g -y
SKILL.md
Frontmatter
{
"name": "matchms-spectral-matching",
"license": "Apache-2.0",
"description": "MS spectral matching and metabolite ID with matchms. Import spectra (mzML, MGF, MSP, JSON), filter\/normalize peaks, score similarity (cosine, modified cosine, fingerprint), build reproducible pipelines, identify unknowns vs spectral libraries. Use pyopenms for full LC-MS\/MS proteomics."
}
Matchms — Spectral Matching & Metabolite Identification
Overview
Matchms is a Python library for mass spectrometry data processing focused on spectral similarity calculation and compound identification. It provides multi-format I/O, 50+ spectrum filters for metadata harmonization and peak processing, 8 similarity scoring functions, and a pipeline framework for reproducible analytical workflows.
When to Use
- Identifying unknown metabolites by matching MS/MS spectra against reference libraries
- Computing spectral similarity scores (cosine, modified cosine, fingerprint-based)
- Processing and standardizing mass spectral data from multiple formats (mzML, MGF, MSP, JSON)
- Building reproducible spectral processing pipelines for quality control
- Harmonizing metadata across spectral databases (compound names, SMILES, InChI, adducts)
- Large-scale spectral library comparisons and duplicate detection
- For full LC-MS/MS proteomics workflows (feature detection, protein ID), use pyopenms instead
- For chemical structure similarity without mass spectra, use rdkit fingerprint comparison
Prerequisites
uv pip install matchms numpy pandas
# For chemical structure processing (SMILES, InChI, fingerprints):
uv pip install matchms[chemistry]
- Python 3.8+; NumPy for peak array operations
- Input: spectral data in MGF, MSP, mzML, mzXML, JSON, or pickle format
- Reference library in any supported format for matching
Quick Start
from matchms.importing import load_from_mgf
from matchms.filtering import default_filters, normalize_intensities
from matchms.filtering import select_by_relative_intensity, require_minimum_number_of_peaks
from matchms import calculate_scores
from matchms.similarity import CosineGreedy
# Load and process query spectra
queries = list(load_from_mgf("queries.mgf"))
queries = [default_filters(s) for s in queries]
queries = [normalize_intensities(s) for s in queries if s is not None]
queries = [require_minimum_number_of_peaks(s, n_required=5) for s in queries if s is not None]
# Load reference library
refs = list(load_from_mgf("library.mgf"))
refs = [default_filters(s) for s in refs]
refs = [normalize_intensities(s) for s in refs if s is not None]
# Calculate similarity scores
scores = calculate_scores(references=refs, queries=queries,
similarity_function=CosineGreedy(tolerance=0.1))
# Get best matches for first query
best = scores.scores_by_query(queries[0], sort=True)[:5]
for match, score_tuple in best:
print(f"Score: {score_tuple['score']:.3f}, Matches: {score_tuple['matches']}")
Core API
Module 1: Spectrum I/O
Import spectra from multiple file formats and export processed data.
from matchms.importing import (load_from_mgf, load_from_mzml, load_from_msp,
load_from_json, load_from_mzxml, load_from_pickle,
load_from_usi)
from matchms.exporting import save_as_mgf, save_as_msp, save_as_json, save_as_pickle
# Import from various formats (returns generators)
spectra_mgf = list(load_from_mgf("library.mgf"))
spectra_mzml = list(load_from_mzml("data.mzML"))
spectra_msp = list(load_from_msp("nist_library.msp"))
spectra_json = list(load_from_json("gnps_spectra.json"))
print(f"Loaded: MGF={len(spectra_mgf)}, mzML={len(spectra_mzml)}")
# Export processed spectra
save_as_mgf(spectra_mgf, "processed.mgf")
save_as_json(spectra_mgf, "processed.json")
save_as_pickle(spectra_mgf, "spectra.pickle") # Fast for intermediate results
# Pickle for large datasets (fastest I/O)
from matchms.importing import load_from_pickle
spectra = list(load_from_pickle("spectra.pickle"))
from matchms import Spectrum
import numpy as np
# Create spectrum manually
mz = np.array([100.0, 150.0, 200.0, 250.0, 300.0])
intensities = np.array([0.1, 0.5, 0.9, 0.3, 0.7])
metadata = {
"precursor_mz": 325.5,
"ionmode": "positive",
"compound_name": "Caffeine",
"smiles": "CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
}
spectrum = Spectrum(mz=mz, intensities=intensities, metadata=metadata)
# Access spectrum data
print(f"Peaks: {spectrum.peaks.mz}")
print(f"Precursor: {spectrum.get('precursor_mz')}")
print(f"Name: {spectrum.get('compound_name')}")
# Visualize
spectrum.plot()
Module 2: Spectrum Filtering & Processing
Apply metadata harmonization and peak processing filters. Matchms provides 50+ filters.
from matchms.filtering import (
default_filters, normalize_intensities,
select_by_relative_intensity, select_by_mz,
require_minimum_number_of_peaks, reduce_to_number_of_peaks,
remove_peaks_around_precursor_mz, add_losses
)
# default_filters applies: metadata cleanup, charge correction, adduct parsing
spectrum = default_filters(spectrum)
# Peak normalization (max intensity → 1.0)
spectrum = normalize_intensities(spectrum)
# Filter peaks by relative intensity (remove noise below 1%)
spectrum = select_by_relative_intensity(spectrum, intensity_from=0.01, intensity_to=1.0)
# Filter peaks by m/z range
spectrum = select_by_mz(spectrum, mz_from=50.0, mz_to=500.0)
# Keep top N peaks only
spectrum = reduce_to_number_of_peaks(spectrum, n_max=50)
# Remove peaks near precursor (common contaminants)
spectrum = remove_peaks_around_precursor_mz(spectrum, mz_tolerance=17.0)
# Require minimum peaks for matching
spectrum = require_minimum_number_of_peaks(spectrum, n_required=5)
# Add neutral losses (useful for NeutralLossesCosine)
spectrum = add_losses(spectrum)
if spectrum is not None:
print(f"After filtering: {len(spectrum.peaks.mz)} peaks")
# Chemical annotation filters (require matchms[chemistry])
from matchms.filtering import (
derive_inchi_from_smiles, derive_inchikey_from_inchi,
derive_smiles_from_inchi, add_fingerprint,
repair_inchi_inchikey_smiles, require_valid_annotation
)
# Derive chemical identifiers from SMILES
spectrum = derive_inchi_from_smiles(spectrum)
spectrum = derive_inchikey_from_inchi(spectrum)
# Add molecular fingerprint for structural similarity
spectrum = add_fingerprint(spectrum, fingerprint_type="morgan", nbits=2048)
# Validate annotations
spectrum = require_valid_annotation(spectrum)
if spectrum is not None:
print(f"InChIKey: {spectrum.get('inchikey')}")
Module 3: Similarity Scoring
Compare spectra using multiple similarity metrics.
from matchms import calculate_scores
from matchms.similarity import (
CosineGreedy, CosineHungarian, ModifiedCosine,
NeutralLossesCosine, FingerprintSimilarity,
MetadataMatch, PrecursorMzMatch
)
# CosineGreedy — fast peak matching (greedy algorithm)
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=CosineGreedy(tolerance=0.1))
# ModifiedCosine — accounts for precursor mass differences (best for analog search)
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=ModifiedCosine(tolerance=0.1))
# CosineHungarian — optimal peak matching (slower but more accurate)
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=CosineHungarian(tolerance=0.1))
# NeutralLossesCosine — similarity based on neutral loss patterns
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=NeutralLossesCosine(tolerance=0.1))
# Access results
for i, query in enumerate(unknowns[:3]):
best_matches = scores.scores_by_query(query, sort=True)[:3]
print(f"\nQuery {i}: precursor_mz={query.get('precursor_mz')}")
for ref, score_tuple in best_matches:
print(f" {ref.get('compound_name', 'Unknown')}: "
f"score={score_tuple['score']:.3f}, matches={score_tuple['matches']}")
# FingerprintSimilarity — structural similarity (requires fingerprints)
from matchms.similarity import FingerprintSimilarity
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=FingerprintSimilarity(
similarity_measure="jaccard"))
# PrecursorMzMatch — fast mass-based pre-filtering
from matchms.similarity import PrecursorMzMatch
scores = calculate_scores(references=library, queries=unknowns,
similarity_function=PrecursorMzMatch(tolerance=0.1))
# Multi-metric scoring: combine peak + structural similarity
cosine_scores = calculate_scores(references=library, queries=unknowns,
similarity_function=CosineGreedy(tolerance=0.1))
fp_scores = calculate_scores(references=library, queries=unknowns,
similarity_function=FingerprintSimilarity())
Module 4: Processing Pipelines
Build reusable, reproducible multi-step processing workflows.
from matchms import SpectrumProcessor
from matchms.filtering import (
default_filters, normalize_intensities,
select_by_relative_intensity, require_minimum_number_of_peaks,
remove_peaks_around_precursor_mz, add_losses
)
# Define reusable pipeline
pipeline = SpectrumProcessor([
default_filters,
normalize_intensities,
lambda s: select_by_relative_intensity(s, intensity_from=0.01),
lambda s: remove_peaks_around_precursor_mz(s, mz_tolerance=17.0),
lambda s: require_minimum_number_of_peaks(s, n_required=5),
add_losses
])
# Apply to all spectra (filters returning None remove the spectrum)
processed = [pipeline(s) for s in raw_spectra]
processed = [s for s in processed if s is not None]
print(f"Processed: {len(processed)}/{len(raw_spectra)} spectra retained")
Key Concepts
Similarity Function Comparison
| Function | Speed | Accuracy | Best For |
|---|---|---|---|
CosineGreedy |
Fast | Good | General library matching |
CosineHungarian |
Slow | Best | Small comparisons, validation |
ModifiedCosine |
Fast | Good | Analog search (different precursors) |
NeutralLossesCosine |
Medium | Good | Structural class identification |
FingerprintSimilarity |
Fast | Moderate | Structure-based pre-filtering |
PrecursorMzMatch |
Fastest | N/A | Mass-based pre-filtering |
Filter Categories
| Category | Examples | Purpose |
|---|---|---|
| Metadata cleanup | default_filters, clean_compound_name, clean_adduct |
Standardize metadata fields |
| Chemical derivation | derive_inchi_from_smiles, add_fingerprint |
Compute chemical identifiers |
| Mass/charge | add_precursor_mz, correct_charge, add_parent_mass |
Fix and validate mass info |
| Peak normalization | normalize_intensities, select_by_relative_intensity |
Scale and filter peaks |
| Peak reduction | reduce_to_number_of_peaks, remove_peaks_around_precursor_mz |
Remove noise/artifacts |
| Quality control | require_minimum_number_of_peaks, require_precursor_mz |
Enforce minimum quality |
| Neutral losses | add_losses |
Compute precursor-fragment losses |
Score Tuple Structure
All similarity functions return (score, matches):
score: float 0.0–1.0 (cosine similarity value)matches: int (number of matched peaks between query and reference)
Higher scores and more matched peaks indicate better matches. Typical thresholds: score > 0.7 and matches > 6 for confident identifications.
Common Workflows
Workflow 1: Library Matching for Metabolite Identification
from matchms.importing import load_from_mgf
from matchms.filtering import default_filters, normalize_intensities
from matchms.filtering import select_by_relative_intensity, require_minimum_number_of_peaks
from matchms import calculate_scores
from matchms.similarity import ModifiedCosine
import pandas as pd
# Load and process both queries and library identically
def process_spectra(spectra):
processed = []
for s in spectra:
s = default_filters(s)
if s is None: continue
s = normalize_intensities(s)
s = select_by_relative_intensity(s, intensity_from=0.01)
s = require_minimum_number_of_peaks(s, n_required=5)
if s is not None:
processed.append(s)
return processed
queries = process_spectra(load_from_mgf("unknowns.mgf"))
library = process_spectra(load_from_mgf("reference_library.mgf"))
print(f"Queries: {len(queries)}, Library: {len(library)}")
# Score all query-reference pairs
scores = calculate_scores(references=library, queries=queries,
similarity_function=ModifiedCosine(tolerance=0.1))
# Extract best matches
results = []
for query in queries:
best = scores.scores_by_query(query, sort=True)[:1]
if best:
ref, score_tuple = best[0]
results.append({
"query_precursor_mz": query.get("precursor_mz"),
"match_name": ref.get("compound_name", "Unknown"),
"match_smiles": ref.get("smiles", ""),
"score": score_tuple["score"],
"matched_peaks": score_tuple["matches"]
})
df = pd.DataFrame(results)
confident = df[df["score"] > 0.7]
print(f"Confident matches (score>0.7): {len(confident)}/{len(df)}")
df.to_csv("identification_results.csv", index=False)
Workflow 2: Quality Control and Data Cleaning
from matchms.importing import load_from_msp
from matchms.exporting import save_as_mgf
from matchms import SpectrumProcessor
from matchms.filtering import (
default_filters, normalize_intensities,
select_by_relative_intensity, require_minimum_number_of_peaks,
require_precursor_mz, add_parent_mass
)
# Define QC pipeline
qc_pipeline = SpectrumProcessor([
default_filters,
require_precursor_mz,
add_parent_mass,
normalize_intensities,
lambda s: select_by_relative_intensity(s, intensity_from=0.001),
lambda s: require_minimum_number_of_peaks(s, n_required=3)
])
# Process and filter
raw = list(load_from_msp("raw_library.msp"))
cleaned = [qc_pipeline(s) for s in raw]
cleaned = [s for s in cleaned if s is not None]
print(f"Input: {len(raw)}, Output: {len(cleaned)} ({len(cleaned)/len(raw)*100:.1f}% retained)")
save_as_mgf(cleaned, "cleaned_library.mgf")
Workflow 3: Format Conversion
- Load spectra from source format (Core API Module 1 —
load_from_mzml) - Apply
default_filtersfor metadata harmonization (Core API Module 2) - Export to target format (Core API Module 1 —
save_as_mgf)
Key Parameters
| Parameter | Function/Module | Default | Range/Options | Effect |
|---|---|---|---|---|
tolerance |
CosineGreedy/ModifiedCosine | 0.1 | 0.005–0.5 Da | m/z tolerance for peak matching |
mz_power |
CosineGreedy | 0.0 | 0.0–2.0 | Weight of m/z in scoring (0=ignore) |
intensity_power |
CosineGreedy | 1.0 | 0.0–2.0 | Weight of intensity in scoring |
intensity_from |
select_by_relative_intensity | 0.0 | 0.0–1.0 | Minimum relative intensity to keep |
n_required |
require_minimum_number_of_peaks | 10 | 1–100 | Minimum peaks to retain spectrum |
n_max |
reduce_to_number_of_peaks | 100 | 10–500 | Maximum peaks to retain |
mz_tolerance |
remove_peaks_around_precursor_mz | 17.0 | 0.5–50 Da | Window around precursor to remove |
fingerprint_type |
add_fingerprint | "daylight" | "daylight"/"morgan"/"maccs" | Molecular fingerprint type |
nbits |
add_fingerprint | 2048 | 256–4096 | Fingerprint bit vector length |
Best Practices
- Always process queries and references identically — apply the same filtering pipeline to both sets to avoid systematic bias in similarity scores
- Save intermediate results in pickle — pickle format is fastest for re-loading; use MGF/MSP for sharing with other tools
- Pre-filter by precursor mass — for large libraries, use
PrecursorMzMatchfirst to reduce the comparison space, then score withCosineGreedy - Combine multiple metrics — use both CosineGreedy (peak matching) and FingerprintSimilarity (structure) for more robust identification
- Check for None after filtering — filters return
Nonewhen a spectrum fails quality requirements. Always filter:[s for s in processed if s is not None] - Use ModifiedCosine for analog search — when querying against libraries that may not have the exact compound, ModifiedCosine handles precursor mass differences
Common Recipes
Recipe 1: Precursor-Filtered Library Search (Efficient)
from matchms import calculate_scores
from matchms.similarity import PrecursorMzMatch, CosineGreedy
# Step 1: Fast mass filter
mass_scores = calculate_scores(references=library, queries=unknowns,
similarity_function=PrecursorMzMatch(tolerance=0.5))
# Step 2: Detailed scoring only for mass-matched pairs
cosine = CosineGreedy(tolerance=0.1)
for query in unknowns:
candidates = mass_scores.scores_by_query(query, sort=True)
mass_matched = [ref for ref, score in candidates if score["score"] > 0]
if mass_matched:
detailed = calculate_scores(references=mass_matched, queries=[query],
similarity_function=cosine)
best = detailed.scores_by_query(query, sort=True)[:3]
for ref, s in best:
print(f"{ref.get('compound_name')}: {s['score']:.3f}")
Recipe 2: Ion Mode-Specific Processing
from matchms.importing import load_from_mgf
from matchms.filtering import default_filters, normalize_intensities
spectra = list(load_from_mgf("mixed_library.mgf"))
spectra = [default_filters(s) for s in spectra]
spectra = [s for s in spectra if s is not None]
# Separate by ion mode
positive = [s for s in spectra if s.get("ionmode") == "positive"]
negative = [s for s in spectra if s.get("ionmode") == "negative"]
print(f"Positive: {len(positive)}, Negative: {len(negative)}")
# Process each mode with mode-specific filtering
positive = [normalize_intensities(s) for s in positive]
negative = [normalize_intensities(s) for s in negative]
Recipe 3: Metadata Enrichment Report
import pandas as pd
from matchms.importing import load_from_mgf
from matchms.filtering import default_filters
spectra = [default_filters(s) for s in load_from_mgf("library.mgf")]
spectra = [s for s in spectra if s is not None]
# Extract metadata summary
rows = []
for s in spectra:
rows.append({
"compound_name": s.get("compound_name", ""),
"precursor_mz": s.get("precursor_mz"),
"ionmode": s.get("ionmode", ""),
"smiles": s.get("smiles", ""),
"inchikey": s.get("inchikey", ""),
"num_peaks": len(s.peaks.mz)
})
df = pd.DataFrame(rows)
print(f"Library: {len(df)} spectra")
print(f"Named: {(df.compound_name != '').sum()}")
print(f"With SMILES: {(df.smiles != '').sum()}")
print(f"Ion modes: {df.ionmode.value_counts().to_dict()}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| All scores are 0.0 | No matching peaks within tolerance | Increase tolerance (try 0.2–0.5 Da); verify both spectra have peaks |
| Low scores despite same compound | Different fragmentation conditions | Use ModifiedCosine instead of CosineGreedy; check ion mode consistency |
| Many spectra filtered to None | Too strict quality filters | Lower n_required in require_minimum_number_of_peaks; relax intensity thresholds |
KeyError on metadata field |
Field name not harmonized | Apply default_filters first to harmonize metadata keys |
| Memory error with large library | All-vs-all comparison | Pre-filter by precursor mass (PrecursorMzMatch) before detailed scoring |
add_fingerprint fails |
RDKit not installed | Install chemistry extras: pip install matchms[chemistry] |
| Import returns empty list | Wrong file format or path | Verify format matches loader (MGF for .mgf, MSP for .msp); check file is not empty |
| Inconsistent scores between runs | Different processing pipelines | Use SpectrumProcessor to ensure identical processing for queries and references |
Bundled Resources
-
references/filtering_catalog.md — Complete catalog of 50+ matchms filter functions organized by category (metadata processing, chemical structure, mass/charge, peak processing, quality control).
- Covers: all filter function signatures with parameters and brief descriptions, common filter combinations
- Relocated inline: key filters (default_filters, normalize_intensities, select_by_relative_intensity, reduce_to_number_of_peaks, remove_peaks_around_precursor_mz, add_fingerprint) in Core API Module 2; filter category summary in Key Concepts
- Omitted: individual filter examples duplicating Core API patterns
-
references/workflows_similarity.md — Extended workflows and detailed similarity function documentation consolidated from two original references.
- Covers: all 8 similarity functions with parameter tables, large-scale comparison strategies, multi-metric scoring patterns, 7 extended workflows (library matching, QC, multi-metric, format conversion, metadata enrichment, large-scale comparison, automated identification report), performance considerations
- Relocated inline: CosineGreedy/ModifiedCosine/CosineHungarian/FingerprintSimilarity usage in Core API Module 3; similarity comparison table in Key Concepts; library matching + QC workflows in Common Workflows
- Omitted: network-based spectral clustering workflow — requires external tool (spec2vec); spectra visualization tutorial — covered by
spectrum.plot()in Core API Module 1
Disposition of original reference files:
- importing_exporting.md (417 lines) → fully consolidated into Core API Module 1 (I/O functions, format list, Spectrum creation, pickle usage). Retained: all 7 import functions, 4 export functions, Spectrum class creation, format selection guidance. Omitted: USI detailed examples — niche use case
- filtering.md (289 lines) → migrated as references/filtering_catalog.md with key filters relocated to Core API Module 2
- similarity.md (381 lines) → consolidated into references/workflows_similarity.md with core functions in Core API Module 3
- workflows.md (648 lines) → consolidated into references/workflows_similarity.md with top workflows in Common Workflows
Related Skills
- pyopenms-mass-spectrometry — full LC-MS/MS proteomics and metabolomics pipelines (feature detection, protein ID)
- rdkit-cheminformatics — molecular fingerprint generation and chemical structure similarity
References
- matchms documentation: https://matchms.readthedocs.io
- Huber et al. (2020) matchms — processing and similarity scoring of mass spectrometry data. Journal of Open Source Software, DOI: 10.21105/joss.02411
- GitHub: https://github.com/matchms/matchms
skills/proteomics-protein-engineering/maxquant-proteomics/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill maxquant-proteomics -g -y
SKILL.md
Frontmatter
{
"name": "maxquant-proteomics",
"license": "Apache-2.0",
"description": "MaxQuant + Perseus proteomics pipeline: run MaxQuant for LFQ and SILAC; parse proteinGroups.txt in Python; filter contaminants\/decoys; log2 + median-normalize; impute MNAR; t-test with FDR; volcano plot; GO\/pathway enrichment. Use Proteome Discoverer for Thermo-native processing; FragPipe\/MSFragger for GPU-accelerated DB search."
}
MaxQuant + Perseus — Proteomics Analysis Pipeline
Overview
MaxQuant is the community-standard software for label-free quantification (LFQ) and SILAC proteomics. It performs database search, protein grouping, and intensity-based quantification from raw LC-MS/MS files, producing proteinGroups.txt as the primary output. Downstream statistical analysis — filtering, normalization, imputation, differential abundance testing, and visualization — is performed in Python using pandas, scipy, and matplotlib/seaborn, mirroring the Perseus workflow in a reproducible scripting environment.
When to Use
- Performing label-free quantification (LFQ) of proteins across multiple biological conditions — MaxQuant's MaxLFQ algorithm is the community benchmark
- Running SILAC (stable isotope labeling) experiments with light/heavy or triple-label designs
- Processing iTRAQ or TMT isobaric labeling experiments via MaxQuant's reporter ion quantification
- Identifying and quantifying proteins when you need the widely-cited MaxQuant output format (
proteinGroups.txt) for comparison with published datasets - Performing statistical differential abundance analysis on MaxQuant outputs without installing Perseus (GUI-only, Windows)
- Generating publication-quality volcano plots and GO enrichment from proteomics data in a reproducible Python workflow
- Use Proteome Discoverer instead when working with Thermo raw files requiring instrument-native processing or Sequest HT
- Use FragPipe/MSFragger instead for GPU-accelerated database search (3–10× faster) or when processing DIA (data-independent acquisition) data
Prerequisites
- MaxQuant: Windows software; download from https://maxquant.org/ (v2.4+); requires .NET 6 runtime
- Python packages:
pandas,numpy,scipy,matplotlib,seaborn,statsmodels,gseapy - Data requirements: Thermo
.rawfiles or mzML-converted files; FASTA protein database (UniProt reviewed + contaminant database) - Environment: MaxQuant runs on Windows (GUI or CLI); Python analysis runs cross-platform
pip install pandas numpy scipy matplotlib seaborn statsmodels gseapy
# Install pyMaxQuant for programmatic mqpar.xml configuration
pip install pymaxquant
Quick Start
import pandas as pd
import numpy as np
# Load MaxQuant output
df = pd.read_csv("combined/txt/proteinGroups.txt", sep="\t", low_memory=False)
print(f"Raw protein groups: {len(df)}")
# Filter contaminants, reverse decoys, only-by-site
mask = (
(df["Potential contaminant"] != "+") &
(df["Reverse"] != "+") &
(df["Only identified by site"] != "+")
)
df = df[mask].copy()
print(f"After filtering: {len(df)} protein groups")
# Extract LFQ intensity columns
lfq_cols = [c for c in df.columns if c.startswith("LFQ intensity ")]
print(f"LFQ columns: {lfq_cols}")
# Log2-transform (0 → NaN)
lfq = df[lfq_cols].replace(0, np.nan)
lfq = np.log2(lfq)
print(f"Valid values per sample:\n{lfq.notna().sum()}")
Workflow
Step 1: Configure MaxQuant Parameters via mqpar.xml
MaxQuant is controlled by an XML parameter file (mqpar.xml). Edit it programmatically to set file paths, enzyme, modifications, and quantification type before running the search.
import xml.etree.ElementTree as ET
def update_mqpar(template_path: str, output_path: str,
raw_files: list[str], fasta_path: str,
experiment_names: list[str]) -> None:
"""Update mqpar.xml with sample-specific file paths."""
tree = ET.parse(template_path)
root = tree.getroot()
# Set raw file paths
file_paths_node = root.find(".//filePaths")
file_paths_node.clear()
for rf in raw_files:
elem = ET.SubElement(file_paths_node, "string")
elem.text = rf
# Set experiment names (maps files to conditions)
experiments_node = root.find(".//experiments")
experiments_node.clear()
for name in experiment_names:
elem = ET.SubElement(experiments_node, "string")
elem.text = name
# Set FASTA database
fasta_node = root.find(".//fastaFiles/FastaFileInfo/fastaFilePath")
fasta_node.text = fasta_path
tree.write(output_path, xml_declaration=True, encoding="utf-8")
print(f"Written: {output_path}")
# Example usage
raw_files = [
r"C:\Data\ctrl_rep1.raw",
r"C:\Data\ctrl_rep2.raw",
r"C:\Data\treat_rep1.raw",
r"C:\Data\treat_rep2.raw",
]
update_mqpar(
template_path="mqpar_template.xml",
output_path="mqpar.xml",
raw_files=raw_files,
fasta_path=r"C:\Databases\human_uniprot_contaminants.fasta",
experiment_names=["ctrl", "ctrl", "treat", "treat"],
)
Key mqpar.xml parameters (set in template or edit directly):
<!-- Enzyme and search settings -->
<enzymes>
<string>Trypsin/P</string>
</enzymes>
<maxMissedCleavages>2</maxMissedCleavages>
<variableModifications>
<string>Oxidation (M)</string>
<string>Acetyl (Protein N-term)</string>
</variableModifications>
<fixedModifications>
<string>Carbamidomethyl (C)</string>
</fixedModifications>
<!-- LFQ settings -->
<lfqMode>1</lfqMode> <!-- 1 = LFQ enabled -->
<lfqMinRatioCount>2</lfqMinRatioCount> <!-- minimum peptides for LFQ -->
<matchBetweenRuns>True</matchBetweenRuns>
<!-- FDR thresholds -->
<peptideFdr>0.01</peptideFdr>
<proteinFdr>0.01</proteinFdr>
Step 2: Run MaxQuant from Command Line (Windows)
MaxQuant can be run headlessly from the Windows command prompt using the bundled MaxQuantCmd.exe.
REM Windows Command Prompt — run MaxQuant with configured mqpar.xml
REM Adjust path to match your MaxQuant installation directory
set MQ_PATH=C:\Program Files\MaxQuant\bin\MaxQuantCmd.exe
set MQPAR=C:\Projects\proteomics\mqpar.xml
"%MQ_PATH%" "%MQPAR%"
REM For specific workflow steps only (useful for reruns):
REM Step IDs: 0=write tables, 1=feature detection, 7=peptide identification
"%MQ_PATH%" "%MQPAR%" --steps 1,7,11
# Cross-platform: run MaxQuant under Wine on Linux/macOS (CI/server use)
wine MaxQuantCmd.exe mqpar.xml
# Monitor progress log
tail -f combined/proc/#runningTimes.txt
Step 3: Load and Filter proteinGroups.txt
Filter out reverse decoys, potential contaminants, and proteins only identified by modification site.
import pandas as pd
import numpy as np
def load_protein_groups(path: str) -> pd.DataFrame:
"""Load MaxQuant proteinGroups.txt with quality filters applied."""
df = pd.read_csv(path, sep="\t", low_memory=False)
print(f"Total protein groups: {len(df)}")
# Remove reverse decoys, contaminants, and only-by-site hits
n_before = len(df)
df = df[
(df.get("Reverse", pd.Series("")) != "+") &
(df.get("Potential contaminant", pd.Series("")) != "+") &
(df.get("Only identified by site", pd.Series("")) != "+")
].copy()
print(f"After quality filter: {len(df)} ({n_before - len(df)} removed)")
# Parse gene names (take first entry for multi-gene groups)
df["Gene names"] = df["Gene names"].fillna("Unknown").str.split(";").str[0]
# Set unique index on majority protein ID
df = df.set_index("Majority protein IDs")
return df
# Load output
pg = load_protein_groups("combined/txt/proteinGroups.txt")
# Identify LFQ intensity columns
lfq_cols = [c for c in pg.columns if c.startswith("LFQ intensity ")]
print(f"LFQ samples ({len(lfq_cols)}): {lfq_cols}")
# Output: LFQ samples (6): ['LFQ intensity ctrl_1', 'LFQ intensity ctrl_2', ...]
Step 4: Log2 Transform and Median Normalize LFQ Intensities
Replace zero intensities with NaN (missing values in MaxQuant are exported as 0), log2-transform, then apply per-sample median centering.
def prepare_lfq_matrix(df: pd.DataFrame, lfq_cols: list[str]) -> pd.DataFrame:
"""Extract, transform, and normalize LFQ intensity matrix."""
# Extract and rename columns (strip 'LFQ intensity ' prefix)
lfq = df[lfq_cols].copy()
lfq.columns = [c.replace("LFQ intensity ", "") for c in lfq_cols]
# Replace 0 with NaN (MaxQuant encodes missing as 0)
lfq = lfq.replace(0, np.nan)
# Log2 transform
lfq = np.log2(lfq)
# Median centering per sample (subtract per-column median of valid values)
col_medians = lfq.median(axis=0)
global_median = col_medians.median()
lfq = lfq.subtract(col_medians, axis=1).add(global_median)
print(f"Matrix shape: {lfq.shape}")
print(f"Missing values per sample:\n{lfq.isna().sum()}")
print(f"Valid values per sample:\n{lfq.notna().sum()}")
return lfq
lfq_matrix = prepare_lfq_matrix(pg, lfq_cols)
# Matrix shape: (3241, 6)
# Missing values per sample: ctrl_1: 421, ctrl_2: 389, ...
Step 5: Impute Missing Values (MNAR Strategy)
Missing-not-at-random (MNAR) values arise from proteins below the detection limit. Impute from the low end of the observed intensity distribution — the standard Perseus approach.
def impute_mnar(lfq: pd.DataFrame,
width: float = 0.3,
downshift: float = 1.8,
random_state: int = 42) -> pd.DataFrame:
"""
Impute MNAR missing values from a downshifted Gaussian.
Parameters
----------
width : std of imputation distribution (fraction of sample std)
downshift : downshift in units of sample std below mean
random_state : for reproducibility
"""
rng = np.random.default_rng(random_state)
lfq_imp = lfq.copy()
for col in lfq_imp.columns:
col_data = lfq_imp[col].dropna()
col_mean = col_data.mean()
col_std = col_data.std()
n_missing = lfq_imp[col].isna().sum()
if n_missing > 0:
imputed = rng.normal(
loc=col_mean - downshift * col_std,
scale=width * col_std,
size=n_missing,
)
lfq_imp.loc[lfq_imp[col].isna(), col] = imputed
print(f"Imputed {lfq.isna().sum().sum()} missing values")
return lfq_imp
lfq_imputed = impute_mnar(lfq_matrix)
# Imputed 2847 missing values
Step 6: Statistical Testing — t-test with FDR Correction
Perform two-sample t-tests for each protein between conditions, then apply Benjamini-Hochberg FDR correction.
from scipy import stats
from statsmodels.stats.multitest import multipletests
def differential_abundance(lfq: pd.DataFrame,
group_a: list[str],
group_b: list[str],
alpha: float = 0.05) -> pd.DataFrame:
"""
Two-sample t-test + BH FDR correction for all proteins.
Parameters
----------
group_a, group_b : sample name lists for each condition
alpha : FDR threshold
"""
results = []
for protein_id, row in lfq.iterrows():
a_vals = row[group_a].dropna().values
b_vals = row[group_b].dropna().values
if len(a_vals) >= 2 and len(b_vals) >= 2:
t_stat, p_val = stats.ttest_ind(a_vals, b_vals, equal_var=False)
log2fc = b_vals.mean() - a_vals.mean()
else:
t_stat, p_val, log2fc = np.nan, np.nan, np.nan
results.append({
"protein_id": protein_id,
"log2FC": log2fc,
"pvalue": p_val,
"t_stat": t_stat,
})
res_df = pd.DataFrame(results).set_index("protein_id")
# BH FDR correction on valid p-values
valid = res_df["pvalue"].notna()
_, padj, _, _ = multipletests(res_df.loc[valid, "pvalue"], method="fdr_bh")
res_df.loc[valid, "padj"] = padj
# Add significance flag
res_df["significant"] = (res_df["padj"] < alpha) & (res_df["pvalue"].notna())
sig_count = res_df["significant"].sum()
print(f"Significant proteins (FDR < {alpha}): {sig_count}")
return res_df.sort_values("padj")
# Define sample groups
group_ctrl = ["ctrl_1", "ctrl_2", "ctrl_3"]
group_treat = ["treat_1", "treat_2", "treat_3"]
results = differential_abundance(lfq_imputed, group_ctrl, group_treat)
print(results[results["significant"]].head(10))
# Significant proteins (FDR < 0.05): 312
Step 7: Volcano Plot Visualization
Generate a publication-quality volcano plot showing log2 fold change vs. -log10(p-value) with significance thresholds highlighted.
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def plot_volcano(results: pd.DataFrame,
gene_names: pd.Series,
fc_threshold: float = 1.0,
pval_threshold: float = 0.05,
top_n_labels: int = 10,
save_path: str = "volcano_plot.pdf") -> None:
"""Volcano plot: log2FC vs -log10(adjusted p-value)."""
df = results.copy()
df["gene"] = gene_names.reindex(df.index).fillna("Unknown")
df["-log10p"] = -np.log10(df["padj"].clip(lower=1e-300))
# Classify regulation
df["regulation"] = "ns"
df.loc[(df["log2FC"] > fc_threshold) & (df["padj"] < pval_threshold), "regulation"] = "up"
df.loc[(df["log2FC"] < -fc_threshold) & (df["padj"] < pval_threshold), "regulation"] = "down"
color_map = {"up": "#D62728", "down": "#1F77B4", "ns": "#AAAAAA"}
fig, ax = plt.subplots(figsize=(7, 6))
for reg, grp in df.groupby("regulation"):
ax.scatter(grp["log2FC"], grp["-log10p"],
c=color_map[reg], s=12, alpha=0.7, linewidths=0, label=reg)
# Threshold lines
ax.axhline(-np.log10(pval_threshold), color="k", lw=0.8, ls="--", alpha=0.5)
ax.axvline( fc_threshold, color="k", lw=0.8, ls="--", alpha=0.5)
ax.axvline(-fc_threshold, color="k", lw=0.8, ls="--", alpha=0.5)
# Label top significant proteins by -log10p
top = df[df["regulation"] != "ns"].nlargest(top_n_labels, "-log10p")
for _, row in top.iterrows():
ax.text(row["log2FC"], row["-log10p"] + 0.1, row["gene"],
fontsize=6, ha="center", va="bottom")
# Counts in legend
up_n = (df["regulation"] == "up").sum()
down_n = (df["regulation"] == "down").sum()
patches = [
mpatches.Patch(color="#D62728", label=f"Up ({up_n})"),
mpatches.Patch(color="#1F77B4", label=f"Down ({down_n})"),
mpatches.Patch(color="#AAAAAA", label="ns"),
]
ax.legend(handles=patches, fontsize=8, frameon=False)
ax.set_xlabel("log₂ Fold Change (treat / ctrl)", fontsize=11)
ax.set_ylabel("-log₁₀(adjusted p-value)", fontsize=11)
ax.set_title("Differential Protein Abundance", fontsize=12)
plt.tight_layout()
plt.savefig(save_path, dpi=300, bbox_inches="tight")
plt.show()
print(f"Saved: {save_path}")
plot_volcano(results, pg["Gene names"])
Step 8: GO/Pathway Enrichment of Significant Proteins
Run over-representation analysis (ORA) on significantly up- and down-regulated proteins using gseapy's Enrichr API.
import gseapy as gp
def run_enrichment(results: pd.DataFrame,
gene_names: pd.Series,
gene_sets: list[str] | None = None,
top_n: int = 20) -> dict[str, pd.DataFrame]:
"""
ORA enrichment for up- and down-regulated proteins via Enrichr.
Parameters
----------
gene_sets : Enrichr gene set libraries (default: GO BP + KEGG)
top_n : top results to display per direction
"""
if gene_sets is None:
gene_sets = ["GO_Biological_Process_2023", "KEGG_2021_Human"]
results_out = {}
gene_map = gene_names.reindex(results.index).fillna("Unknown")
for direction in ("up", "down"):
sig = results[
(results["significant"]) &
(results["log2FC"] > 0 if direction == "up" else results["log2FC"] < 0)
]
gene_list = gene_map.reindex(sig.index).tolist()
print(f"{direction.capitalize()}-regulated: {len(gene_list)} proteins")
if len(gene_list) < 5:
print(f" Too few proteins for enrichment (n={len(gene_list)}), skipping")
continue
enr = gp.enrichr(
gene_list=gene_list,
gene_sets=gene_sets,
organism="Human",
outdir=f"enrichment_{direction}",
cutoff=0.05,
)
top_results = enr.results.sort_values("Adjusted P-value").head(top_n)
results_out[direction] = top_results
print(top_results[["Term", "Adjusted P-value", "Overlap"]].to_string(index=False))
return results_out
enr_results = run_enrichment(results, pg["Gene names"])
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
matchBetweenRuns |
False |
True / False |
Transfers identifications across runs by retention time matching; increases quantified protein count 10–30% |
lfqMinRatioCount |
2 |
1–5 |
Minimum peptide pairs required for LFQ normalization; lower values increase coverage but reduce accuracy |
maxMissedCleavages |
2 |
0–4 |
Tryptic missed cleavages allowed; increase for samples with poor digestion |
peptideFdr / proteinFdr |
0.01 |
0.001–0.05 |
FDR thresholds for peptide and protein identifications |
MNAR downshift |
1.8 |
1.5–2.5 |
Shifts imputation distribution below detection limit in units of column std; larger = more conservative imputation |
MNAR width |
0.3 |
0.1–0.5 |
Width of imputed distribution relative to column std |
t-test alpha |
0.05 |
0.01–0.1 |
FDR significance threshold for differential abundance |
fc_threshold (volcano) |
1.0 |
0.5–2.0 |
log2 fold-change cutoff for "significant" label in volcano plot |
Key Concepts
MaxQuant Output Files
| File | Content |
|---|---|
proteinGroups.txt |
Primary output: one row per protein group with LFQ/SILAC intensities, peptide counts, sequence coverage |
peptides.txt |
Peptide-level quantification with charge states and modifications |
evidence.txt |
Individual MS/MS identifications (one row per peptide-spectrum match) |
msms.txt |
Full MS/MS scan data including fragment ions and scores |
summary.txt |
Per-raw-file statistics: identifications, MS/MS counts, calibration |
LFQ Intensity vs. iBAQ
- LFQ (Label-Free Quantification): MaxLFQ algorithm normalizes intensities across samples based on razor+unique peptide ratios. Use for cross-sample comparisons (fold changes). Stored in
LFQ intensity <sample>columns. - iBAQ (intensity-Based Absolute Quantification): Divides summed peptide intensities by the number of theoretically observable peptides. Use for estimating copy numbers and comparing absolute abundance between proteins within a sample. Stored in
iBAQcolumn. - SILAC ratio: Direct H/L ratio from isotope-labeled pairs. More accurate than LFQ for small fold changes.
Perseus Equivalent Operations in Python
| Perseus step | Python equivalent |
|---|---|
| Filter rows by categorical column | df[df["Reverse"] != "+"] |
| Replace 0 with NaN | df.replace(0, np.nan) |
| Log2 transform | np.log2(df) |
| Median normalization | df.subtract(df.median()).add(global_median) |
| MNAR imputation (normal distribution) | impute_mnar() function above |
| Two-sample t-test | scipy.stats.ttest_ind() + multipletests() |
| Volcano plot | matplotlib.pyplot scatter + threshold lines |
| Hierarchical clustering | seaborn.clustermap() |
Common Recipes
Recipe: SILAC Ratio Analysis
When to use: SILAC experiments with H/L or H/M/L labeling instead of LFQ.
import pandas as pd
import numpy as np
# Load proteinGroups.txt for SILAC experiment
df = pd.read_csv("combined/txt/proteinGroups.txt", sep="\t", low_memory=False)
# Filter contaminants and decoys
df = df[(df["Reverse"] != "+") & (df["Potential contaminant"] != "+")].copy()
# Extract H/L ratio columns (log2-transformed)
ratio_cols = [c for c in df.columns if c.startswith("Ratio H/L ") and "normalized" in c.lower()]
if not ratio_cols:
# Fall back to non-normalized
ratio_cols = [c for c in df.columns if c.startswith("Ratio H/L")]
print(f"SILAC ratio columns: {ratio_cols}")
ratios = df[ratio_cols].copy().replace(0, np.nan)
# Log2 transform ratios
log2_ratios = np.log2(ratios)
log2_ratios.columns = [c.replace("Ratio H/L normalized ", "") for c in ratio_cols]
# Summary statistics per sample
print(log2_ratios.describe().round(3))
Recipe: Hierarchical Clustering Heatmap
When to use: visualizing patterns across all significant proteins simultaneously.
import seaborn as sns
import matplotlib.pyplot as plt
def plot_heatmap(lfq_imputed: pd.DataFrame,
results: pd.DataFrame,
gene_names: pd.Series,
top_n: int = 50,
save_path: str = "heatmap.pdf") -> None:
"""Hierarchical clustering heatmap of top significant proteins."""
sig_proteins = results[results["significant"]].nlargest(top_n, "-log10p" if "-log10p" in results else "padj").index
# Recalculate if needed
sig_proteins = results[results["significant"]].nsmallest(top_n, "padj").index
heatmap_data = lfq_imputed.loc[sig_proteins].copy()
heatmap_data.index = gene_names.reindex(sig_proteins).fillna(sig_proteins)
# Z-score per row for visualization
heatmap_z = heatmap_data.subtract(heatmap_data.mean(axis=1), axis=0).divide(
heatmap_data.std(axis=1).replace(0, 1), axis=0
)
g = sns.clustermap(
heatmap_z,
cmap="RdBu_r",
center=0,
vmin=-2.5, vmax=2.5,
figsize=(8, 10),
yticklabels=True,
xticklabels=True,
dendrogram_ratio=(0.15, 0.1),
cbar_kws={"label": "Z-score (log₂ LFQ)"},
)
g.ax_heatmap.set_yticklabels(g.ax_heatmap.get_yticklabels(), fontsize=6)
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Saved: {save_path}")
plot_heatmap(lfq_imputed, results, pg["Gene names"])
Recipe: STRING-db Network Enrichment for Significant Proteins
When to use: protein-protein interaction network analysis and enrichment without downloading gene sets locally.
import requests
import pandas as pd
def string_enrichment(gene_list: list[str],
species: int = 9606,
fdr_threshold: float = 0.05) -> pd.DataFrame:
"""Query STRING /enrichment endpoint for GO/KEGG enrichment."""
url = "https://string-db.org/api/json/enrichment"
params = {
"identifiers": "\r".join(gene_list),
"species": species,
"caller_identity": "maxquant_proteomics_skill",
}
response = requests.post(url, data=params)
response.raise_for_status()
enr_df = pd.DataFrame(response.json())
if enr_df.empty:
print("No enrichment results returned")
return enr_df
enr_df = enr_df[enr_df["fdr"].astype(float) < fdr_threshold]
enr_df = enr_df.sort_values("fdr")
print(f"Enriched terms (FDR < {fdr_threshold}): {len(enr_df)}")
print(enr_df[["category", "term", "description", "fdr", "number_of_genes"]].head(15).to_string(index=False))
return enr_df
# Significant up-regulated gene names
up_genes = pg.loc[
results[(results["significant"]) & (results["log2FC"] > 1)].index, "Gene names"
].tolist()
string_enr = string_enrichment(up_genes)
Recipe: Parse MaxQuant Output with pyMaxQuant
When to use: reading and filtering MaxQuant text files with a higher-level API.
# pyMaxQuant provides typed accessors for MaxQuant output files
# Install: pip install pymaxquant
from maxquant.io import read_protein_groups
# Load with built-in contaminant filtering
pg_clean = read_protein_groups(
"combined/txt/proteinGroups.txt",
filter_invalid=True, # removes reverse, contaminant, only-by-site
)
print(f"Loaded {len(pg_clean)} filtered protein groups")
# Access LFQ columns via helper
lfq_df = pg_clean.filter(like="LFQ intensity")
print(f"LFQ matrix: {lfq_df.shape}")
Expected Outputs
| File | Description |
|---|---|
combined/txt/proteinGroups.txt |
Main MaxQuant output: protein groups with LFQ intensities, peptide counts, unique peptides, iBAQ |
combined/txt/peptides.txt |
Peptide-level quantification with modifications and charge states |
combined/txt/summary.txt |
Per-raw-file QC statistics: identification rates, MS/MS counts |
results_differential.csv |
Differential abundance table: log2FC, pvalue, padj, significant per protein |
volcano_plot.pdf |
Volcano plot with up/down-regulated proteins colored and top proteins labeled |
heatmap.pdf |
Hierarchical clustering heatmap of top significant proteins (Z-score normalized) |
enrichment_up/ |
gseapy output directory: GO/KEGG enrichment for up-regulated proteins |
enrichment_down/ |
gseapy output directory: GO/KEGG enrichment for down-regulated proteins |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| MaxQuant produces 0 protein identifications | Wrong FASTA database or enzyme settings; raw file path not found | Verify .raw file paths in mqpar.xml are absolute Windows paths; confirm enzyme matches experiment (Trypsin/P vs Trypsin); check summary.txt for identification rate |
| All LFQ intensities are 0 after filtering | matchBetweenRuns off + sparse data, or wrong column selection |
Check combined/txt/proteinGroups.txt directly; use pg.filter(like="LFQ intensity") to confirm column names; lower lfqMinRatioCount to 1 |
| Too many missing values after log2 transform | Insufficient replicates, inconsistent sample loading, or undetected peptides | Enable matchBetweenRuns; verify equal protein loading (Bradford/BCA); consider stricter valid-value filter (require 3/3 per group) before imputation |
Memory error loading proteinGroups.txt |
File is large (>500 MB for DDA with many samples) | Use pd.read_csv(..., low_memory=False, usecols=[...]) to select only needed columns; or use pd.read_csv(..., chunksize=...) |
| gseapy Enrichr returns empty results | Gene symbols unrecognized or network timeout | Ensure gene list uses HGNC symbols (not UniProt IDs); check internet connectivity; use gp.enrichr(..., timeout=60) |
| Volcano plot: all proteins in "ns" | FDR threshold too stringent or padj not calculated |
Verify multipletests returned valid FDR values; try relaxing alpha to 0.1; check sample group assignments are correct |
| MaxQuant run hangs at "Feature detection" | Low memory (MaxQuant needs 4–8 GB RAM per 3–4 raw files) | Process files in smaller batches; increase system RAM; close other applications |
| Imputation inflates false positives | Imputing too aggressively (low downshift) |
Increase downshift to 2.0–2.5; alternatively, filter to proteins with ≥ 2 valid values per group before testing |
References
- MaxQuant software and documentation — official MaxQuant download, manual, and parameter guide
- Perseus computational platform — MaxQuant companion statistical analysis tool; basis for the Python workflow above
- Cox et al. (2008) Nature Biotechnology — MaxQuant paper — original MaxQuant publication describing the MaxLFQ algorithm
- Tyanova et al. (2016) Nature Methods — Perseus paper — Perseus statistical framework reference; all workflow steps above mirror Perseus operations
- pyMaxQuant GitHub — Python library for parsing MaxQuant output files
- gseapy documentation — gene set enrichment analysis library used in Step 8
- STRING API enrichment — protein network enrichment endpoint used in Common Recipes
skills/proteomics-protein-engineering/metabolomics-workbench-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill metabolomics-workbench-database -g -y
SKILL.md
Frontmatter
{
"name": "metabolomics-workbench-database",
"license": "CC-BY-4.0",
"description": "Query Metabolomics Workbench REST API (4,200+ NIH studies) for metabolite ID, study discovery, RefMet standardization, m\/z precursor searches, and gene\/protein annotations. Quirks: compound input_item rejects `name` (use pubchem_cid\/kegg_id\/inchi_key\/etc.); free-text → compound is a two-step refmet\/match→refmet\/name flow; moverz endpoint returns TSV text, not JSON. Use hmdb-database for local XML; pubchem-compound-search for general compound lookup."
}
Metabolomics Workbench Database — REST API Access
Overview
The Metabolomics Workbench (MW) REST API at https://www.metabolomicsworkbench.org/rest/ exposes 4,200+ metabolomics studies hosted at UCSD under NIH Common Fund sponsorship. URL pattern is /{context}/{input_item}/{input_value}/{output_item}/{format}. Contexts include compound, refmet, moverz, study, analysis, metabolite, gene, protein. Notable quirks discovered live:
compound/name/{x}is rejected —nameis not an allowed input_item. Usepubchem_cid,kegg_id,inchi_key,hmdb_id,regno,lm_id,formula,smiles, orabbrev. For free-text input, go throughrefmet/match/{x}first.refmet/name/{x}/allrequires the exact RefMet name (e.g.Glucose, notD-glucose); userefmet/match/{x}for fuzzy normalisation first.moverz/{REFMET|LIPIDS|MB}/{mz}/{ion}/{tol}/txtreturns TSV text (no JSON variant).- The
metstat/filter/...endpoint shown in older examples returns[]— replace withstudy/{context}/{value}/summary(or/metabolites) + client-side filtering.
No authentication required.
When to Use
- Searching metabolite records by PubChem CID, KEGG ID, InChIKey, HMDB ID, formula, or SMILES
- Discovering studies by species, disease, last_name, institute, analysis_type, or polarity
- Standardising metabolite names to RefMet nomenclature for cross-study integration
- Identifying unknown compounds from MS m/z values with adduct-aware matching (
moverz) - Retrieving experimental metabolite tables (analyses, abundances) from published studies
- Querying gene/protein annotations linked to metabolomics pathways
- Downloading raw mwTab files for local analysis
- For local 220K-metabolite XML parsing with NMR/MS spectra use
hmdb-databaseinstead - For live 110M-compound property lookups use
pubchem-compound-searchinstead
Prerequisites
- Python packages:
requests,pandas - No API key required: publicly accessible
- Rate limits: MW does not enforce strict limits; add
time.sleep(0.3)between bulk requests - Base URL:
https://www.metabolomicsworkbench.org/rest
pip install requests pandas
Quick Start
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
# Two-step free-text → compound (the API rejects compound/name/...)
def lookup_by_name(name):
# 1) Normalise to RefMet name
r = requests.get(f"{BASE}/refmet/match/{name}", timeout=30)
r.raise_for_status()
refmet = r.json()
if not refmet.get("refmet_name"):
return None
# 2) Pull full compound record by RefMet name (or by pubchem_cid)
r2 = requests.get(f"{BASE}/refmet/name/{refmet['refmet_name']}/all", timeout=30)
rec = r2.json() if r2.json() else {}
return rec if isinstance(rec, dict) else None
c = lookup_by_name("glucose")
print(f"{c['name']}: formula={c['formula']}, PubChem CID={c['pubchem_cid']}, "
f"InChIKey={c['inchi_key']}")
# Glucose: formula=C6H12O6, PubChem CID=5793, InChIKey=WQZGKKKJIJFFOK-GASJEMHNSA-N
Core API
Module 1: Compound Queries
compound/{input_item}/{input_value}/all/json — input_item must be one of regno, formula, inchi_key, lm_id, pubchem_cid, hmdb_id, kegg_id, smiles, abbrev. The legacy name input is rejected by the server.
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
# By PubChem CID
r = requests.get(f"{BASE}/compound/pubchem_cid/5793/all/json", timeout=30)
glucose = r.json()
print(f"PubChem 5793 -> {glucose['name']}, formula={glucose['formula']}, "
f"HMDB={glucose.get('hmdb_id')}, KEGG={glucose.get('kegg_id')}")
# By KEGG ID
r = requests.get(f"{BASE}/compound/kegg_id/C00031/all/json", timeout=30)
print("KEGG C00031 ->", r.json()["name"])
# By InChIKey
r = requests.get(f"{BASE}/compound/inchi_key/WQZGKKKJIJFFOK-GASJEMHNSA-N/all/json", timeout=30)
print("InChIKey -> regno:", r.json()["regno"])
# Compound by formula returns a paged dict (multiple matches)
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
r = requests.get(f"{BASE}/compound/formula/C6H12O6/all/json", timeout=30)
matches = r.json()
print(f"Compounds with formula C6H12O6: {len(matches)}")
for k in list(matches)[:3]:
print(f" regno={matches[k]['regno']} name={matches[k]['name']}")
Module 2: Study Discovery
study/{input_item}/{input_value}/{output} — input_item includes study_id, study_title, last_name, institute, analysis_id, metabolite_id, kegg_id, refmet_name. output includes summary, metabolites, factors, data, available_studies, species, disease. summary for study_id returns a dict (keyed by accession when multiple); for last_name/institute it returns a list.
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
# Single-study summary — `study/study_id/{id}/summary` returns a flat dict
# (keys: study_id, study_title, species, institute, analysis_type, ...)
r = requests.get(f"{BASE}/study/study_id/ST000001/summary", timeout=30)
s = r.json()
print(f"{s['study_id']}: {s['study_title'][:60]}")
print(f" Species : {s.get('species')} Institute: {s.get('institute')}")
print(f" Submit : {s.get('submission_date')}")
# Studies that detected a metabolite — `study/refmet_name/{x}/summary` returns
# a thin index of (refmet_name, kegg_id, study_id) rows. Chain study_id → full summary
# to get title and species.
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
r = requests.get(f"{BASE}/study/refmet_name/Glucose/summary", timeout=60)
d = r.json()
rows = list(d.values()) if isinstance(d, dict) else d
print(f"Studies referencing 'Glucose': {len(rows)}")
print(pd.DataFrame(rows).head(5).to_string(index=False))
# refmet_name kegg_id study_id
# Glucose C00031 ST000001
# Glucose C00031 ST000002
# ...
Module 3: RefMet Standardisation
refmet/match/{user_text} is a fuzzy normaliser — returns the standard RefMet record (no pubchem_cid/kegg_id though). refmet/name/{exact_refmet_name}/all returns the full record including IDs. Use them as a two-step pipeline.
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
def normalise_to_refmet(user_text):
r = requests.get(f"{BASE}/refmet/match/{user_text}", timeout=30)
r.raise_for_status()
m = r.json()
if not m or not m.get("refmet_name"):
return None
return m["refmet_name"]
def refmet_full(refmet_name):
r = requests.get(f"{BASE}/refmet/name/{refmet_name}/all", timeout=30)
r.raise_for_status()
rec = r.json()
return rec if isinstance(rec, dict) and rec else None
name = normalise_to_refmet("alpha-D-glucose") # -> 'Glucose'
print(f"Normalised: {name}")
rec = refmet_full(name)
print(f" PubChem CID : {rec['pubchem_cid']}")
print(f" InChIKey : {rec['inchi_key']}")
print(f" Super class : {rec['super_class']} / {rec['main_class']} / {rec['sub_class']}")
Module 4: Study Filtering (replaces broken metstat)
The older metstat/filter/... endpoint returns []. Use the study context endpoints with client-side filtering instead.
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
def study_ids_for_metabolite(refmet_name):
"""Return the study_id list that report a given RefMet name."""
r = requests.get(f"{BASE}/study/refmet_name/{refmet_name}/summary", timeout=60)
r.raise_for_status()
d = r.json()
rows = list(d.values()) if isinstance(d, dict) else d
return sorted({row["study_id"] for row in rows if row.get("study_id")})
def study_summary(study_id):
"""Pull full summary (title, species, institute, dates) for one study_id.
Response is a flat dict with keys study_id/study_title/species/institute/..."""
return requests.get(f"{BASE}/study/study_id/{study_id}/summary", timeout=30).json()
# Find glucose studies, then enrich the first few
ids = study_ids_for_metabolite("Glucose")
print(f"Studies referencing 'Glucose': {len(ids)}")
rows = [study_summary(sid) for sid in ids[:5]]
df = pd.DataFrame(rows)
print(df[["study_id", "study_title", "species"]].head().to_string(index=False))
Module 5: m/z Precursor Search (moverz)
moverz/{REFMET|LIPIDS|MB}/{mz}/{ion}/{tolerance}/txt returns tab-separated text (not JSON). The first DB selector (REFMET, LIPIDS, or MB) is required — mz as the first segment is rejected.
import requests, io, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
def moverz_search(db, mz, ion, tolerance=0.005):
"""Search precursor m/z in REFMET / LIPIDS / MB and return a DataFrame.
Response is TSV text — no JSON variant."""
assert db in {"REFMET", "LIPIDS", "MB"}
r = requests.get(f"{BASE}/moverz/{db}/{mz}/{ion}/{tolerance}/txt", timeout=30)
r.raise_for_status()
return pd.read_csv(io.StringIO(r.text), sep="\t")
df = moverz_search("REFMET", 180.063, "M+H", 0.005)
print(f"Candidates at m/z 180.063 [M+H]+ (REFMET): {len(df)}")
print(df.head(5).to_string(index=False))
# Same query against the LIPIDS database
import requests, io, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
r = requests.get(f"{BASE}/moverz/LIPIDS/760.585/M+H/0.01/txt", timeout=30)
df_lipids = pd.read_csv(io.StringIO(r.text), sep="\t")
print(f"Lipid candidates at m/z 760.585: {len(df_lipids)}")
print(df_lipids.head(3).to_string(index=False))
Module 6: Genes and Proteins
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
r = requests.get(f"{BASE}/gene/gene_symbol/HMGCR/all", timeout=30)
g = r.json()
print(f"{g['gene_symbol']} -> MGP: {g.get('mgp_id')} ({g.get('gene_name', '')[:60]})")
# Protein by UniProt accession
r2 = requests.get(f"{BASE}/protein/uniprot_id/P04035/all", timeout=30)
p = r2.json()
print(f"UniProt P04035: {p.get('protein_name', '')[:60]} organism={p.get('organism')}")
Key Concepts
Allowed input_item Values per Context
| Context | Valid input_item | Notes |
|---|---|---|
compound |
regno, formula, inchi_key, lm_id, pubchem_cid, hmdb_id, kegg_id, smiles, abbrev |
name is rejected — go via refmet/match first |
refmet |
match, name, formula, exactmass, inchi_key, pubchem_cid, regno |
match is fuzzy; name requires the canonical RefMet name |
study |
study_id, study_title, last_name, institute, analysis_id, metabolite_id, kegg_id, refmet_name |
summary for study_id is a dict keyed by accession; for last_name/institute it's a list |
moverz |
(path) REFMET / LIPIDS / MB |
First segment is the DB, not mz |
gene |
gene_id, gene_symbol, gene_name, mgp_id |
Returns a dict |
protein |
mgp_id, gene_id, uniprot_id, gene_symbol |
Returns a dict |
Output Type Conventions
output=summaryreturns a dict when the input identifier is unique (e.g.study_id), a list when it isn't (e.g.last_name).- Appending
/jsontostudy/.../summaryflips the response to TSV. Omit the format suffix — JSON is the default. moverzonly emits/txt(TSV); there is no JSON variant.
refmet/match vs refmet/name
refmet/match/{user_text}— fuzzy. Always returns a single dict withrefmet_name,formula,exactmass, classification. Does not includepubchem_cid/inchi_key.refmet/name/{exact_refmet_name}/all— requires the canonical RefMet name. Returns the full record including IDs. Returns an empty list if the name isn't canonical.
Common Workflows
Workflow 1: Free-Text → Full Compound Record
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
def resolve_to_compound(user_text):
# 1) Normalise via refmet/match
rm = requests.get(f"{BASE}/refmet/match/{user_text}", timeout=30).json()
if not rm.get("refmet_name"):
return None
name = rm["refmet_name"]
# 2) Fetch full RefMet record (includes pubchem_cid / inchi_key)
full = requests.get(f"{BASE}/refmet/name/{name}/all", timeout=30).json()
if not isinstance(full, dict) or not full:
return None
# 3) Optionally pull the matching compound record via PubChem CID
cid = full.get("pubchem_cid")
compound = None
if cid:
compound = requests.get(f"{BASE}/compound/pubchem_cid/{cid}/all/json",
timeout=30).json()
return {
"input": user_text,
"refmet_name": name,
"formula": full.get("formula"),
"pubchem_cid": cid,
"hmdb_id": compound.get("hmdb_id") if compound else None,
"kegg_id": compound.get("kegg_id") if compound else None,
"inchi_key": full.get("inchi_key"),
}
queries = ["glucose", "alpha-D-glucose", "L-tyrosine", "cholesterol"]
df = pd.DataFrame([resolve_to_compound(q) for q in queries])
print(df.to_string(index=False))
df.to_csv("name_resolution.csv", index=False)
Workflow 2: Annotate MS Hit List
Goal: Take a list of measured m/z values and assign RefMet candidate compounds.
import requests, io, pandas as pd, time
BASE = "https://www.metabolomicsworkbench.org/rest"
def annotate_peaks(mz_values, ion="M+H", tolerance=0.005):
out = []
for mz in mz_values:
r = requests.get(f"{BASE}/moverz/REFMET/{mz}/{ion}/{tolerance}/txt", timeout=30)
if r.status_code != 200 or not r.text.strip():
time.sleep(0.3); continue
df = pd.read_csv(io.StringIO(r.text), sep="\t")
for _, row in df.iterrows():
out.append({
"query_mz": mz,
"matched_mz": row["Matched m/z"],
"delta": row["Delta"],
"name": row["Name"],
"formula": row["Formula"],
"ion": row["Ion"],
"main_class": row.get("Main class"),
})
time.sleep(0.3)
return pd.DataFrame(out)
peaks = [180.063, 166.086, 90.055] # glucose, phenylalanine, alanine
df_ann = annotate_peaks(peaks)
print(df_ann.head(10).to_string(index=False))
df_ann.to_csv("ms_annotations.csv", index=False)
Workflow 3: Find Studies Detecting a Metabolite
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
def studies_with(refmet_name, enrich_n=20):
"""Return a DataFrame: study_id rows that report the metabolite, enriched with
title + species for the first `enrich_n` IDs (via study/study_id/.../summary)."""
r = requests.get(f"{BASE}/study/refmet_name/{refmet_name}/summary", timeout=60)
r.raise_for_status()
d = r.json()
rows = list(d.values()) if isinstance(d, dict) else d
ids = sorted({row["study_id"] for row in rows if row.get("study_id")})
enriched = []
for sid in ids[:enrich_n]:
enriched.append(requests.get(
f"{BASE}/study/study_id/{sid}/summary", timeout=30).json())
return pd.DataFrame(enriched)
df = studies_with("Glucose", enrich_n=20)
print(f"Glucose-detecting studies (first 20 enriched): {len(df)}")
print(df.groupby("species").size().sort_values(ascending=False).head(8))
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
context |
path | required | compound, refmet, moverz, study, analysis, metabolite, gene, protein |
API context selector |
input_item |
path | required | depends on context (see "Allowed input_item Values per Context") |
Identifier type |
input_value |
path | required | string | The actual identifier or value |
output_item |
path | all |
all, summary, metabolites, factors, data, etc. |
What aspect to return |
format |
path | (varies) | json, txt |
moverz only emits txt; do NOT append /json to study/.../summary |
mz / ion / tolerance |
moverz path |
required | float / M+H, M-H, M+Na, M+K, etc. / float |
Mass tolerance in Da |
Best Practices
- Use
refmet/matchfirst for free-text input.compound/name/...is rejected by the server (nameis not an allowedinput_item). moverzis TSV-only. Parse withpd.read_csv(io.StringIO(r.text), sep="\t")— never call.json()on the response.- Don't append
/jsontostudy/.../summary. The default is JSON; the suffix flips the response to TSV. refmet/name/{x}/allneeds the canonical RefMet name. If you have user text, run it throughrefmet/matchfirst.- Compound results paged by formula come keyed
'1','2',...— iteratedict.values()or pass topd.DataFrame.from_dict(orient="index"). - Always
time.sleep(0.3)in batch loops — no rate limit is published but the server is shared.
Common Recipes
Recipe: Cross-Database ID Mapping (PubChem ↔ KEGG ↔ HMDB)
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
def cross_refs(refmet_name):
rm = requests.get(f"{BASE}/refmet/name/{refmet_name}/all", timeout=30).json()
if not isinstance(rm, dict) or not rm:
return None
cid = rm.get("pubchem_cid")
if not cid:
return {"refmet": refmet_name, "pubchem_cid": None}
c = requests.get(f"{BASE}/compound/pubchem_cid/{cid}/all/json", timeout=30).json()
return {"refmet": refmet_name,
"pubchem_cid": cid,
"kegg_id": c.get("kegg_id"),
"hmdb_id": c.get("hmdb_id"),
"inchi_key": rm.get("inchi_key")}
df = pd.DataFrame([cross_refs(n) for n in ["Glucose", "L-Tyrosine", "Cholesterol"]])
print(df.to_string(index=False))
Recipe: Pull a Study's Metabolite Table
import requests, pandas as pd
BASE = "https://www.metabolomicsworkbench.org/rest"
r = requests.get(f"{BASE}/study/study_id/ST000001/metabolites", timeout=60)
r.raise_for_status()
rows = list(r.json().values())
df = pd.DataFrame(rows)
print(f"ST000001 metabolites: {len(df)}")
print(df[["analysis_id", "analysis_summary", "metabolite_name", "refmet_name"]].head(5).to_string(index=False))
Recipe: Gene → Metabolomics Pathways
import requests
BASE = "https://www.metabolomicsworkbench.org/rest"
g = requests.get(f"{BASE}/gene/gene_symbol/HMGCR/all", timeout=30).json()
print({k: g.get(k) for k in ["gene_symbol", "gene_id", "mgp_id",
"gene_name", "gene_synonyms"]})
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
"This input item (name) is not allowed..." |
compound/name/... is rejected |
Use one of the allowed input_item values (pubchem_cid, kegg_id, inchi_key, hmdb_id, etc.); for free text, go through refmet/match/{x} first |
JSONDecodeError on moverz response |
moverz returns TSV text, not JSON |
Parse with pd.read_csv(io.StringIO(r.text), sep="\t") |
Empty list from refmet/name/{x}/all |
Need the canonical RefMet name (case-sensitive) | Normalise via refmet/match/{user_text} first, then plug refmet_name into refmet/name/.../all |
metstat/filter/... returns [] |
Endpoint syntax is non-functional | Use `study/{refmet_name |
study/.../summary returns TSV instead of JSON |
The /json suffix flips to TSV |
Drop the trailing /json — JSON is the default |
| Compound query by formula returns a dict, not a list | Server pages multiple matches as {'1': {...}, '2': {...}} |
Iterate dict.values() (or pd.DataFrame.from_dict(d, orient='index')) |
refmet/match response lacks pubchem_cid |
match returns the lightweight record |
Use refmet/name/{refmet_name}/all for the full record |
Related Skills
hmdb-database— Local HMDB XML (220K metabolites, NMR/MS spectra, disease links) for offline queriespubchem-compound-search— General compound property lookups (110M+ compounds) via PubChemPykegg-database— Pathway and orthology data complementary to MW's study/metabolite hitschembl-database-bioactivity— Bioactivity data for the same compounds
References
- Metabolomics Workbench home
- REST API documentation (PDF)
- RefMet nomenclature
- Sud M et al. "Metabolomics Workbench: An international repository for metabolomics data and metadata." Nucleic Acids Research 44(D1): D463–D470 (2016). https://doi.org/10.1093/nar/gkv1042
skills/proteomics-protein-engineering/pyopenms-mass-spectrometry/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pyopenms-mass-spectrometry -g -y
SKILL.md
Frontmatter
{
"name": "pyopenms-mass-spectrometry",
"license": "BSD-3-Clause",
"description": "MS data processing with PyOpenMS for LC-MS\/MS proteomics and metabolomics — mzML\/mzXML I\/O, signal processing (smoothing, peak picking, centroiding), feature detection\/linking, peptide\/protein ID with FDR, untargeted metabolomics. Use matchms for simple spectral matching."
}
PyOpenMS — Mass Spectrometry Analysis
Overview
PyOpenMS provides Python bindings to the OpenMS C++ library for computational mass spectrometry. It supports proteomics and metabolomics data processing including file I/O for 10+ MS formats, signal processing, feature detection, peptide/protein identification, and quantitative analysis across samples.
When to Use
- Processing raw LC-MS/MS data (mzML, mzXML) for proteomics or metabolomics
- Detecting chromatographic features and linking them across multiple samples
- Identifying peptides and proteins from MS/MS search engine results with FDR control
- Running untargeted metabolomics workflows (peak picking → feature detection → alignment → annotation)
- Converting between mass spectrometry file formats (mzML, mzXML, featureXML, idXML)
- Smoothing, filtering, and centroiding raw spectral data
- For simple spectral library matching and metabolite identification, use matchms instead
- For protein sequence analysis (not mass spec), use biopython instead
Prerequisites
uv pip install pyopenms numpy pandas matplotlib
- Python 3.8+; NumPy for peak array operations
- Input data: mzML files (standard MS format), FASTA databases (for identification)
- All algorithms follow a consistent pattern:
algo = Algorithm(); params = algo.getParameters(); params.setValue(...); algo.setParameters(params)
Quick Start
import pyopenms as ms
# Load mzML file
exp = ms.MSExperiment()
ms.MzMLFile().load("sample.mzML", exp)
print(f"Spectra: {exp.getNrSpectra()}, Chromatograms: {exp.getNrChromatograms()}")
# Examine first spectrum
spec = exp.getSpectrum(0)
mz, intensity = spec.get_peaks()
print(f"MS level: {spec.getMSLevel()}, RT: {spec.getRT():.2f}s, Peaks: {len(mz)}")
# Quick preprocessing: smooth + centroid
gauss = ms.GaussFilter()
p = gauss.getParameters(); p.setValue("gaussian_width", 0.1); gauss.setParameters(p)
gauss.filterExperiment(exp)
picker = ms.PeakPickerHiRes()
centroided = ms.MSExperiment()
picker.pickExperiment(exp, centroided)
print(f"Centroided spectra: {centroided.getNrSpectra()}")
Core API
Module 1: File I/O & Data Access
Read and write mass spectrometry data in multiple formats.
import pyopenms as ms
# Read mzML (standard MS format)
exp = ms.MSExperiment()
ms.MzMLFile().load("data.mzML", exp)
# Indexed access for large files (memory-efficient)
loader = ms.IndexedMzMLFileLoader()
indexed_file = ms.OnDiscMSExperiment()
loader.load("large_data.mzML", indexed_file)
spec = indexed_file.getSpectrum(0) # Load single spectrum on demand
print(f"Total spectra: {indexed_file.getNrSpectra()}")
# Read identification results (idXML)
protein_ids, peptide_ids = [], []
ms.IdXMLFile().load("results.idXML", protein_ids, peptide_ids)
print(f"Peptide IDs: {len(peptide_ids)}, Protein IDs: {len(protein_ids)}")
# Read feature map
fm = ms.FeatureMap()
ms.FeatureXMLFile().load("features.featureXML", fm)
print(f"Features: {fm.size()}")
# Write mzML with compression
exp_out = ms.MSExperiment()
# ... populate experiment ...
ms.MzMLFile().store("output.mzML", exp_out)
# Read FASTA database
entries = []
ms.FASTAFile().load("database.fasta", entries)
print(f"Proteins in DB: {len(entries)}")
for e in entries[:3]:
print(f" {e.identifier}: {e.sequence[:30]}...")
Supported formats: mzML, mzXML, mzData (spectra); featureXML, consensusXML (features); idXML, mzIdentML, pepXML (identifications); TraML (transitions); mzTab (results); FASTA (sequences)
Module 2: Signal Processing & Peak Picking
Preprocess raw spectral data for downstream analysis.
import pyopenms as ms
exp = ms.MSExperiment()
ms.MzMLFile().load("raw.mzML", exp)
# Gaussian smoothing
gauss = ms.GaussFilter()
p = gauss.getParameters()
p.setValue("gaussian_width", 0.15) # m/z width
gauss.setParameters(p)
gauss.filterExperiment(exp)
# Savitzky-Golay smoothing (alternative)
sg = ms.SavitzkyGolayFilter()
p = sg.getParameters()
p.setValue("frame_length", 15) # Must be odd
sg.setParameters(p)
# sg.filterExperiment(exp) # Use one smoother, not both
# Peak picking (centroiding) — required before feature detection
picker = ms.PeakPickerHiRes()
p = picker.getParameters()
p.setValue("signal_to_noise", 1.0)
picker.setParameters(p)
centroided = ms.MSExperiment()
picker.pickExperiment(exp, centroided)
print(f"Raw peaks in spec 0: {exp.getSpectrum(0).size()}")
print(f"Centroided peaks: {centroided.getSpectrum(0).size()}")
# Normalization
normalizer = ms.Normalizer()
p = normalizer.getParameters()
p.setValue("method", "to_one") # "to_one" or "to_TIC"
normalizer.setParameters(p)
normalizer.filterPeakMap(centroided)
# Peak filtering — remove low-intensity noise
mower = ms.ThresholdMower()
p = mower.getParameters()
p.setValue("threshold", 100.0) # Minimum intensity
mower.setParameters(p)
mower.filterPeakMap(centroided)
# Baseline reduction
morph = ms.MorphologicalFilter()
p = morph.getParameters()
p.setValue("struc_elem_length", 3.0) # m/z window
morph.setParameters(p)
morph.filterExperiment(exp)
Module 3: Feature Detection & Linking
Detect chromatographic features and link them across samples.
import pyopenms as ms
# Load centroided data
exp = ms.MSExperiment()
ms.MzMLFile().load("centroided.mzML", exp)
# Feature detection (proteomics — centroided data)
ff = ms.FeatureFinder()
features = ms.FeatureMap()
seeds = ms.FeatureMap()
params = ms.FeatureFinder().getParameters("centroided")
ff.run("centroided", exp, features, params, seeds)
print(f"Detected {features.size()} features")
# Access feature properties
for f in features[:5]:
print(f" RT: {f.getRT():.1f}s, m/z: {f.getMZ():.4f}, "
f"intensity: {f.getIntensity():.0f}, quality: {f.getOverallQuality():.3f}")
# Feature linking across samples — align retention times first
aligner = ms.MapAlignmentAlgorithmPoseClustering()
p = aligner.getParameters()
p.setValue("max_num_peaks_considered", 1000)
aligner.setParameters(p)
# Link features into consensus map
linker = ms.FeatureGroupingAlgorithmQT()
p = linker.getParameters()
p.setValue("distance_RT:max_difference", 60.0) # seconds
p.setValue("distance_MZ:max_difference", 10.0) # ppm
linker.setParameters(p)
consensus = ms.ConsensusMap()
linker.group([features_sample1, features_sample2, features_sample3], consensus)
print(f"Consensus features: {consensus.size()}")
# Export to pandas for downstream analysis
import pandas as pd
df = consensus.get_df()
print(f"Consensus table: {df.shape}")
Module 4: Peptide & Protein Identification
Process search engine results with FDR control and protein inference.
import pyopenms as ms
# Load search engine results
protein_ids, peptide_ids = [], []
ms.IdXMLFile().load("search_results.idXML", protein_ids, peptide_ids)
# Examine peptide hits
for pep_id in peptide_ids[:3]:
print(f"Spectrum: RT={pep_id.getRT():.1f}, MZ={pep_id.getMZ():.4f}")
for hit in pep_id.getHits():
seq = hit.getSequence()
print(f" {seq} score={hit.getScore():.4f} charge={hit.getCharge()}")
# FDR filtering (target-decoy approach)
fdr = ms.FalseDiscoveryRate()
fdr.apply(peptide_ids)
# Filter at 1% FDR
filtered = []
for pep_id in peptide_ids:
hits = [h for h in pep_id.getHits() if h.getScore() <= 0.01]
if hits:
pep_id.setHits(hits)
filtered.append(pep_id)
print(f"Peptide IDs at 1% FDR: {len(filtered)}")
# Protein inference
inference = ms.BasicProteinInferenceAlgorithm()
inference.run(peptide_ids, protein_ids)
for prot_id in protein_ids:
for hit in prot_id.getHits()[:5]:
print(f"Protein: {hit.getAccession()}, score: {hit.getScore():.4f}")
# Peptide sequence handling
seq = ms.AASequence.fromString("PEPTIDER")
print(f"Molecular weight: {seq.getMonoWeight():.4f}")
print(f"Formula: {seq.getFormula()}")
# Modified sequence
mod_seq = ms.AASequence.fromString("PEPTM(Oxidation)DER")
print(f"Modified weight: {mod_seq.getMonoWeight():.4f}")
# Enzymatic digestion
digestor = ms.ProteaseDigestion()
digestor.setEnzyme("Trypsin")
digest = []
digestor.digest(ms.AASequence.fromString("MKWVTFISLLLLFSSAYSRGVFRR"), digest)
print(f"Tryptic peptides: {len(digest)}")
Module 5: Metabolomics Pipeline
Complete untargeted metabolomics workflow from raw data to feature table.
import pyopenms as ms
# Step 1: Load and centroid raw data
exp = ms.MSExperiment()
ms.MzMLFile().load("metabolomics_sample.mzML", exp)
picker = ms.PeakPickerHiRes()
centroided = ms.MSExperiment()
picker.pickExperiment(exp, centroided)
# Step 2: Feature detection for metabolomics (small molecules)
ff = ms.FeatureFinder()
features = ms.FeatureMap()
seeds = ms.FeatureMap()
params = ms.FeatureFinder().getParameters("centroided")
params.setValue("isotopic_pattern:charge_low", 1)
params.setValue("isotopic_pattern:charge_high", 3)
ff.run("centroided", centroided, features, params, seeds)
print(f"Detected features: {features.size()}")
# Step 3: Adduct detection (group related adducts)
decharger = ms.MetaboliteAdductDecharger()
p = decharger.getParameters()
p.setValue("potential_adducts", "H:+:0.6;Na:+:0.3;K:+:0.1") # Positive mode
decharger.setParameters(p)
# decharger.compute(features, feature_map_out, consensus_map_out)
# Step 4: RT alignment across samples
import pyopenms as ms
import pandas as pd
# Assuming feature maps from multiple samples
sample_files = ["sample1.featureXML", "sample2.featureXML", "sample3.featureXML"]
feature_maps = []
for f in sample_files:
fm = ms.FeatureMap()
ms.FeatureXMLFile().load(f, fm)
feature_maps.append(fm)
# Align retention times
aligner = ms.MapAlignmentAlgorithmPoseClustering()
aligner.setReference(0) # Use first sample as reference
# Step 5: Link features to consensus map
linker = ms.FeatureGroupingAlgorithmQT()
p = linker.getParameters()
p.setValue("distance_RT:max_difference", 30.0)
p.setValue("distance_MZ:max_difference", 10.0)
linker.setParameters(p)
consensus = ms.ConsensusMap()
linker.group(feature_maps, consensus)
# Step 6: Export metabolite table
df = consensus.get_df()
print(f"Feature table: {df.shape[0]} features × {df.shape[1]} columns")
print(df[["RT", "mz", "intensity_0", "intensity_1", "intensity_2"]].head())
Key Concepts
Algorithm Parameter Pattern
All PyOpenMS algorithms follow the same 3-step pattern:
algo = ms.AlgorithmClass() # 1. Instantiate
params = algo.getParameters() # 2. Get parameters
params.setValue("param_name", value) # 3. Set values
algo.setParameters(params) # 4. Apply
# algo.process(input, output) # 5. Execute
To discover available parameters:
for key in params.keys():
print(f"{key}: {params.getValue(key)} (type: {params.getDescription(key)})")
Core Data Structures
| Object | Description | Key Methods |
|---|---|---|
MSExperiment |
Collection of spectra + chromatograms | getNrSpectra(), getSpectrum(i), addSpectrum() |
MSSpectrum |
Single mass spectrum (m/z, intensity) | get_peaks(), getMSLevel(), getRT(), size() |
MSChromatogram |
Chromatographic trace | get_peaks(), getNativeID() |
Feature |
Detected chromatographic peak | getRT(), getMZ(), getIntensity(), getOverallQuality() |
FeatureMap |
Collection of features | size(), get_df(), iteration |
ConsensusMap |
Features linked across samples | size(), get_df() |
PeptideIdentification |
Search results for one spectrum | getHits(), getRT(), getMZ() |
AASequence |
Amino acid sequence with modifications | fromString(), getMonoWeight(), getFormula() |
Supported File Formats
| Format | Reader Class | Content |
|---|---|---|
| mzML | MzMLFile |
Raw spectra (standard) |
| mzXML | MzXMLFile |
Raw spectra (legacy) |
| featureXML | FeatureXMLFile |
Detected features |
| consensusXML | ConsensusXMLFile |
Linked features |
| idXML | IdXMLFile |
Peptide/protein IDs |
| mzIdentML | MzIdentMLFile |
Peptide/protein IDs (standard) |
| FASTA | FASTAFile |
Protein sequences |
| TraML | TraMLFile |
MRM transitions |
| mzTab | MzTabFile |
Quantification results |
Common Workflows
Workflow 1: Proteomics Feature Detection Pipeline
import pyopenms as ms
# Load → Smooth → Centroid → Detect → Export
exp = ms.MSExperiment()
ms.MzMLFile().load("proteomics.mzML", exp)
# Preprocessing
gauss = ms.GaussFilter()
p = gauss.getParameters(); p.setValue("gaussian_width", 0.1); gauss.setParameters(p)
gauss.filterExperiment(exp)
picker = ms.PeakPickerHiRes()
centroided = ms.MSExperiment()
picker.pickExperiment(exp, centroided)
# Feature detection
ff = ms.FeatureFinder()
features = ms.FeatureMap()
params = ff.getParameters("centroided")
ff.run("centroided", centroided, features, params, ms.FeatureMap())
# Export to pandas
df = features.get_df()
print(f"Features: {df.shape}")
df.to_csv("proteomics_features.csv", index=False)
Workflow 2: Multi-Sample Metabolomics Comparison
- Load mzML files for all samples (Core API Module 1)
- Centroid each sample (Core API Module 2 —
PeakPickerHiRes) - Detect features per sample (Core API Module 3 —
FeatureFinder) - Align retention times (Core API Module 3 —
MapAlignmentAlgorithmPoseClustering) - Link features to consensus map (Core API Module 3 —
FeatureGroupingAlgorithmQT) - Export consensus table to pandas and filter by CV across replicates
Workflow 3: Identification Results Analysis
import pyopenms as ms
import pandas as pd
# Load and filter identifications
protein_ids, peptide_ids = [], []
ms.IdXMLFile().load("search.idXML", protein_ids, peptide_ids)
# FDR filtering
fdr = ms.FalseDiscoveryRate()
fdr.apply(peptide_ids)
# Build results table
rows = []
for pep_id in peptide_ids:
for hit in pep_id.getHits():
if hit.getScore() <= 0.01: # 1% FDR
rows.append({
"sequence": str(hit.getSequence()),
"score": hit.getScore(),
"charge": hit.getCharge(),
"rt": pep_id.getRT(),
"mz": pep_id.getMZ()
})
df = pd.DataFrame(rows)
print(f"Identified peptides at 1% FDR: {len(df)}")
print(f"Unique sequences: {df['sequence'].nunique()}")
df.to_csv("peptide_identifications.csv", index=False)
Key Parameters
| Parameter | Module | Default | Range/Options | Effect |
|---|---|---|---|---|
gaussian_width |
GaussFilter | 0.2 | 0.05–1.0 | m/z smoothing window width |
frame_length |
SavitzkyGolayFilter | 11 | 5–31 (odd) | Smoothing window points |
signal_to_noise |
PeakPickerHiRes | 1.0 | 0.5–10.0 | Minimum S/N for centroiding |
isotopic_pattern:charge_low |
FeatureFinder | 1 | 1–6 | Minimum charge state |
isotopic_pattern:charge_high |
FeatureFinder | 3 | 1–10 | Maximum charge state |
distance_RT:max_difference |
FeatureGroupingAlgorithmQT | 100 | 10–300 s | RT tolerance for feature linking |
distance_MZ:max_difference |
FeatureGroupingAlgorithmQT | 10 | 1–50 ppm | m/z tolerance for feature linking |
threshold |
ThresholdMower | 0.0 | 0–10000 | Minimum peak intensity |
method |
Normalizer | "to_one" | "to_one"/"to_TIC" | Normalization method |
Best Practices
- Always centroid before feature detection — FeatureFinder requires centroided data. Use
PeakPickerHiResfirst - Use indexed loading for large files —
OnDiscMSExperimentloads spectra on demand, avoiding memory issues for files >1 GB - Preserve original data — Store raw experiment before processing:
orig = ms.MSExperiment(exp). Processing is destructive - Profile vs centroid awareness — Check data type:
spec.getType()returns 1 (profile) or 2 (centroid). Some algorithms require specific types - Parameter discovery — Print parameters before tuning:
for k in params.keys(): print(k, params.getValue(k)) - Export to pandas early — Use
features.get_df()orconsensus.get_df()to leverage pandas/numpy for statistical analysis
Common Recipes
Recipe 1: Spectrum Statistics and Quality Check
import pyopenms as ms
import numpy as np
exp = ms.MSExperiment()
ms.MzMLFile().load("sample.mzML", exp)
ms1_count = sum(1 for s in exp if s.getMSLevel() == 1)
ms2_count = sum(1 for s in exp if s.getMSLevel() == 2)
rt_range = (exp.getSpectrum(0).getRT(), exp.getSpectrum(exp.getNrSpectra()-1).getRT())
peak_counts = [s.size() for s in exp]
print(f"MS1: {ms1_count}, MS2: {ms2_count}")
print(f"RT range: {rt_range[0]:.1f}–{rt_range[1]:.1f}s")
print(f"Peaks per spectrum: mean={np.mean(peak_counts):.0f}, "
f"median={np.median(peak_counts):.0f}")
Recipe 2: Theoretical Spectrum Generation
import pyopenms as ms
# Generate theoretical fragment spectrum for a peptide
tsg = ms.TheoreticalSpectrumGenerator()
p = tsg.getParameters()
p.setValue("add_b_ions", "true")
p.setValue("add_y_ions", "true")
p.setValue("add_metainfo", "true")
tsg.setParameters(p)
spec = ms.MSSpectrum()
seq = ms.AASequence.fromString("PEPTIDER")
tsg.getSpectrum(spec, seq, 1, 2) # charge 1 to 2
mz, intensity = spec.get_peaks()
print(f"Theoretical fragments for PEPTIDER: {len(mz)} ions")
Recipe 3: Format Conversion (mzXML → mzML)
import pyopenms as ms
exp = ms.MSExperiment()
ms.MzXMLFile().load("old_data.mzXML", exp)
ms.MzMLFile().store("converted.mzML", exp)
print(f"Converted {exp.getNrSpectra()} spectra to mzML format")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FileNotFound on load |
Wrong path or format | Verify file exists; use correct reader class (MzMLFile for .mzML) |
| Empty feature map after detection | Profile data instead of centroided | Run PeakPickerHiRes before FeatureFinder |
| Very few features detected | Too-strict S/N threshold | Lower signal_to_noise to 0.5–1.0; adjust isotopic_pattern charge range |
| Memory error on large files | Loading entire file at once | Use OnDiscMSExperiment for indexed access |
setValue type error |
Wrong value type for parameter | Check expected type: params.getDescription(key). Use float for numeric, string for enum |
| RT alignment fails | Too few common features | Increase max_num_peaks_considered; verify samples are from same experiment |
| FDR values all 1.0 | No decoy hits in search results | Ensure search was run with target-decoy database; check score orientation |
| Feature linking too aggressive | Large RT/m/z tolerances | Reduce distance_RT:max_difference and distance_MZ:max_difference |
| Slow processing | Processing all spectra | Filter by MS level first: [s for s in exp if s.getMSLevel() == 1] |
Bundled Resources
-
references/data_structures_reference.md — Comprehensive documentation of PyOpenMS core objects (MSExperiment, MSSpectrum, Feature, FeatureMap, ConsensusMap, PeptideIdentification, AASequence, Param). Covers: object creation, attribute access, iteration patterns, memory management, type conversions.
- Covers: all 13+ core data structure classes with creation/access/iteration examples
- Relocated inline: Core Data Structures summary table in Key Concepts, AASequence usage in Core API Module 4
- Omitted: exhaustive attribute listings duplicating PyOpenMS API docs
-
references/signal_feature_processing.md — Signal processing algorithms and feature detection/linking workflows consolidated from two original references.
- Covers: smoothing (Gaussian, Savitzky-Golay), peak picking (HiRes, CWT), normalization, peak filtering (threshold, window, N-largest), baseline reduction, deconvolution, spectrum merging, RT alignment, mass calibration, feature finding (centroided, metabolomics), feature linking, adduct detection, quality control
- Relocated inline: GaussFilter, PeakPickerHiRes, Normalizer, ThresholdMower in Core API Module 2; FeatureFinder, MapAlignment, FeatureGroupingAlgorithmQT in Core API Module 3
- Omitted: CWT peak picker detailed parameters — rarely used vs HiRes; isotope wavelet transform — specialized use case
-
references/identification_metabolomics.md — Peptide/protein identification and untargeted metabolomics pipelines consolidated from two original references.
- Covers: search engine integration, FDR calculation, protein inference, enzymatic digestion, spectral library search, theoretical spectrum generation, untargeted metabolomics pipeline (peak picking → feature detection → adduct detection → alignment → linking → gap filling → annotation → export), compound identification (mass-based, MS/MS-based), normalization (TIC), QC (CV filtering, blank filtering), MetaboAnalyst export
- Relocated inline: FDR filtering, protein inference, AASequence handling in Core API Module 4; metabolomics pipeline in Core API Module 5
- Omitted: detailed Comet/Mascot parameter configuration — search engine-specific; MetaboAnalyst export format details — external tool
Related Skills
- matchms-spectral-matching — spectral library matching and metabolite identification from MS/MS
- biopython-molecular-biology — protein sequence analysis (FASTA parsing, BLAST)
References
- PyOpenMS documentation: https://pyopenms.readthedocs.io
- OpenMS project: https://www.openms.org
- Röst et al. (2016) OpenMS: a flexible open-source software platform for mass spectrometry data analysis. Nature Methods, DOI: 10.1038/nmeth.3959
- GitHub: https://github.com/OpenMS/OpenMS
skills/proteomics-protein-engineering/uniprot-protein-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill uniprot-protein-database -g -y
SKILL.md
Frontmatter
{
"name": "uniprot-protein-database",
"license": "CC-BY-4.0",
"description": "Query UniProt REST API: search by gene\/protein name, fetch FASTA, map IDs (Ensembl, PDB, RefSeq), access Swiss-Prot annotations. Use bioservices for multi-DB access; alphafold-database-access for structures."
}
UniProt — Protein Database
Overview
UniProt is the most comprehensive protein sequence and functional annotation database, containing 250M+ entries. This skill covers programmatic access via the UniProt REST API for protein search, sequence retrieval, ID mapping, and annotation queries. Swiss-Prot entries are manually curated; TrEMBL entries are computationally predicted.
When to Use
- Searching for proteins by gene name, accession, organism, or function keywords
- Retrieving protein sequences in FASTA format for downstream analysis
- Mapping identifiers between databases (UniProt ↔ Ensembl, PDB, RefSeq, KEGG)
- Accessing protein annotations: GO terms, domains, post-translational modifications
- Batch retrieving multiple protein entries for comparative analysis
- Downloading reviewed (Swiss-Prot) protein datasets for a specific organism
- For unified access to 40+ databases, use bioservices instead
- For protein 3D structures, use alphafold-database-access or pdb-database
Prerequisites
pip install requests pandas
API Rate Limits: UniProt REST API has no strict rate limit but recommends adding time.sleep(0.5) between batch requests. For large queries (>10k results), use the streaming endpoint instead of paginated search. Maximum 100,000 IDs per ID mapping job.
Quick Start
import requests
# Search for human insulin proteins (reviewed/Swiss-Prot only)
url = "https://rest.uniprot.org/uniprotkb/search"
params = {"query": "insulin AND organism_id:9606 AND reviewed:true", "format": "tsv",
"fields": "accession,gene_names,protein_name,length"}
response = requests.get(url, params=params)
print(response.text[:500])
# accession gene_names protein_name length
# P01308 INS Insulin 110
Core API
1. Protein Search
Search UniProt with structured queries combining Boolean operators and field-specific filters.
import requests
import time
BASE = "https://rest.uniprot.org/uniprotkb/search"
def search_uniprot(query, fields=None, format="json", size=25):
"""Search UniProt with query syntax."""
params = {"query": query, "format": format, "size": size}
if fields:
params["fields"] = ",".join(fields)
resp = requests.get(BASE, params=params)
resp.raise_for_status()
return resp.json() if format == "json" else resp.text
# Search by gene name
results = search_uniprot("gene:BRCA1 AND reviewed:true",
fields=["accession", "gene_names", "organism_name", "length"])
for entry in results["results"][:3]:
print(f"{entry['primaryAccession']} | {entry.get('genes', [{}])[0].get('geneName', {}).get('value', 'N/A')} | {entry.get('organism', {}).get('scientificName', 'N/A')}")
Query syntax reference:
# Boolean operators
kinase AND organism_id:9606 # Human kinases
(diabetes OR insulin) AND reviewed:true
cancer NOT lung
# Field-specific
gene:BRCA1
accession:P12345
taxonomy_name:"Homo sapiens"
go:0005515 # GO term: protein binding
# Range queries
length:[100 TO 500]
mass:[50000 TO 100000]
# Wildcards
gene:BRCA*
2. Protein Entry Retrieval
Retrieve individual protein entries by accession number.
import requests
def get_protein(accession, format="json"):
"""Retrieve a single protein entry."""
url = f"https://rest.uniprot.org/uniprotkb/{accession}"
resp = requests.get(url, headers={"Accept": f"application/{format}"})
resp.raise_for_status()
return resp.json() if format == "json" else resp.text
# Get human insulin
entry = get_protein("P01308")
print(f"Protein: {entry['proteinDescription']['recommendedName']['fullName']['value']}")
print(f"Gene: {entry['genes'][0]['geneName']['value']}")
print(f"Length: {entry['sequence']['length']} aa")
print(f"Sequence: {entry['sequence']['value'][:50]}...")
# Get FASTA directly
fasta = requests.get("https://rest.uniprot.org/uniprotkb/P01308.fasta").text
print(fasta[:200])
3. ID Mapping
Map identifiers between UniProt and other databases.
import requests
import time
def map_ids(ids, from_db, to_db):
"""Map identifiers between databases (async job)."""
# Submit job
resp = requests.post("https://rest.uniprot.org/idmapping/run",
data={"from": from_db, "to": to_db, "ids": ",".join(ids)})
resp.raise_for_status()
job_id = resp.json()["jobId"]
# Poll for completion
while True:
status = requests.get(f"https://rest.uniprot.org/idmapping/status/{job_id}").json()
if "results" in status or "failedIds" in status:
break
time.sleep(1)
# Get results
results = requests.get(f"https://rest.uniprot.org/idmapping/results/{job_id}").json()
return results
# UniProt → PDB mapping
results = map_ids(["P01308", "P12345"], from_db="UniProtKB_AC-ID", to_db="PDB")
for r in results.get("results", []):
print(f"{r['from']} → PDB: {r['to']}")
# UniProt → Ensembl mapping
results = map_ids(["P01308"], from_db="UniProtKB_AC-ID", to_db="Ensembl")
for r in results.get("results", []):
print(f"{r['from']} → Ensembl: {r['to']}")
Common database codes: UniProtKB_AC-ID, Ensembl, RefSeq_Protein, PDB, Gene_Name, GeneID, KEGG
4. Batch Retrieval and Streaming
Retrieve large datasets efficiently.
import requests
import time
def batch_retrieve(accessions, fields=None, format="tsv"):
"""Retrieve multiple proteins by accession."""
query = " OR ".join(f"accession:{acc}" for acc in accessions)
params = {"query": query, "format": format}
if fields:
params["fields"] = ",".join(fields)
resp = requests.get("https://rest.uniprot.org/uniprotkb/search", params=params)
resp.raise_for_status()
return resp.text
# Batch retrieve
accessions = ["P01308", "P12345", "Q9Y6K9"]
tsv = batch_retrieve(accessions, fields=["accession", "gene_names", "protein_name", "length"])
print(tsv)
# Streaming for large queries (no pagination needed)
def stream_query(query, format="fasta"):
"""Stream large result sets."""
url = f"https://rest.uniprot.org/uniprotkb/stream?query={query}&format={format}"
resp = requests.get(url, stream=True)
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=8192, decode_unicode=True):
yield chunk
# Stream all human kinases as FASTA
# for chunk in stream_query("kinase AND organism_id:9606 AND reviewed:true"):
# print(chunk[:200])
5. Pagination and Cursor-Based Iteration
Handle large result sets with pagination using the Link header cursor.
import requests
def paginate_search(query, fields=None, page_size=500):
"""Iterate all pages of a UniProt search using cursor pagination."""
params = {"query": query, "format": "tsv", "size": page_size}
if fields:
params["fields"] = ",".join(fields)
url = "https://rest.uniprot.org/uniprotkb/search"
rows = []
header = None
while url:
resp = requests.get(url, params=params)
resp.raise_for_status()
params = {} # cursor is embedded in the next URL
lines = resp.text.strip().split("\n")
if header is None:
header = lines[0]
rows.extend(lines[1:])
# Follow Link header for next page
link = resp.headers.get("Link", "")
url = link.split("<")[1].split(">")[0] if "<" in link else None
return header, rows
header, rows = paginate_search(
"kinase AND organism_id:9606 AND reviewed:true",
fields=["accession", "gene_names", "length"]
)
print(f"Retrieved {len(rows)} proteins")
print(header)
print("\n".join(rows[:3]))
6. Field Selection and Annotations
Customize which data fields to retrieve.
import requests
import pandas as pd
from io import StringIO
# Retrieve specific annotation fields
params = {
"query": "gene:TP53 AND organism_id:9606 AND reviewed:true",
"format": "tsv",
"fields": "accession,gene_names,protein_name,go_p,go_f,go_c,cc_function,ft_domain",
}
resp = requests.get("https://rest.uniprot.org/uniprotkb/search", params=params)
df = pd.read_csv(StringIO(resp.text), sep="\t")
print(df.columns.tolist())
print(df.iloc[0])
Common field groups:
- Sequence:
accession,sequence,length,mass - Names:
gene_names,protein_name,organism_name - GO:
go_p(process),go_f(function),go_c(component) - Features:
ft_domain,ft_binding,ft_act_site,ft_mod_res - Comments:
cc_function,cc_interaction,cc_subcellular_location
Key Parameters
| Parameter | Function/Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
query |
/search, /stream |
— | UniProt query syntax | Filter proteins by criteria |
format |
All endpoints | json |
json, tsv, fasta, xml, gff |
Output format |
fields |
/search |
all | Comma-separated field names | Reduces response size |
size |
/search |
25 | 1–500 | Results per page |
from / to |
/idmapping/run |
— | Database codes | ID mapping direction |
reviewed:true |
Query filter | — | true/false |
Swiss-Prot (curated) only |
organism_id |
Query filter | — | NCBI taxonomy ID | Filter by species |
Best Practices
-
Filter
reviewed:truefor curated data: Swiss-Prot entries are manually reviewed; TrEMBL entries are computationally predicted. Use Swiss-Prot for high-confidence annotations. -
Use TSV format with
fieldsfor tabular analysis: Requesting only needed fields as TSV is faster and easier to parse than full JSON entries. -
Use streaming for large downloads: The
/streamendpoint returns all results without pagination, avoiding the need for multi-page iteration. -
Add
time.sleep(0.5)between batch requests: Respect API resources, especially when making many sequential requests. -
Cache frequently accessed entries locally: UniProt updates monthly; cache results and re-fetch only when needed.
-
Anti-pattern — querying without
organism_id: Broad queries likegene:INSreturn thousands of entries across all species. Always filter by organism for targeted results.
Common Recipes
Recipe: Download All Human Kinases as DataFrame
import requests
import pandas as pd
from io import StringIO
url = "https://rest.uniprot.org/uniprotkb/stream"
params = {
"query": "ec:2.7.* AND organism_id:9606 AND reviewed:true",
"format": "tsv",
"fields": "accession,gene_names,protein_name,length,go_f",
}
resp = requests.get(url, params=params)
df = pd.read_csv(StringIO(resp.text), sep="\t")
print(f"Human kinases (Swiss-Prot): {len(df)}")
print(df.head())
Recipe: Extract GO Annotations for a Gene Set
import requests
import pandas as pd
from io import StringIO
gene_list = ["BRCA1", "BRCA2", "TP53", "ATM", "CHEK2"]
query = " OR ".join(f"gene:{g}" for g in gene_list)
query += " AND organism_id:9606 AND reviewed:true"
params = {
"query": query,
"format": "tsv",
"fields": "accession,gene_names,go_p,go_f,go_c",
}
resp = requests.get("https://rest.uniprot.org/uniprotkb/search", params=params)
df = pd.read_csv(StringIO(resp.text), sep="\t")
print(df[["Accession", "Gene Names", "Gene Ontology (biological process)"]].head())
Recipe: Cross-Reference UniProt to PDB Structures
import requests
import time
accessions = ["P53_HUMAN", "P01308", "P00533"] # TP53, Insulin, EGFR
resp = requests.post("https://rest.uniprot.org/idmapping/run",
data={"from": "UniProtKB_AC-ID", "to": "PDB", "ids": ",".join(accessions)})
job_id = resp.json()["jobId"]
time.sleep(2)
results = requests.get(f"https://rest.uniprot.org/idmapping/results/{job_id}").json()
for r in results.get("results", []):
print(f"{r['from']} → PDB: {r['to']}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
400 Bad Request |
Invalid query syntax | Check Boolean operators, field names, bracket matching; use UniProt query syntax docs |
| Too many results (slow) | No organism or review filter | Add AND organism_id:9606 AND reviewed:true to narrow results |
| ID mapping returns empty | Wrong database code | Verify from/to codes: use UniProtKB_AC-ID (not UniProtKB alone) |
| Pagination missing entries | Large result set | Use /stream endpoint instead of paginated /search |
429 Too Many Requests |
Excessive API calls | Add time.sleep(0.5) between requests; batch accessions in single queries |
| FASTA has no gene name | TrEMBL entry with minimal annotation | Filter reviewed:true for Swiss-Prot entries with full annotations |
Related Skills
- biopython-molecular-biology — parse FASTA sequences returned by UniProt; run BLAST with retrieved sequences
- alphafold-database-access — retrieve predicted 3D structures using UniProt accessions
- esm-protein-language-model — generate embeddings from UniProt protein sequences
- gget-genomic-databases — alternative interface for quick gene/protein lookups across databases
References
- UniProt REST API documentation — official API reference
- UniProt query syntax — field-specific search operators
- UniProt Consortium (2023) — "UniProt: the Universal Protein Knowledgebase in 2023" — Nucleic Acids Research
skills/scientific-computing/aeon/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill aeon -g -y
SKILL.md
Frontmatter
{
"name": "aeon",
"license": "BSD-3-Clause",
"description": "scikit-learn compatible Python toolkit for time series ML: classify, cluster, regress, segment, transform with 30+ algorithms (ROCKET, InceptionTime, KNN-DTW, HIVE-COTE, WEASEL). Handles panel, multivariate, and unequal-length series. Maintained successor to sktime. Alternatives: sktime (larger ecosystem), tslearn (fewer algorithms), catch22 (features only)."
}
aeon
Overview
aeon provides a unified scikit-learn-compatible API for time series ML tasks: classification, regression, clustering, segmentation, annotation, similarity search, and transformation. It follows the same fit(X, y) / predict(X) pattern as scikit-learn, where X is a 3D NumPy array of shape (n_instances, n_channels, n_timepoints). aeon curates state-of-the-art algorithms from the time series literature — ROCKET and its variants (MiniROCKET, MultiROCKET) for classification, k-means with DTW for clustering, CLASP for segmentation — and provides benchmarking tools for comparing algorithms across datasets. It is the community-maintained fork of sktime following the 2022 governance split.
When to Use
- Classifying ECG, EEG, accelerometer, or sensor time series using state-of-the-art algorithms
- Regressing a scalar target from a time series input (e.g., predicting patient severity from vital sign waveforms)
- Clustering time series by shape similarity when class labels are unavailable
- Detecting change points or segmenting a continuous recording into homogeneous intervals
- Extracting fixed-length feature vectors from variable-length time series for downstream ML
- Benchmarking time series algorithms on the UCR/UEA archive with reproducible comparisons
- Use sktime when you need a larger ecosystem or existing code depends on its API; use tslearn for DTW-focused work
Prerequisites
- Python packages:
aeon,numpy,scikit-learn,matplotlib - Data format: 3D NumPy array
(n_instances, n_channels, n_timepoints)or 2D(n_instances, n_timepoints)for univariate - Optional:
numba(required for ROCKET, DTW — install viapip install aeon[all_extras])
pip install aeon
pip install aeon[all_extras] # includes numba, statsmodels for full algorithm support
Quick Start
import numpy as np
from aeon.classification.convolution_based import RocketClassifier
from aeon.datasets import load_unit_test
# Load a small benchmark dataset
X_train, y_train = load_unit_test(split="train") # shape: (n, 1, timepoints)
X_test, y_test = load_unit_test(split="test")
print(f"Train: {X_train.shape}, classes: {np.unique(y_train)}")
clf = RocketClassifier(num_kernels=500, random_state=42)
clf.fit(X_train, y_train)
accuracy = clf.score(X_test, y_test)
print(f"ROCKET accuracy: {accuracy:.3f}")
Core API
Module 1: Time Series Classification
30+ classifiers spanning convolution, dictionary, distance, feature, interval, and shapelet families.
import numpy as np
from aeon.datasets import load_unit_test
from aeon.classification.convolution_based import RocketClassifier, MiniRocketClassifier
from aeon.classification.distance_based import KNeighborsTimeSeriesClassifier
from aeon.classification.feature_based import Catch22Classifier
from sklearn.metrics import accuracy_score
X_train, y_train = load_unit_test(split="train")
X_test, y_test = load_unit_test(split="test")
classifiers = {
"ROCKET": RocketClassifier(num_kernels=1000, random_state=42),
"MiniROCKET": MiniRocketClassifier(random_state=42),
"KNN-DTW": KNeighborsTimeSeriesClassifier(n_neighbors=1, distance="dtw"),
"Catch22": Catch22Classifier(random_state=42),
}
for name, clf in classifiers.items():
clf.fit(X_train, y_train)
acc = accuracy_score(y_test, clf.predict(X_test))
print(f"{name:15s}: {acc:.3f}")
# Multivariate classification (multiple channels)
from aeon.classification.convolution_based import MultiRocketMultivariateClassifier
# Synthetic multivariate time series: 100 instances, 3 channels, 50 timepoints
np.random.seed(0)
X_mv_train = np.random.randn(100, 3, 50)
y_mv_train = (X_mv_train[:, 0, :].mean(axis=1) > 0).astype(str)
X_mv_test = np.random.randn(30, 3, 50)
y_mv_test = (X_mv_test[:, 0, :].mean(axis=1) > 0).astype(str)
clf_mv = MultiRocketMultivariateClassifier(random_state=42)
clf_mv.fit(X_mv_train, y_mv_train)
print(f"Multivariate accuracy: {clf_mv.score(X_mv_test, y_mv_test):.3f}")
Module 2: Time Series Regression
Predict a continuous target from a time series input.
import numpy as np
from aeon.regression.convolution_based import RocketRegressor
from aeon.regression.distance_based import KNeighborsTimeSeriesRegressor
from sklearn.metrics import mean_squared_error
# Synthetic regression: predict the mean of each series
np.random.seed(42)
X_train = np.random.randn(200, 1, 100)
y_train = X_train[:, 0, :].mean(axis=1) + np.random.randn(200) * 0.1
X_test = np.random.randn(50, 1, 100)
y_test = X_test[:, 0, :].mean(axis=1) + np.random.randn(50) * 0.1
reg = RocketRegressor(num_kernels=500, random_state=42)
reg.fit(X_train, y_train)
y_pred = reg.predict(X_test)
mse = mean_squared_error(y_test, y_pred)
print(f"ROCKET regressor MSE: {mse:.4f}")
Module 3: Time Series Clustering
Group time series by shape similarity without class labels.
import numpy as np
from aeon.clustering.k_means import TimeSeriesKMeans
from aeon.clustering.k_medoids import TimeSeriesKMedoids
# Synthetic clustering dataset: 3 distinct shapes
np.random.seed(0)
n_per_class = 30
class_0 = np.sin(np.linspace(0, 2*np.pi, 50)) + np.random.randn(n_per_class, 1, 50)*0.1
class_1 = np.cos(np.linspace(0, 2*np.pi, 50)) + np.random.randn(n_per_class, 1, 50)*0.1
class_2 = np.linspace(0, 1, 50) + np.random.randn(n_per_class, 1, 50)*0.05
X = np.concatenate([class_0, class_1, class_2], axis=0)
# K-Means with DTW averaging
km = TimeSeriesKMeans(n_clusters=3, metric="dtw", averaging_method="ba",
random_state=42, n_init=3, max_iter=50)
labels = km.fit_predict(X)
print(f"Cluster sizes: {np.bincount(labels)}")
print(f"Cluster centers shape: {km.cluster_centers_.shape}")
Module 4: Segmentation and Change Point Detection
Detect boundaries between regimes in a continuous time series.
import numpy as np
from aeon.segmentation import ClaSPSegmenter, EAggloSegmenter
# Simulate signal with 3 segments
np.random.seed(0)
seg1 = np.random.randn(100) * 0.5 + 0
seg2 = np.random.randn(100) * 0.5 + 3
seg3 = np.random.randn(100) * 0.5 - 2
signal = np.concatenate([seg1, seg2, seg3])
# CLASP: Classification Score Profile segmentation
clasp = ClaSPSegmenter(period_length=10, n_change_points=2)
labels = clasp.fit_predict(signal)
change_points = clasp.change_points_
print(f"Detected change points: {change_points}")
# Expected near indices 100 and 200
Module 5: Transformation (Feature Extraction)
Convert time series into fixed-length feature vectors or transformed series.
import numpy as np
from aeon.transformations.collection.convolution_based import Rocket, MiniRocket
from aeon.transformations.collection.feature_based import Catch22
from sklearn.linear_model import RidgeClassifierCV
from sklearn.pipeline import Pipeline
np.random.seed(42)
X_train = np.random.randn(100, 1, 50)
y_train = (X_train[:, 0, :].mean(axis=1) > 0).astype(str)
X_test = np.random.randn(30, 1, 50)
# ROCKET transform → Ridge classifier (the classic ROCKET pipeline)
rocket_pipe = Pipeline([
("rocket", Rocket(num_kernels=1000, random_state=42)),
("clf", RidgeClassifierCV(alphas=[0.1, 1.0, 10.0])),
])
rocket_pipe.fit(X_train, y_train)
print(f"ROCKET pipeline accuracy: {rocket_pipe.score(X_test, (X_test[:, 0, :].mean(axis=1) > 0).astype(str)):.3f}")
# Catch22: 22 canonical time series features per channel
catch22 = Catch22()
X_features = catch22.fit_transform(X_train)
print(f"Catch22 feature matrix: {X_features.shape}") # (n_instances, 22)
Module 6: Dataset Loading and Benchmarking
from aeon.datasets import load_classification, load_from_tsf_file
from aeon.benchmarking.results_loaders import get_estimator_results_as_array
import numpy as np
# Load UCR/UEA dataset
X_train, y_train = load_classification("BasicMotions", split="train")
X_test, y_test = load_classification("BasicMotions", split="test")
print(f"BasicMotions: train={X_train.shape}, classes={np.unique(y_train)}")
# Load custom .ts file (UCR format)
# X, y = load_from_tsf_file("my_dataset.ts")
# Compare to published benchmark results
# from aeon.benchmarking import plot_critical_difference_diagram
Key Concepts
Data Format Convention
aeon uses 3D NumPy arrays of shape (n_instances, n_channels, n_timepoints):
n_instances: number of time series (liken_samplesin sklearn)n_channels: number of variables per time series (1 for univariate)n_timepoints: length of each series (can differ between instances for unequal-length)
Scikit-learn expects 2D (n_samples, n_features). Use aeon's transformers to convert.
ROCKET Family
ROCKET (Random Convolutional Kernel Transform) randomly generates 10,000 convolutional kernels of varying lengths, dilations, and biases, then applies them to each series to extract PPV (proportion of positive values) and max features. These 20,000 features are then classified with a linear model. MiniROCKET uses fewer, fixed kernels and is ~75× faster than ROCKET with similar accuracy. MultiROCKET extends to multivariate series.
Common Workflows
Workflow 1: Cross-Validated Classifier Comparison
import numpy as np
from aeon.datasets import load_unit_test
from aeon.classification.convolution_based import RocketClassifier, MiniRocketClassifier
from aeon.classification.feature_based import Catch22Classifier
from sklearn.model_selection import cross_val_score
X, y = load_unit_test() # Full dataset (train+test merged)
classifiers = {
"ROCKET": RocketClassifier(num_kernels=500, random_state=42),
"MiniROCKET": MiniRocketClassifier(random_state=42),
"Catch22": Catch22Classifier(random_state=42),
}
print(f"Dataset: {X.shape}, classes: {np.unique(y)}")
for name, clf in classifiers.items():
scores = cross_val_score(clf, X, y, cv=5, scoring="accuracy")
print(f"{name:15s}: {scores.mean():.3f} ± {scores.std():.3f}")
Workflow 2: Pipeline with Preprocessing and Classification
import numpy as np
from aeon.datasets import load_unit_test
from aeon.transformations.collection.convolution_based import Rocket
from aeon.transformations.collection.normalize import TimeSeriesScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import RidgeClassifierCV
from sklearn.preprocessing import StandardScaler
X_train, y_train = load_unit_test(split="train")
X_test, y_test = load_unit_test(split="test")
# Normalize series → ROCKET features → Ridge
pipe = Pipeline([
("normalize", TimeSeriesScaler()),
("rocket", Rocket(num_kernels=2000, random_state=42)),
("scale", StandardScaler(with_mean=False)),
("clf", RidgeClassifierCV(alphas=[0.01, 0.1, 1.0, 10.0])),
])
pipe.fit(X_train, y_train)
print(f"Pipeline accuracy: {pipe.score(X_test, y_test):.3f}")
Key Parameters
| Parameter | Module/Class | Default | Range / Options | Effect |
|---|---|---|---|---|
num_kernels |
Rocket, RocketClassifier |
10000 | 500–50000 | Number of random kernels; more = better accuracy, slower |
n_clusters |
TimeSeriesKMeans |
8 | 2–50 | Number of clusters for K-Means |
metric |
TimeSeriesKMeans, KNN |
"dtw" |
"dtw", "euclidean", "msm", "twe" |
Distance metric for similarity computation |
n_change_points |
ClaSPSegmenter |
1 | 1–20 | Expected number of change points to detect |
period_length |
ClaSPSegmenter |
auto | 5–100 | Minimum segment length |
n_neighbors |
KNeighborsTimeSeriesClassifier |
1 | 1–20 | k for KNN; k=1 often best for time series |
averaging_method |
TimeSeriesKMeans |
"ba" |
"ba" (Barycentre), "mean" |
Method for computing cluster prototypes |
random_state |
All stochastic | None | int | Seed for reproducibility |
Common Recipes
Recipe: Export ROCKET Features to DataFrame for AutoML
import numpy as np
import pandas as pd
from aeon.transformations.collection.convolution_based import MiniRocket
np.random.seed(0)
X = np.random.randn(200, 1, 100) # 200 univariate time series, length 100
y = (X[:, 0, :].mean(axis=1) > 0).astype(int)
transformer = MiniRocket(random_state=42)
X_features = transformer.fit_transform(X) # shape: (200, 9996)
df = pd.DataFrame(X_features, columns=[f"f{i}" for i in range(X_features.shape[1])])
df["label"] = y
df.to_parquet("rocket_features.parquet", index=False)
print(f"Feature matrix: {df.shape}, saved to rocket_features.parquet")
Recipe: Load and Classify a Custom Dataset
import numpy as np
from aeon.classification.convolution_based import RocketClassifier
# Custom dataset: time series stored as 3D array
# Shape: (n_instances, n_channels, n_timepoints)
np.random.seed(42)
X_train = np.random.randn(150, 2, 80) # 150 bivariate series, length 80
y_train = np.random.choice(["class_A", "class_B", "class_C"], 150)
X_test = np.random.randn(50, 2, 80)
y_test = np.random.choice(["class_A", "class_B", "class_C"], 50)
clf = RocketClassifier(num_kernels=2000, random_state=42)
clf.fit(X_train, y_train)
print(f"Test accuracy: {clf.score(X_test, y_test):.3f}")
# Save predictions
y_pred = clf.predict(X_test)
print(f"Predictions: {y_pred[:10]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: numba when using ROCKET |
numba not installed | pip install aeon[all_extras] or pip install numba |
| Input shape error: expected 3D array | Passing 2D (n, t) instead of 3D (n, 1, t) |
Reshape: X = X[:, np.newaxis, :] |
| KNN-DTW very slow on large datasets | DTW is O(n·m) per pair; all-pairs comparison is O(N²) | Use ROCKET instead for N>1000; or reduce series length |
| Clustering assigns all points to one cluster | Initial centroids too close or metric not suited | Set n_init=10; try different metric ("msm" or "euclidean") |
load_classification fails with download error |
Network timeout or dataset name typo | Check available datasets: aeon.datasets.list_datasets(); download manually |
ClaSPSegmenter detects wrong number of change points |
n_change_points too high or period_length wrong |
Set period_length to expected minimum segment length; validate with known signal |
| Memory error with large panel dataset | ROCKET transforms require n_kernels × n_instances matrix |
Reduce num_kernels; process in batches with partial_fit |
Related Skills
neurokit2— time series generation (ECG, EDA) that feeds into aeon classifiersscikit-learn-machine-learning— sklearn-compatible downstream models for aeon featuresmatplotlib-scientific-plotting— visualization of time series and classification results
References
- aeon documentation — API reference, algorithm catalog, and tutorials
- aeon GitHub — source code, benchmarks, and contribution guide
- ROCKET paper: Dempster et al. (2020), DMKD — ROCKET algorithm description and UCR benchmark results
- MiniROCKET paper: Dempster et al. (2021), KDD — faster variant with comparable accuracy
- UCR Time Series Archive — 128 benchmark datasets for time series classification
skills/scientific-computing/astropy-astronomy/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill astropy-astronomy -g -y
SKILL.md
Frontmatter
{
"name": "astropy-astronomy",
"license": "BSD-3-Clause",
"description": "Core Python library for astronomy\/astrophysics: units with dimensional analysis, celestial coordinate transforms (ICRS\/Galactic\/AltAz\/FK5), FITS I\/O, tables (FITS\/HDF5\/VOTable\/CSV), cosmology (Planck18, distance\/age), precise time (UTC\/TAI\/TT\/TDB, Julian, barycentric), WCS pixel-world mapping, model fitting. For general tables use pandas\/polars; for radio interferometry use CASA."
}
Astropy — Astronomy & Astrophysics Toolkit
Overview
Astropy is the core Python package for astronomy, providing essential functionality for astronomical research: unit-aware calculations, celestial coordinate transformations, FITS file I/O, cosmological calculations, precise time handling, tabular data operations, and WCS image coordinate mapping.
When to Use
- Converting between celestial coordinate systems (ICRS, Galactic, FK5, AltAz)
- Working with physical quantities and units (Jy→mJy, parsec→km, spectral equivalencies)
- Reading, writing, or manipulating FITS files (images and tables)
- Cosmological calculations (luminosity distance, lookback time, comoving volume)
- Precise time handling with multiple scales (UTC, TAI, TT, TDB) and formats (JD, MJD, ISO)
- Cross-matching astronomical catalogs by sky position
- WCS transformations between pixel and world coordinates
- For general tabular data: use pandas or polars instead
- For radio interferometry: use CASA instead
Prerequisites
pip install astropy # Core package
pip install astropy[all] # With optional dependencies (regions, photutils, etc.)
pip install pytz # For timezone conversions
Quick Start
import astropy.units as u
from astropy.coordinates import SkyCoord
from astropy.time import Time
from astropy.io import fits
from astropy.table import Table
from astropy.cosmology import Planck18
# Units and quantities
distance = 100 * u.pc
print(f"{distance.to(u.km):.3e}") # 3.086e+15 km
# Coordinates
coord = SkyCoord(ra=10.5*u.degree, dec=41.2*u.degree, frame='icrs')
print(f"Galactic: l={coord.galactic.l:.2f}, b={coord.galactic.b:.2f}")
# Cosmology
d_L = Planck18.luminosity_distance(z=1.0)
print(f"Luminosity distance at z=1: {d_L:.1f}") # ~6780 Mpc
# Time
t = Time('2023-01-15 12:30:00')
print(f"JD: {t.jd:.6f}, MJD: {t.mjd:.6f}")
Core API
1. Units & Quantities (astropy.units)
import astropy.units as u
import numpy as np
# Create quantities
distance = 10 * u.kpc
flux = 3.5e-15 * u.erg / u.s / u.cm**2
wavelength = 6563 * u.Angstrom
# Unit conversions
distance_ly = distance.to(u.lyr)
flux_jy = flux.to(u.Jy, equivalencies=u.spectral_density(wavelength))
print(f"Distance: {distance_ly:.2f}")
# Arithmetic with automatic unit tracking
velocity = 300 * u.km / u.s
time = 1 * u.Gyr
distance_traveled = (velocity * time).to(u.Mpc)
print(f"Distance traveled: {distance_traveled:.2f}")
# Equivalencies for domain-specific conversions
freq = wavelength.to(u.Hz, equivalencies=u.spectral())
energy = wavelength.to(u.eV, equivalencies=u.spectral())
parallax_dist = (0.1 * u.arcsec).to(u.pc, equivalencies=u.parallax())
print(f"Frequency: {freq:.3e}, Parallax distance: {parallax_dist:.1f}")
# Logarithmic units (magnitudes)
mag = -2.5 * u.mag
flux_ratio = mag.to(u.dimensionless_unscaled)
# Performance: pre-compute composite units
flux_unit = u.erg / u.s / u.cm**2 / u.Angstrom
fluxes = np.array([1e-15, 2e-15, 3e-15]) * flux_unit
# Custom units
bbl = u.def_unit('bbl', 158.987 * u.liter)
2. Coordinate Systems (astropy.coordinates)
from astropy.coordinates import SkyCoord, EarthLocation, AltAz
from astropy.time import Time
import astropy.units as u
# Create coordinates (multiple formats)
c = SkyCoord(ra='05h23m34.5s', dec='-69d45m22s', frame='icrs')
c = SkyCoord(ra=10.5*u.degree, dec=41.2*u.degree)
c = SkyCoord(l=280*u.degree, b=-30*u.degree, frame='galactic')
# Transform between frames
c_gal = c.galactic
c_fk5 = c.fk5
print(f"Galactic: l={c_gal.l:.4f}, b={c_gal.b:.4f}")
# Observer-dependent AltAz (requires time + location)
location = EarthLocation(lat=40*u.deg, lon=-120*u.deg, height=1000*u.m)
obstime = Time('2023-06-15 23:00:00')
altaz = c.transform_to(AltAz(obstime=obstime, location=location))
print(f"Alt={altaz.alt:.2f}, Az={altaz.az:.2f}")
# Angular separation and matching
c1 = SkyCoord(ra=10*u.deg, dec=20*u.deg)
c2 = SkyCoord(ra=10.1*u.deg, dec=20.05*u.deg)
sep = c1.separation(c2)
print(f"Separation: {sep.arcsec:.2f} arcsec")
# Catalog matching
from astropy.coordinates import match_coordinates_sky
idx, sep, _ = coords1.match_to_catalog_sky(coords2)
matches = sep < 1 * u.arcsec
# Named object lookup
m31 = SkyCoord.from_name('M31')
# 3D coordinates with distance
c3d = SkyCoord(ra=10*u.deg, dec=20*u.deg, distance=50*u.kpc)
print(f"Cartesian: {c3d.cartesian}")
# Velocity information
c_vel = SkyCoord(ra=10*u.deg, dec=20*u.deg,
pm_ra_cosdec=5*u.mas/u.yr, pm_dec=-3*u.mas/u.yr,
radial_velocity=100*u.km/u.s)
3. FITS File Handling (astropy.io.fits)
from astropy.io import fits
import numpy as np
# Read FITS file
with fits.open('observation.fits') as hdul:
hdul.info() # Show HDU structure
data = hdul[0].data # Image data as NumPy array
header = hdul[0].header # Header as dict-like object
# Access header values
exptime = header['EXPTIME']
header['OBSERVER'] = 'Smith' # Modify
header.add_history('Processed with astropy')
# Convenience functions
data = fits.getdata('image.fits')
header = fits.getheader('image.fits')
value = fits.getval('image.fits', 'EXPTIME')
# Create new FITS file
hdu_primary = fits.PrimaryHDU(data=np.zeros((100, 100)))
hdu_primary.header['OBJECT'] = 'M31'
# Multi-extension file
hdu_image = fits.ImageHDU(data=np.random.random((256, 256)), name='SCI')
hdu_table = fits.BinTableHDU.from_columns([
fits.Column(name='ID', format='J', array=np.arange(100)),
fits.Column(name='FLUX', format='E', array=np.random.random(100)),
fits.Column(name='NAME', format='20A', array=['star']*100)
])
hdul = fits.HDUList([hdu_primary, hdu_image, hdu_table])
hdul.writeto('output.fits', overwrite=True)
# Large file handling with memory mapping
hdul = fits.open('huge.fits', memmap=True)
cutout = hdul[0].section[100:200, 100:200] # Read only a slice
4. Table Operations (astropy.table)
from astropy.table import Table, QTable
import astropy.units as u
import numpy as np
# Create tables
t = Table({'ra': [10.0, 20.0, 30.0], 'dec': [41.0, 42.0, 43.0],
'mag': [15.2, 16.1, 14.8]})
# Read from file (auto-detect format)
t = Table.read('catalog.fits')
t = Table.read('data.csv', format='csv')
t = Table.read('catalog.vot', format='votable')
# Unit-aware QTable
qt = QTable({'distance': [10, 20, 30] * u.kpc,
'flux': [1e-15, 2e-15, 3e-15] * u.erg / u.s / u.cm**2})
# Filter, sort, column operations
bright = t[t['mag'] < 15.5]
t.sort('mag')
t['abs_mag'] = t['mag'] - 5 * np.log10(100)
print(f"Rows: {len(t)}, Columns: {t.colnames}")
# Joins and grouping
from astropy.table import join, vstack, hstack
# Database-style join
merged = join(t1, t2, keys='id', join_type='inner')
# Stack tables
combined = vstack([t1, t2, t3]) # Vertical (row-append)
combined = hstack([t_coords, t_phot]) # Horizontal (column-append)
# Group and aggregate
grouped = t.group_by('field')
stats = grouped.groups.aggregate(np.mean)
# Write
t.write('output.fits', format='fits', overwrite=True)
t.write('output.ecsv', format='ascii.ecsv') # Preserves units + metadata
5. Time Handling (astropy.time)
from astropy.time import Time, TimeDelta
import astropy.units as u
import numpy as np
# Create from various formats
t = Time('2023-01-15 12:30:45', format='iso', scale='utc')
t = Time(2460000.0, format='jd')
t = Time(59945.0, format='mjd')
t = Time(1673785845.0, format='unix')
# Convert between formats and scales
print(f"ISO: {t.iso}")
print(f"JD: {t.jd}, MJD: {t.mjd}")
print(f"TAI: {t.tai.iso}") # UTC → TAI (includes leap seconds)
print(f"TDB: {t.tdb.iso}") # UTC → Barycentric Dynamical Time
# Time arithmetic
dt = TimeDelta(7, format='jd')
t_future = t + dt
t_future = t + 1 * u.hour
duration = Time('2024-01-01') - Time('2023-01-01')
print(f"Duration: {duration.jd:.1f} days")
# Array of times
times = Time('2023-01-01') + np.arange(365) * u.day
# Observing features
from astropy.coordinates import SkyCoord, EarthLocation
location = EarthLocation.of_site('Keck Observatory')
t = Time('2023-06-15 23:00:00', location=location)
# Sidereal time
lst = t.sidereal_time('apparent')
print(f"LST: {lst}")
# Barycentric correction
target = SkyCoord(ra='23h23m08.55s', dec='+18d24m59.3s')
ltt = t.light_travel_time(target, kind='barycentric')
t_bary = t.tdb + ltt
print(f"Barycentric correction: {ltt.sec:.3f} seconds")
6. Cosmological Calculations (astropy.cosmology)
from astropy.cosmology import Planck18, FlatLambdaCDM
import astropy.units as u
import numpy as np
# Built-in cosmologies: Planck18, Planck15, Planck13, WMAP9, WMAP7
z = 1.5
# Distance calculations
d_L = Planck18.luminosity_distance(z)
d_A = Planck18.angular_diameter_distance(z)
d_C = Planck18.comoving_distance(z)
dm = Planck18.distmod(z) # Distance modulus
print(f"d_L={d_L:.1f}, d_A={d_A:.1f}, d_C={d_C:.1f}")
# Time calculations
age = Planck18.age(z)
lookback = Planck18.lookback_time(z)
print(f"Age at z={z}: {age.to(u.Gyr):.2f}")
print(f"Lookback time: {lookback.to(u.Gyr):.2f}")
# Scale and volume
scale = Planck18.kpc_proper_per_arcmin(z)
vol = Planck18.comoving_volume(z)
print(f"Scale: {scale:.2f}")
# Inverse calculations — find z for given property
from astropy.cosmology import z_at_value
z_10gyr = z_at_value(Planck18.lookback_time, 10 * u.Gyr)
z_1gpc = z_at_value(Planck18.comoving_distance, 1 * u.Gpc)
print(f"z at lookback 10 Gyr: {z_10gyr:.4f}")
# Custom cosmology
cosmo = FlatLambdaCDM(H0=70, Om0=0.3, Tcmb0=2.725)
d_L_custom = cosmo.luminosity_distance(z=1.0)
# Array operations (all methods accept arrays)
z_array = np.linspace(0.1, 3.0, 100)
distances = Planck18.luminosity_distance(z_array)
print(f"Distance array shape: {distances.shape}") # (100,)
7. WCS & Image Processing
from astropy.wcs import WCS
from astropy.io import fits
import astropy.units as u
# Read WCS from FITS
with fits.open('image.fits') as hdul:
wcs = WCS(hdul[0].header)
# Pixel ↔ world transformations
world = wcs.pixel_to_world(100, 200) # Returns SkyCoord
print(f"RA: {world.ra:.6f}, Dec: {world.dec:.6f}")
from astropy.coordinates import SkyCoord
coord = SkyCoord(ra=10.5*u.degree, dec=41.2*u.degree)
x, y = wcs.world_to_pixel(coord)
# WCS properties
print(f"Ref pixel: {wcs.wcs.crpix}")
print(f"Ref value: {wcs.wcs.crval}")
print(f"Pixel scale: {wcs.proj_plane_pixel_scales()}")
footprint = wcs.calc_footprint() # Corner coordinates
# Image visualization
from astropy.visualization import simple_norm, ZScaleInterval, AsinhStretch, ImageNormalize
import matplotlib.pyplot as plt
data = fits.getdata('image.fits')
norm = simple_norm(data, 'sqrt', percent=99)
plt.imshow(data, norm=norm, cmap='gray', origin='lower')
plt.colorbar()
# Advanced normalization
interval = ZScaleInterval()
stretch = AsinhStretch()
norm = ImageNormalize(data, interval=interval, stretch=stretch)
# Sigma clipping for robust statistics
from astropy.stats import sigma_clipped_stats
mean, median, std = sigma_clipped_stats(data, sigma=3.0)
print(f"Background: {median:.2f} ± {std:.2f}")
Key Concepts
Unit Equivalency System
Astropy's equivalencies parameter enables domain-specific conversions that are not dimensionally equivalent:
| Equivalency | Converts Between | Example |
|---|---|---|
u.spectral() |
Wavelength ↔ frequency ↔ energy | (500*u.nm).to(u.THz, u.spectral()) |
u.spectral_density(wav) |
Flux density (Fλ ↔ Fν ↔ Jy) | flux.to(u.Jy, u.spectral_density(wav)) |
u.parallax() |
Parallax angle ↔ distance | (10*u.mas).to(u.pc, u.parallax()) |
u.doppler_optical(rest) |
Velocity ↔ wavelength (optical) | vel.to(u.Angstrom, u.doppler_optical(rest)) |
u.brightness_temperature(freq) |
Flux ↔ temperature | For radio astronomy |
Time Scales
| Scale | Description | Use When |
|---|---|---|
| UTC | Coordinated Universal Time (with leap seconds) | Default; civil time |
| TAI | International Atomic Time (UTC + leap seconds) | Continuous timekeeping |
| TT | Terrestrial Time (TAI + 32.184s) | Geocentric calculations |
| TDB | Barycentric Dynamical Time | Solar system dynamics, ephemerides |
| UT1 | Earth rotation angle | Sidereal time, AltAz transforms |
Access via: t.utc, t.tai, t.tt, t.tdb, t.ut1
Coordinate Frame Hierarchy
- ICRS — International Celestial Reference System (default, ~J2000)
- FK5 / FK4 — Historical equatorial frames (FK4 requires equinox)
- Galactic — Galactic coordinates (l, b)
- AltAz — Observer-dependent (requires
obstime+location) - Ecliptic — Solar system plane
- Galactocentric — Galaxy-centered Cartesian
Common Workflows
Workflow 1: Coordinate Conversion Pipeline
from astropy.coordinates import SkyCoord, EarthLocation, AltAz
from astropy.time import Time
import astropy.units as u
# Load catalog of sources
from astropy.table import Table
cat = Table.read('sources.fits')
coords = SkyCoord(ra=cat['RA']*u.degree, dec=cat['DEC']*u.degree)
# Transform to galactic
gal = coords.galactic
print(f"Galactic l range: {gal.l.min():.1f} to {gal.l.max():.1f}")
# Check observability (AltAz)
location = EarthLocation.of_site('Paranal Observatory')
obstime = Time('2023-06-15 23:00:00')
altaz = coords.transform_to(AltAz(obstime=obstime, location=location))
observable = altaz.alt > 30 * u.deg
print(f"Observable (alt>30°): {observable.sum()} of {len(coords)}")
Workflow 2: FITS Image Analysis
from astropy.io import fits
from astropy.wcs import WCS
from astropy.stats import sigma_clipped_stats
from astropy.visualization import simple_norm
import numpy as np
# Load image and WCS
with fits.open('science_image.fits') as hdul:
data = hdul[0].data.astype(float)
wcs = WCS(hdul[0].header)
# Background statistics
mean, median, std = sigma_clipped_stats(data, sigma=3.0)
print(f"Background: {median:.2f} ± {std:.2f}")
# Find bright pixels (simple threshold detection)
threshold = median + 5 * std
sources = np.where(data > threshold)
print(f"Pixels above 5σ: {len(sources[0])}")
# Convert pixel positions to sky coordinates
sky_coords = wcs.pixel_to_world(sources[1], sources[0])
print(f"RA range: {sky_coords.ra.min():.4f} to {sky_coords.ra.max():.4f}")
Workflow 3: Catalog Cross-Matching
from astropy.table import Table
from astropy.coordinates import SkyCoord
import astropy.units as u
# Read two catalogs
cat1 = Table.read('catalog1.fits')
cat2 = Table.read('catalog2.fits')
coords1 = SkyCoord(ra=cat1['RA']*u.degree, dec=cat1['DEC']*u.degree)
coords2 = SkyCoord(ra=cat2['RA']*u.degree, dec=cat2['DEC']*u.degree)
# Match
idx, sep, _ = coords1.match_to_catalog_sky(coords2)
max_sep = 1 * u.arcsec
matches = sep < max_sep
cat1_matched = cat1[matches]
cat2_matched = cat2[idx[matches]]
print(f"Matched: {matches.sum()} of {len(cat1)} (within {max_sep})")
Key Parameters
| Parameter | Module | Default | Description |
|---|---|---|---|
frame |
SkyCoord | 'icrs' |
Coordinate reference frame |
scale |
Time | 'utc' |
Time scale (utc, tai, tt, tdb, ut1) |
format |
Time | auto | Time format (iso, jd, mjd, unix, etc.) |
equivalencies |
.to() |
None | Domain-specific unit conversion rules |
memmap |
fits.open |
True | Memory-map large files |
join_type |
join() |
'inner' |
Join type (inner, outer, left, right) |
sigma |
sigma_clip |
3.0 | Clipping threshold in standard deviations |
stretch |
simple_norm |
'linear' |
Image stretch (linear, sqrt, log, asinh) |
percent |
simple_norm |
100 | Percentile for normalization limits |
Best Practices
-
Always attach units — Use
Quantityobjects (e.g.,10 * u.kpc) to prevent dimensional errors. Bare numbers silently produce wrong results. -
Use context managers for FITS —
with fits.open(...) as hdul:ensures proper file closing and memory map cleanup. -
Process arrays, not loops — All astropy operations accept arrays. Process 10,000 coordinates at once instead of looping.
-
Be explicit about time scales —
Time('2023-01-15', scale='utc')prevents ambiguity. UTC↔TDB differences matter for precision timing. -
Use QTable for unit-aware columns —
QTablepreserves units through I/O; plainTablestores units as metadata only. -
Use ECSV for round-trip fidelity —
t.write('file.ecsv')preserves units, dtypes, and metadata. CSV/FITS lose some metadata. -
Anti-pattern — Wrong cosmology model: Always specify which cosmology you're using. Different models give different distances at the same redshift. Planck18 is current standard.
-
Anti-pattern — Ignoring WCS origin convention: astropy uses 0-based pixel coordinates. FITS standard uses 1-based. Use
wcs.pixel_to_world()(handles this automatically) instead of manual calculations.
Common Recipes
Recipe: Custom Cosmology with Neutrinos
from astropy.cosmology import FlatLambdaCDM
import astropy.units as u
cosmo = FlatLambdaCDM(
H0=67.66, Om0=0.3111, Tcmb0=2.7255,
Neff=3.046, m_nu=[0, 0, 0.06] * u.eV
)
print(f"Age of universe: {cosmo.age(0).to(u.Gyr):.3f}")
Recipe: Model Fitting
from astropy.modeling import models, fitting
import numpy as np
# Generate noisy Gaussian data
x = np.linspace(0, 10, 100)
y = 10 * np.exp(-0.5 * ((x - 5) / 1.0)**2) + np.random.normal(0, 0.5, 100)
# Fit
fitter = fitting.LevMarLSQFitter()
model = models.Gaussian1D(amplitude=8, mean=4, stddev=1.5)
fitted = fitter(model, x, y)
print(f"Amplitude: {fitted.amplitude.value:.2f}")
print(f"Mean: {fitted.mean.value:.2f}")
print(f"Stddev: {fitted.stddev.value:.2f}")
Recipe: NDData and CCDData
from astropy.nddata import CCDData, StdDevUncertainty
import astropy.units as u
import numpy as np
# Create CCDData with uncertainty
data = np.random.random((256, 256))
uncertainty = StdDevUncertainty(np.sqrt(np.abs(data)))
ccd = CCDData(data, unit=u.adu, uncertainty=uncertainty,
meta={'OBJECT': 'M31', 'EXPTIME': 300.0})
# Read/write
ccd.write('processed.fits', overwrite=True)
ccd2 = CCDData.read('processed.fits', unit=u.adu)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
UnitConversionError |
Incompatible units without equivalency | Add equivalencies=u.spectral() or appropriate equivalency |
| Wrong coordinate frame after transform | Missing obstime/location for AltAz |
Provide both: AltAz(obstime=t, location=loc) |
ErfaWarning: dubious year |
Time outside 1960–2040 range for UT1 | Use scale='tt' or scale='tdb' for extreme dates |
FileNotFoundError for IERS data |
Leap second table not downloaded | Run from astropy.utils.iers import IERS_Auto; IERS_Auto.open() |
FITS header VerifyError |
Non-standard FITS keywords | Use fits.open(f, ignore_missing_end=True) or hdul.verify('fix') |
| Slow coordinate transforms | Looping over single coordinates | Use array SkyCoord: SkyCoord(ra=ra_array, dec=dec_array) |
QTable loses units on write |
Using CSV format | Use ECSV format: qt.write('file.ecsv') |
KeyError accessing FITS extension |
Wrong extension index | Use hdul.info() to see structure; access by name: hdul['SCI'] |
| Inaccurate barycentric correction | Wrong time scale or missing location | Use scale='utc', set location on Time object |
ModelFit doesn't converge |
Bad initial parameters | Provide closer initial guesses; try SimplexLSQFitter |
Bundled Resources
references/data_io_guide.md— Detailed FITS operations (headers, multi-extension, binary tables, column format codes, memory mapping, remote access), Table operations (creation, I/O formats, joins, grouping, indexing, QTable, masked data, display, performance), and collection conversion patterns. Consolidated from originalfits.mdandtables.md.references/coordinates_time_cosmology.md— Complete coordinate system reference (all frames, 3D coordinates, proper motions, representations, catalog matching), time handling (all formats, all scales, TimeDelta, sidereal time, light travel time, barycentric corrections, precision), and cosmological models (all built-ins, custom models, distances, volumes, inverse calculations, neutrino effects). Consolidated from originalcoordinates.md,time.md, andcosmology.md.references/auxiliary_modules.md— WCS detailed operations, NDData/CCDData, modeling framework (1D/2D models, fitting, compound models), image visualization (stretches, intervals, normalization), constants catalog, convolution, robust statistics, SAMP interoperability, data download utilities. Consolidated from originalwcs_and_other_modules.mdandunits.md(equivalency details).
Not migrated as separate files: Original had 7 reference files. Consolidated into 3 topical reference files covering all capabilities. units.md equivalency content split between SKILL.md Key Concepts (summary table) and auxiliary_modules.md (detailed code).
Related Skills
- matplotlib-scientific-plotting — Visualization library used for astropy image display and plot generation
- zarr-python — Chunked array storage; used with astropy for large astronomical datasets
References
- Official Documentation: https://docs.astropy.org/en/stable/
- Tutorials: https://learn.astropy.org/
- GitHub: https://github.com/astropy/astropy
- Coordinate Frame Reference: https://docs.astropy.org/en/stable/coordinates/
skills/scientific-computing/dask-parallel-computing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill dask-parallel-computing -g -y
SKILL.md
Frontmatter
{
"name": "dask-parallel-computing",
"license": "BSD-3-Clause",
"description": "Parallel\/distributed computing for larger-than-RAM data. Components: DataFrames (parallel pandas), Arrays (parallel NumPy), Bags, Futures, Schedulers. Scales laptop to HPC cluster. For single-machine speed use polars; for out-of-core without cluster use vaex."
}
Dask — Parallel & Distributed Computing
Overview
Dask is a Python library for parallel and distributed computing that scales familiar pandas/NumPy APIs to larger-than-memory datasets. It provides five main components (DataFrames, Arrays, Bags, Futures, Schedulers) and scales from single-machine multi-core to multi-node HPC clusters.
When to Use
- Processing datasets that exceed available RAM (10 GB–100 TB)
- Parallelizing pandas or NumPy operations across multiple cores
- Processing multiple files efficiently (CSV, Parquet, JSON, HDF5, Zarr)
- Building custom parallel workflows with task dependencies
- Distributing workloads across HPC clusters (SLURM, Kubernetes)
- Streaming/ETL pipelines for unstructured data (logs, JSON records)
- For in-memory single-machine speed: use polars instead
- For out-of-core single-machine analytics: use vaex instead
Prerequisites
pip install dask[complete] # All components
pip install dask[dataframe] # DataFrames only
pip install dask[distributed] # Distributed scheduler + dashboard
pip install dask-jobqueue # HPC cluster integration (SLURM, PBS)
Core API
1. DataFrames — Parallel Pandas
import dask.dataframe as dd
# Read multiple files as a single DataFrame
ddf = dd.read_csv('data/2024-*.csv')
ddf = dd.read_parquet('data/', columns=['id', 'value', 'category'])
# Operations are lazy until .compute()
filtered = ddf[ddf['value'] > 100]
result = filtered.groupby('category').agg({'value': ['mean', 'sum']}).compute()
print(result.shape) # (n_categories, 2)
# Custom operations via map_partitions (preferred over apply)
def normalize_partition(df):
df['norm_value'] = (df['value'] - df['value'].mean()) / df['value'].std()
return df
ddf = ddf.map_partitions(normalize_partition)
# Joins
ddf_merged = ddf.merge(lookup_ddf, on='category', how='left')
# Write results
ddf.to_parquet('output/', engine='pyarrow')
# Repartitioning for optimal chunk sizes
ddf = ddf.repartition(npartitions=20) # By count
ddf = ddf.repartition(partition_size='100MB') # By size
# Index management for sorted operations
ddf = ddf.set_index('timestamp', sorted=True)
# Debugging
print(f"Partitions: {ddf.npartitions}")
print(f"Dtypes: {ddf.dtypes}")
sample = ddf.get_partition(0).compute() # Inspect first partition
2. Arrays — Parallel NumPy
import dask.array as da
import numpy as np
# Create from various sources
x = da.random.random((100000, 1000), chunks=(10000, 1000))
x = da.from_array(np_array, chunks=(10000, 1000))
x = da.from_zarr('large_dataset.zarr')
# Standard operations (lazy)
y = (x - x.mean(axis=0)) / x.std(axis=0) # Normalize
z = da.dot(x.T, x) # Matrix multiply
u, s, v = da.linalg.svd(x) # SVD
# Compute and persist
result = y.mean(axis=0).compute()
print(result.shape) # (1000,)
# Custom operations with map_blocks
def custom_filter(block):
from scipy.ndimage import gaussian_filter
return gaussian_filter(block, sigma=2)
filtered = da.map_blocks(custom_filter, x, dtype=x.dtype)
# Rechunking for different access patterns
x_rechunked = x.rechunk({0: 5000, 1: 500})
# Save to disk
da.to_zarr(y, 'normalized.zarr')
3. Bags — Unstructured Data Processing
import dask.bag as db
import json
# Read unstructured data
bag = db.read_text('logs/*.json').map(json.loads)
# Functional operations
valid = bag.filter(lambda x: x['status'] == 'success')
ids = valid.pluck('user_id')
flat = bag.map(lambda x: x['tags']).flatten()
# Aggregation — use foldby instead of groupby (much faster)
counts = bag.foldby(
key='category',
binop=lambda total, x: total + x['amount'],
initial=0,
combine=lambda a, b: a + b,
combine_initial=0
).compute()
# Convert to DataFrame for structured analysis
ddf = valid.to_dataframe(meta={'user_id': 'str', 'amount': 'float64', 'category': 'str'})
4. Futures — Task-Based Parallelism
from dask.distributed import Client
client = Client() # Local cluster with all cores
print(client.dashboard_link) # http://localhost:8787
# Submit individual tasks (executes immediately, not lazy)
def process(x, param):
return x ** param
future = client.submit(process, 42, param=2)
print(future.result()) # 1764
# Map over many inputs
futures = client.map(process, range(100), param=2)
results = client.gather(futures)
print(len(results)) # 100
# Scatter large data to workers (avoids repeated transfers)
import numpy as np
big_data = np.random.random((10000, 1000))
data_future = client.scatter(big_data, broadcast=True)
# Submit tasks using scattered data
futures = [client.submit(process_chunk, data_future, i) for i in range(10)]
results = client.gather(futures)
# Progressive result processing
from dask.distributed import as_completed
for future in as_completed(futures):
result = future.result()
print(f"Completed: {result}")
# Coordination primitives
from dask.distributed import Lock, Queue, Event
lock = Lock('resource-lock')
with lock:
# Thread-safe operation across workers
pass
client.close()
5. Schedulers & Configuration
import dask
# Global scheduler setting
dask.config.set(scheduler='threads') # Default: GIL-releasing numeric work
dask.config.set(scheduler='processes') # Pure Python, GIL-bound work
dask.config.set(scheduler='synchronous') # Debugging with pdb
# Context manager for temporary change
with dask.config.set(scheduler='synchronous'):
result = computation.compute() # Can use pdb here
# Per-compute override
result = ddf.mean().compute(scheduler='processes')
# Distributed scheduler with resource control
from dask.distributed import Client
client = Client(n_workers=4, threads_per_worker=2, memory_limit='4GB')
print(client.dashboard_link)
# HPC cluster integration
from dask_jobqueue import SLURMCluster
from dask.distributed import Client
cluster = SLURMCluster(
cores=24, memory='100GB',
walltime='02:00:00', queue='regular'
)
cluster.scale(jobs=10) # Request 10 SLURM jobs
client = Client(cluster)
# Adaptive scaling
cluster.adapt(minimum=2, maximum=20)
result = computation.compute()
client.close()
Key Concepts
Component Selection Guide
| Data Type | Component | When to Use |
|---|---|---|
| Tabular (CSV, Parquet) | DataFrames | Standard pandas-like operations at scale |
| Numeric arrays (HDF5, Zarr) | Arrays | NumPy operations, linear algebra, image processing |
| Text, JSON, logs | Bags | ETL/cleaning → convert to DataFrame for analysis |
| Custom parallel tasks | Futures | Dynamic workflows, parameter sweeps, task dependencies |
| Any of above | Schedulers | Control execution backend (threads/processes/distributed) |
Control level: DataFrames/Arrays/Bags = high-level lazy API. Futures = low-level immediate execution.
Lazy Evaluation Model
All DataFrames, Arrays, and Bags build a task graph — nothing executes until .compute() or .persist().
.compute()— execute and return result to local memory.persist()— execute and keep result on workers (for reuse across multiple computations)dask.compute(a, b, c)— compute multiple results in a single pass (shares intermediates)
Chunk Size Strategy
Target: ~100 MB per chunk (or 10 chunks per core in worker memory).
| Chunk Size | Effect |
|---|---|
| Too large (>1 GB) | Memory overflow, poor parallelization |
| Optimal (~100 MB) | Good parallelism, manageable memory |
| Too small (<1 MB) | Excessive scheduling overhead |
Example: 8 cores, 32 GB RAM → target ~400 MB per chunk (32 GB / 8 cores / 10).
Scheduler Selection Guide
| Scheduler | Overhead | Best For | GIL |
|---|---|---|---|
threads (default) |
~10 µs/task | NumPy, pandas, scikit-learn | Affected |
processes |
~10 ms/task | Pure Python, text processing | Not affected |
synchronous |
~1 µs/task | Debugging with pdb | N/A |
distributed |
~1 ms/task | Dashboard, clusters, advanced features | Configurable |
Common Workflows
Workflow 1: Multi-File ETL Pipeline
import dask.dataframe as dd
import dask
# Extract: Read all CSV files
ddf = dd.read_csv('raw_data/*.csv', dtype={'amount': 'float64'})
# Transform: Clean and process
ddf = ddf[ddf['status'] == 'valid']
ddf['amount'] = ddf['amount'].fillna(0)
ddf = ddf.dropna(subset=['category'])
# Aggregate
summary = ddf.groupby('category').agg({'amount': ['sum', 'mean', 'count']})
# Load: Save as Parquet (columnar, compressed)
summary.to_parquet('output/summary.parquet')
print(f"Processed {len(ddf)} rows across {ddf.npartitions} partitions")
Workflow 2: Large-Scale Array Processing
import dask.array as da
# Load large scientific dataset
x = da.from_zarr('experiment_data.zarr') # e.g., (50000, 50000) float64
print(f"Shape: {x.shape}, Chunks: {x.chunks}")
# Normalize per-column
x_norm = (x - x.mean(axis=0)) / x.std(axis=0)
# Compute covariance matrix
cov = da.dot(x_norm.T, x_norm) / (x_norm.shape[0] - 1)
# SVD for dimensionality reduction (top-k)
u, s, v = da.linalg.svd_compressed(x_norm, k=50)
# Save results
da.to_zarr(u, 'pca_components.zarr')
print(f"Explained variance (top 5): {(s[:5]**2 / (s**2).sum()).compute()}")
Workflow 3: Unstructured Data to Analysis
This workflow is a simple combination of Bags (Section 3) → DataFrames (Section 1): read JSON logs with Bags, filter/transform, convert to DataFrame for groupby analysis. Each step maps directly to Core API examples above.
Key Parameters
| Parameter | Module | Default | Description |
|---|---|---|---|
npartitions |
DataFrame | auto | Number of partitions (controls parallelism) |
partition_size |
DataFrame | — | Target size per partition (e.g., '100MB') |
chunks |
Array | required | Chunk dimensions (e.g., (10000, 1000)) |
blocksize |
Bag | '128 MiB' | File read block size |
scheduler |
All | 'threads' | Execution backend ('threads', 'processes', 'synchronous') |
n_workers |
Distributed | auto | Number of worker processes |
threads_per_worker |
Distributed | auto | Threads per worker |
memory_limit |
Distributed | auto | Per-worker memory limit (e.g., '4GB') |
sorted |
set_index |
False | Whether data is pre-sorted (enables optimizations) |
meta |
map_partitions |
— | Output DataFrame/Series structure template |
Best Practices
-
Let Dask handle data loading — Never load data into pandas/numpy first then convert. Use
dd.read_csv()/da.from_zarr()directly. -
Batch compute calls — Use
dask.compute(a, b, c)instead of calling.compute()in loops. Allows sharing intermediates. -
Use
map_partitionsoverapply—ddf.apply(func, axis=1)creates one task per row.ddf.map_partitions(func)creates one task per partition. -
Persist reused intermediates — Call
.persist()on data accessed multiple times, thendelwhen done. -
Use the dashboard —
client.dashboard_linkshows task progress, memory usage, worker states. Essential for diagnosing performance issues. -
Anti-pattern — Excessively large task graphs: If
len(ddf.__dask_graph__())returns millions, increase chunk sizes or usemap_partitions/map_blocksto fuse operations. -
Anti-pattern — Wrong scheduler for workload: Using threads for pure Python text processing (GIL-bound) or processes for NumPy operations (unnecessary serialization overhead).
Common Recipes
Recipe: Dask-ML Integration
from dask_ml.preprocessing import StandardScaler
from dask_ml.model_selection import train_test_split
import dask.array as da
X = da.random.random((100000, 50), chunks=(10000, 50))
y = da.random.randint(0, 2, size=100000, chunks=10000)
# Preprocessing
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Train/test split
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2)
print(f"Train: {X_train.shape}, Test: {X_test.shape}")
Recipe: Actors for Stateful Computation
from dask.distributed import Client
client = Client()
class RunningStats:
def __init__(self):
self.count = 0
self.total = 0.0
def add(self, value):
self.count += 1
self.total += value
return self.total / self.count
# Create actor on a worker
stats = client.submit(RunningStats, actor=True).result()
# Call methods (~1ms roundtrip)
for v in [10, 20, 30]:
mean = stats.add(v).result()
print(f"Running mean: {mean}")
client.close()
Recipe: Kubernetes Cluster
from dask_kubernetes import KubeCluster
from dask.distributed import Client
cluster = KubeCluster()
cluster.adapt(minimum=2, maximum=50)
client = Client(cluster)
# Run computation on auto-scaling cluster
result = large_computation.compute()
client.close()
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
MemoryError during .compute() |
Result too large for local memory | Use .to_parquet() or .persist() instead of .compute() |
| Slow computation start | Task graph has millions of tasks | Increase chunk sizes; use map_partitions/map_blocks |
| Poor parallelization | GIL contention with threads scheduler | Switch to scheduler='processes' for Python-heavy code |
TypeError in map_partitions |
Missing or wrong meta parameter |
Provide meta=pd.DataFrame({'col': pd.Series(dtype='float64')}) |
| Workers killed (OOM) | Chunks exceed worker memory | Decrease chunk size; increase memory_limit |
KilledWorker exception |
Worker process crashed | Check worker logs; reduce memory per task; increase memory_limit |
| Slow joins/merges | Data not pre-sorted on join key | Call ddf.set_index('key', sorted=True) before join |
NotImplementedError |
Operation not supported by Dask | Use map_partitions with pandas equivalent |
| Dashboard not accessible | Distributed client not started | Use client = Client() to enable distributed scheduler |
| Data type mismatch across partitions | Inconsistent CSV files | Specify dtype explicitly in dd.read_csv() |
Bundled Resources
references/collections_guide.md— Detailed DataFrames, Arrays, and Bags guide with comprehensive code examples for reading, transforming, aggregating, and writing data. Coversmap_partitionspatterns, meta parameter, chunking strategies,map_blocks,foldby, and collection conversion. Consolidated from originaldataframes.md,arrays.md, andbags.md. Originalbest-practices.mdcontent relocated to Best Practices section and Key Concepts inline.references/distributed_computing.md— Futures API, distributed coordination primitives (Locks, Queues, Events, Variables), Actors, scheduler configuration, HPC cluster setup (SLURM, Kubernetes), adaptive scaling, dashboard monitoring, and performance profiling. Consolidated from originalfutures.mdandschedulers.md.
Not migrated: Original had 6 reference files. best-practices.md content consolidated into Best Practices section and Key Concepts (chunk strategy, scheduler selection). Remaining content organized into 2 reference files covering the 5 main components.
Related Skills
- polars-dataframes — In-memory single-machine DataFrame library; faster than Dask for data that fits in RAM
- zarr-python — Chunked array storage format; primary Dask Array persistence backend
- scikit-learn-machine-learning — ML library; Dask-ML provides distributed wrappers
References
- Official Documentation: https://docs.dask.org/
- Dask Best Practices: https://docs.dask.org/en/stable/best-practices.html
- Dask-ML: https://ml.dask.org/
- Dask-Jobqueue (HPC): https://jobqueue.dask.org/
skills/scientific-computing/degenerate-input-filtering/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill degenerate-input-filtering -g -y
SKILL.md
Frontmatter
{
"name": "degenerate-input-filtering",
"license": "CC-BY-4.0",
"description": "Filter degenerate, uninformative inputs before statistical tests: single-sequence alignments, empty files, constant features, zero-variance inputs, all-NaN columns. See nan-safe-correlation for NaN-aware correlation; statistical-analysis for test guidance."
}
Degenerate Input Filtering Guide
Overview
Degenerate inputs are data points that carry no statistical information: constant-value features, all-NaN columns, single-sequence alignments, empty files, and similar edge cases. When these reach a statistical test or model, the result is meaningless -- a correlation of NaN, a p-value of 1.0, a score of 0.0, or an outright crash. This guide establishes the mandatory practice of detecting and removing such inputs before any analysis, and of reporting every removal so that downstream consumers know the effective sample size.
Key Concepts
What Counts as Degenerate
A data point is degenerate when it cannot contribute to the statistic being computed. The root cause is always the same: the input lacks the variation or completeness that the method requires.
| Type | Example | Why It Fails |
|---|---|---|
| Constant-value feature | Gene with identical expression across all samples | Variance = 0; correlation, t-test, fold-change are all undefined |
| All-NaN feature | Column with no valid observations | Every aggregation returns NaN |
| Single-sequence alignment | BLAST result with one sequence | Score = 0.0; no pairwise comparison is possible |
| Empty file | 0-byte FASTA or CSV | Parser crashes or returns an empty frame |
| Single value after grouping | One sample in a treatment group | Within-group variance is undefined; group comparison is meaningless |
| Zero-length sequence | Empty FASTA entry | Alignment and k-mer tools fail or produce nonsense |
| Near-constant feature | Gene with one outlier and N-1 identical values | Technically non-zero variance but correlation is dominated by a single point |
Why Silent Failures Are Dangerous
Many numerical libraries do not raise errors on degenerate input. Instead they return sentinel values:
numpy.corrcoefreturnsnanfor constant columns without warning.scipy.stats.spearmanrreturns(nan, nan)when one array is constant.scipy.stats.ttest_indreturns(nan, nan)for zero-variance groups.
These NaN values propagate silently through pipelines, contaminating aggregated statistics, heatmaps, volcano plots, and ranked gene lists. By the time a researcher notices the problem, it may be unclear which upstream step introduced the NaN.
The Reporting Obligation
Filtering without reporting is nearly as harmful as not filtering. When items are silently dropped, the effective sample or feature count differs from what the user expects, leading to incorrect power calculations and misleading summary statistics. Every filtering step must print:
- The count of items removed.
- The reason for removal.
- The count of items remaining.
Decision Framework
Use this tree to determine which filtering checks apply to your data before running a statistical analysis:
Is the input tabular (DataFrame)?
├── Yes
│ ├── Are there columns with zero unique values (all NaN)? → Remove, report count
│ ├── Are there columns with exactly one unique value (constant)? → Remove, report count
│ ├── Are there columns with fewer than N valid observations? → Remove, report count
│ └── Are there near-constant columns (1 outlier, rest identical)? → Flag or remove
└── No (file list, sequence set, etc.)
├── Are there empty files (0 bytes)? → Skip, report count
├── Are there single-entry collections (e.g., 1-sequence alignment)? → Skip, report count
└── Are there zero-length entries within a file? → Filter entries, report count
| Scenario | Recommended Check | Threshold |
|---|---|---|
| Gene expression matrix before DE analysis | Remove zero-variance genes, remove genes detected in fewer than N samples | N = max(3, 10% of samples) |
| Correlation analysis between two feature sets | Remove features constant in either set; require >= 10 shared non-NaN pairs | nunique >= 2 in both vectors; shared observations >= 10 |
| Multiple sequence alignment scoring | Skip alignments with < 2 sequences | Sequence count >= 2 |
| Survival analysis with grouped covariates | Remove groups with < 2 events | Events per group >= 2 |
| PCA or clustering on a feature matrix | Remove zero-variance and all-NaN features | Variance > 0 and at least 1 non-NaN value |
| Batch correction (e.g., ComBat) | Remove features absent in any batch | Feature detected in all batches |
Best Practices
-
Filter before any statistical computation, not after. Degenerate inputs can cause division-by-zero, NaN propagation, and inflated multiple-testing corrections. Filtering after the test means the damage is already done.
-
Use a single reusable filtering function. Centralizing the logic prevents inconsistencies between scripts and ensures the reporting format is uniform. See the reference implementation in the Workflow section below.
-
Print counts at every filtering step. Even when the count is zero, printing "0 items removed" confirms that the check ran. This makes logs self-documenting and auditable.
-
Set domain-appropriate thresholds rather than relying on generic defaults. A gene expression analysis might require detection in at least 10% of samples, while a proteomics dataset with more missingness might use 5%. Document the threshold and the rationale.
-
Treat near-constant features with caution. A feature with one non-identical value technically has non-zero variance, but its correlation with anything is driven entirely by that single point. Consider flagging these separately rather than including them blindly.
-
Never filter silently. Dropping data without reporting is a form of hidden data manipulation. Every removal must be logged with the reason and the count, so that the effective sample size is always transparent.
-
Re-check after transformations. Log-transforming expression data can introduce negative infinities from zero counts. Merging datasets can introduce new NaN columns. Run the degenerate check again after any transformation or merge step.
Common Pitfalls
-
Running a correlation on constant-valued columns and getting NaN without realizing it. The NaN result silently propagates into downstream rankings, heatmaps, or enrichment inputs, producing misleading figures.
- How to avoid: Always check
nunique() >= 2for every column before computing correlations. Use the reference implementation below.
- How to avoid: Always check
-
Scoring single-sequence alignments and reporting a score of 0.0 as a real result. Alignment scores require at least two sequences; a single-sequence "alignment" is not a failed alignment, it is a non-alignment.
- How to avoid: Count sequences before scoring. Skip and report any alignment file with fewer than 2 sequences.
-
Filtering data but not reporting the count, leading to confusion about effective sample size. A volcano plot based on 15,000 genes looks very different from one based on 18,500, but without a log message, no one knows which it is.
- How to avoid: Always print three things: count removed, reason, count remaining. Include this in every notebook and script.
-
Applying a minimum-samples threshold that is too low, retaining genes detected in only 1-2 samples. These genes produce unstable variance estimates and unreliable p-values that inflate the false discovery rate.
- How to avoid: Use
max(3, int(0.1 * n_samples))as a floor. Adjust upward for small studies where even 10% is fewer than 3.
- How to avoid: Use
-
Forgetting to re-check after a merge or transformation step. Joining two datasets can introduce all-NaN columns from non-overlapping features. Log-transforming zeros creates negative infinities. Both are degenerate.
- How to avoid: Run the degenerate filter again after every merge, join, or mathematical transformation.
-
Using
df.var() > 0without handling NaN correctly. If a column is all NaN,var()returns NaN, which is not> 0, so the column is dropped -- but the reason logged is "zero variance" rather than "all NaN," which is misleading.- How to avoid: Check for all-NaN columns separately before checking for zero variance. Report each category with its own label.
-
Assuming the input is clean because it came from a curated database. Public databases contain placeholder values, missing entries, and withdrawn records. Always validate, even when the source is trusted.
- How to avoid: Treat every input as untrusted. Run the full degenerate-input check regardless of the data source.
Workflow
The following sequential process should be applied before any statistical analysis.
-
Step 1: Load and inspect
- Load the data into a DataFrame or equivalent structure.
- Print the shape: rows, columns, and dtype summary.
-
Step 2: Remove all-NaN features
- Identify columns (or rows, depending on orientation) where every value is NaN.
- Remove them and print the count.
-
Step 3: Remove constant-value features
- Identify columns with
nunique() <= 1(after excluding NaN). - Remove them and print the count.
- Identify columns with
-
Step 4: Remove features with too few valid observations
- Set a threshold (e.g., at least 10% of samples or at least 3).
- Remove features below the threshold and print the count.
-
Step 5: Apply domain-specific checks
- For sequence data: skip single-sequence alignments, empty files, zero-length entries.
- For expression data: filter low-detection genes.
- For correlation inputs: verify both vectors are non-constant and share enough observations.
-
Step 6: Report summary
- Print a final summary: total input, total removed (by category), total remaining.
Reference Implementation
import pandas as pd
import numpy as np
def filter_degenerate(data, context="features"):
"""Filter degenerate data points and report what was removed.
Always call this BEFORE statistical analysis.
"""
n_before = len(data)
# Filter based on data type
if isinstance(data, pd.DataFrame):
# Remove constant columns (zero variance)
non_constant = data.loc[:, data.nunique() > 1]
# Remove all-NaN columns
non_nan = non_constant.dropna(axis=1, how='all')
filtered = non_nan
elif isinstance(data, list):
# Remove None, empty, and zero-length items
filtered = [x for x in data if x is not None and len(x) > 0]
else:
filtered = data
n_after = len(filtered) if not isinstance(filtered, pd.DataFrame) else filtered.shape[1]
n_removed = n_before if not isinstance(data, pd.DataFrame) else data.shape[1]
n_removed = n_removed - n_after
# MANDATORY: Print the count
print(f"Degenerate {context} filtered: {n_removed} / {n_before} removed")
print(f"Remaining {context}: {n_after}")
return filtered
Sequence Alignment Filtering
# Filter single-sequence alignments (score=0.0 is meaningless)
alignment_scores = {}
for aln_file in alignment_files:
n_seqs = count_sequences(aln_file)
if n_seqs < 2:
print(f"Skipping {aln_file}: only {n_seqs} sequence(s)")
continue
score = compute_alignment_score(aln_file)
alignment_scores[aln_file] = score
print(f"Filtered {len(alignment_files) - len(alignment_scores)} single-sequence alignments")
Gene Expression Filtering
# Filter genes with zero variance (constant expression)
gene_vars = expression_df.var(axis=1)
zero_var = (gene_vars == 0).sum()
print(f"Genes with zero variance: {zero_var}")
expression_filtered = expression_df.loc[gene_vars > 0]
# Filter genes detected in too few samples
min_samples = max(3, int(0.1 * expression_df.shape[1])) # At least 10% of samples
detected = (expression_df > 0).sum(axis=1)
low_detection = (detected < min_samples).sum()
print(f"Genes detected in < {min_samples} samples: {low_detection}")
expression_filtered = expression_filtered.loc[detected >= min_samples]
Correlation Pre-filtering
from scipy.stats import spearmanr
# Filter features before computing correlations
valid_correlations = {}
skipped_constant = 0
skipped_few_values = 0
for feature in features:
x = data_x[feature].dropna()
y = data_y[feature].dropna()
# Check for constant values
if x.nunique() < 2 or y.nunique() < 2:
skipped_constant += 1
continue
# Check for sufficient data points
common = x.index.intersection(y.index)
if len(common) < 10:
skipped_few_values += 1
continue
rho, pval = spearmanr(x[common], y[common])
valid_correlations[feature] = rho
print(f"Skipped (constant values): {skipped_constant}")
print(f"Skipped (< 10 valid pairs): {skipped_few_values}")
print(f"Valid correlations computed: {len(valid_correlations)}")
Expected Output Format
A well-formatted filtering report looks like this:
Input: 18500 genes
Filtered: 230 genes with zero variance
Filtered: 45 genes with < 3 valid samples
Remaining: 18225 genes for analysis
Further Reading
- scipy.stats documentation -- Reference for statistical functions and their behavior on edge-case inputs including constant arrays and NaN values.
- pandas DataFrame.dropna -- Official documentation for NaN removal with axis and threshold control.
- numpy NaN handling -- NumPy documentation covering NaN propagation semantics in arithmetic and aggregation functions.
- Lun, A. et al. (2016) "A step-by-step workflow for low-level analysis of single-cell RNA-seq data with Bioconductor" -- Demonstrates gene-level quality control filtering including low-abundance and zero-variance gene removal prior to normalization and differential expression.
- Law, C.W. et al. (2018) "RNA-seq analysis is easy as 1-2-3 with limma, Glimma and edgeR" -- Covers filterByExpr-style gene filtering to remove lowly expressed and uninformative genes before differential expression analysis.
Related Skills
nan-safe-correlation-- Techniques for computing correlations in the presence of missing values; complements this guide's pre-filtering step for correlation inputs.statistical-analysis-- Broader guidance on choosing and applying statistical tests; assumes degenerate inputs have already been removed per this guide.
skills/scientific-computing/exploratory-data-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill exploratory-data-analysis -g -y
SKILL.md
Frontmatter
{
"name": "exploratory-data-analysis",
"license": "CC-BY-4.0",
"description": "Methodology for exploratory data analysis on scientific files. Decision frameworks by data type (tabular, sequence, image, spectral, structural, omics), quality assessment, report generation, format detection across 200+ formats. Use when given a data file for initial exploration or to pick an analysis before a pipeline."
}
Exploratory Data Analysis for Scientific Data
Overview
Exploratory data analysis (EDA) is the systematic examination of scientific data files to understand their structure, content, quality, and characteristics before formal analysis. This knowhow covers methodology for detecting file types, selecting appropriate analysis approaches, assessing data quality, and generating comprehensive reports across all major scientific data domains.
Key Concepts
Scientific Data Type Categories
| Category | Common Formats | Typical Analysis | Key Libraries |
|---|---|---|---|
| Tabular | CSV, TSV, XLSX, Parquet | Summary statistics, distributions, correlations, missing values | pandas, polars |
| Sequence | FASTA, FASTQ, SAM/BAM | Length distribution, quality scores, GC content, alignment stats | BioPython, pysam |
| Image/Microscopy | TIFF, ND2, CZI, DICOM | Dimensions (XYZCT), intensity stats, metadata, calibration | tifffile, aicsimageio, nd2reader |
| Spectral | mzML, SPC, JCAMP, FID | Peak detection, baseline, S/N ratio, resolution | pymzml, nmrglue, pyteomics |
| Structural | PDB, CIF, MOL, SDF | Atom counts, bond validation, B-factors, completeness | BioPython, RDKit, MDAnalysis |
| Array/Tensor | NPY, HDF5, Zarr, NetCDF | Shape, dtype, value range, NaN/Inf check, chunk structure | numpy, h5py, zarr, xarray |
| Omics | H5AD, MTX, VCF, BED | Feature/sample counts, sparsity, annotation completeness | scanpy, pyranges, cyvcf2 |
Format Detection Strategy
- Extension-based: Map file extension to category (primary method)
- Magic bytes: Check file header for binary format identification (HDF5:
\x89HDF, GZIP:\x1f\x8b) - Content sniffing: For ambiguous extensions (.txt, .dat, .csv), inspect first lines for delimiters, headers, or format markers
- Compound extensions: Handle
.ome.tiff,.nii.gz,.tar.gzby checking from the rightmost extension inward
Data Quality Dimensions
- Completeness: Missing values, empty fields, truncated records
- Consistency: Matching dimensions, compatible dtypes, cross-field validation
- Validity: Values within expected ranges, proper encoding, format compliance
- Provenance: Instrument metadata, software versions, processing history
- Uniqueness: Duplicate records, redundant features
Decision Framework
Data file received
├── What is the file type?
│ ├── Known extension → Look up in format reference
│ ├── Unknown extension → Magic bytes / content sniffing
│ └── Directory (e.g., .d, .zarr) → Check internal structure
│
├── What category does it belong to?
│ ├── Tabular → Summary stats, distributions, correlations
│ ├── Sequence → Length/quality distributions, composition
│ ├── Image → Dimensions, channels, intensity, metadata
│ ├── Spectral → Peaks, baseline, resolution, S/N
│ ├── Structural → Atom/bond validation, geometry checks
│ ├── Array → Shape, dtype, value range, sparsity
│ └── Omics → Feature counts, sample QC, annotation check
│
├── How large is the file?
│ ├── Small (<100 MB) → Load fully, comprehensive analysis
│ ├── Medium (100 MB–1 GB) → Sample or lazy evaluation
│ └── Large (>1 GB) → Stream/chunk, representative sampling
│
└── What is the analysis goal?
├── Pre-pipeline QC → Focus on completeness, format compliance
├── Data understanding → Statistics, distributions, patterns
├── Troubleshooting → Compare against expected format/values
└── Documentation → Full report with recommendations
Quick Reference: Analysis Approach by Data Type
| Data Type | First Check | Core Analysis | Visualization |
|---|---|---|---|
| Tabular | dtypes, shape, nulls | describe(), correlations, outliers | histograms, scatter, heatmap |
| Sequence | record count, format | length dist., quality, composition | quality plots, length histogram |
| Image | dimensions, bit depth | intensity stats, channel info | thumbnail, histogram |
| Spectral | scan count, m/z range | peak detection, TIC, baseline | spectrum plot, TIC chromatogram |
| Structural | atom/residue count | B-factors, missing residues | Ramachandran, contact map |
| Array | shape, dtype | statistics, NaN check | slice visualization |
| Omics | genes × cells matrix | sparsity, QC metrics | violin plots, PCA |
Best Practices
- Always check file integrity first — verify file is complete (not truncated) and readable before deep analysis. Check file size against expectations
- Sample large files before full analysis — for files with millions of records, analyze a representative sample (first N records, random sample, or stratified sample) to get quick feedback
- Use lazy/streaming readers when available —
pl.scan_parquet(),h5pydataset slicing,pysamindexed access prevent memory overflows - Validate metadata against data — cross-check stated dimensions vs actual data, verify column count matches header, confirm timestamps are monotonic
- Report data quality quantitatively — "5.2% missing values in column X" is more useful than "some missing values". Include completeness percentages, outlier counts, and format compliance scores
- Consider data provenance — note instrument type, software version, processing steps, and any preprocessing already applied. This context affects downstream analysis choices
- Generate actionable recommendations — don't just describe the data; suggest specific preprocessing steps (normalization method, imputation strategy), appropriate analyses, and potential issues to watch for
- Preserve analysis reproducibility — include library versions, parameter choices, and random seeds in reports so EDA can be reproduced
- Check for batch effects early — in multi-sample datasets, compare distributions across batches, plates, or runs before combining
- Handle vendor-specific formats carefully — many instruments produce proprietary formats (.nd2, .czi, .raw). Document which reader library and version was used, as format support varies
Common Pitfalls
-
Assuming CSV means clean tabular data — CSV files can have inconsistent delimiters, mixed encodings, embedded newlines, or malformed quoting. How to avoid: Use
pd.read_csv(engine='python')for robustness; check encoding withchardet -
Ignoring missing value encoding — scientific data uses diverse null representations:
NA,NaN,-999, empty string,#N/A,.. How to avoid: Specifyna_valuesparameter; check for sentinel values in numeric columns -
Drawing conclusions from truncated files — large file transfers can fail silently. How to avoid: Check file size, verify record counts against expected values, check for EOF markers
-
Applying wrong reader to file — some extensions are ambiguous (.raw = Thermo MS, XRD, or image; .d = Agilent directory or generic data). How to avoid: Use magic bytes and context (source instrument) to disambiguate
-
Memory overflow on large datasets — loading a 10 GB CSV into a pandas DataFrame will fail. How to avoid: Check file size first; use chunked reading, lazy evaluation, or sampling for files >100 MB
-
Ignoring coordinate systems and units — microscopy data may use pixels vs microns; spectroscopy data may use wavelength vs wavenumber vs energy. How to avoid: Extract and report units from metadata; verify calibration information
-
Treating all columns as independent — scientific tabular data often has hierarchical structure (replicates nested within conditions). How to avoid: Identify experimental design from column names and metadata before computing correlations
-
Skipping format-specific quality metrics — generic statistics miss domain-specific issues (e.g., Phred quality scores in FASTQ, R-factors in crystallography, mass accuracy in MS). How to avoid: Consult the format reference for domain-specific QC metrics
-
Overinterpreting small samples — EDA on first 1000 rows may not represent the full dataset's distribution. How to avoid: Sample from multiple positions in the file; report sample size and sampling method
-
Not checking for duplicates — duplicate records are common in merged datasets and database exports. How to avoid: Check for exact and near-duplicates early; report the duplication rate
Workflow
Step 1: File Identification
- Extract file extension (handle compound extensions:
.ome.tiff,.nii.gz) - Look up format in reference catalog
- If unknown: check magic bytes, inspect first lines, ask user about source instrument
- Record: format name, category, typical content, recommended libraries
Step 2: Initial Assessment
- File size, creation date, permissions
- For binary formats: verify file integrity (header/footer checks)
- For text formats: detect encoding, delimiter, line endings
- For directories: enumerate contents and check expected structure
Step 3: Data Loading
- Install required library if missing (provide
pip installcommand) - Use appropriate reader with explicit parameters (dtype, encoding, columns)
- For large files: load metadata first, then sample data
- Record: dimensions (rows × columns, or X×Y×Z×C×T), dtypes, memory footprint
Step 4: Quality Assessment
- Completeness: count nulls per column/field, check for truncation
- Validity: range checks, dtype verification, format compliance
- Consistency: cross-field validation, duplicate detection
- Domain-specific: Phred scores (FASTQ), B-factors (PDB), mass accuracy (MS), intensity range (microscopy)
Step 5: Statistical Analysis
- Summary statistics (mean, median, std, min, max, quartiles)
- Distribution characteristics (skewness, modality, outliers)
- Correlation structure (for multi-variable data)
- Domain-specific metrics (GC content, coverage, resolution, S/N ratio)
Step 6: Report Generation
Generate a structured markdown report containing:
- Header: filename, timestamp, file size
- Format Information: type, description, typical use cases
- Data Structure: dimensions, dtypes, memory usage
- Quality Assessment: completeness scores, validity checks, issues found
- Statistical Summary: key metrics and distributions
- Key Findings: notable patterns, potential issues, anomalies
- Recommendations: preprocessing steps, appropriate analyses, tools to use
Save as {original_filename}_eda_report.md.
Bundled Resources
references/file_format_reference.md— Quick-reference catalog of the most common scientific file formats across all 6 categories (bioinformatics, chemistry, microscopy, spectroscopy, proteomics/metabolomics, general), with extension, description, Python library, and key EDA approach for each format
Not migrated from original: The 6 category-specific format catalog files (3,616 lines total) contained detailed entries for 200+ formats. The bundled reference consolidates the ~50 most commonly encountered formats. For rare or vendor-specific formats, consult official library documentation.
Further Reading
- McKinney, W. (2017). Python for Data Analysis (2nd ed.) — pandas-based EDA methodology
- VanderPlas, J. (2016). Python Data Science Handbook — EDA with matplotlib and scikit-learn
- Tukey, J. (1977). Exploratory Data Analysis — foundational EDA philosophy
- Bio-Formats documentation: https://www.openmicroscopy.org/bio-formats/ — microscopy format reference
- PSI standard formats: https://www.psidev.info/ — proteomics/MS data standards
Related Skills
- matplotlib-scientific-plotting — visualization for EDA reports
- polars-dataframes — efficient tabular data loading and analysis
- scanpy-scrna-seq — single-cell omics EDA workflows
- zarr-python — chunked array data access for large datasets
- pysam-genomic-files — indexed access to BAM/CRAM/VCF files
skills/scientific-computing/geopandas-geospatial/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill geopandas-geospatial -g -y
SKILL.md
Frontmatter
{
"name": "geopandas-geospatial",
"license": "BSD-3-Clause",
"description": "Geospatial vector analysis extending pandas. Read\/write spatial formats (Shapefile, GeoJSON, GeoPackage, Parquet, PostGIS), CRS handling, geometric ops (buffer, simplify, centroid, affine), spatial analysis (joins, overlays, dissolve, clipping, distance), visualization (choropleth, interactive maps, basemaps). Use for spatial joins, overlays, CRS transforms, area\/distance, maps."
}
GeoPandas Geospatial Analysis
Overview
GeoPandas extends pandas with spatial operations on geometric types, combining pandas DataFrames with Shapely geometries and Fiona for file I/O. It enables reading, writing, manipulating, and visualizing geospatial vector data (points, lines, polygons) with a familiar pandas-like API.
When to Use
- Reading and writing spatial file formats (Shapefile, GeoJSON, GeoPackage, Parquet)
- Performing spatial joins between geographic datasets (points in polygons, nearest neighbors)
- Running overlay operations (intersection, union, difference, clipping)
- Computing geometric properties (area, distance, buffer, centroid)
- Creating choropleth maps and interactive web maps
- Reprojecting data between coordinate reference systems
- Aggregating spatial features by attribute (dissolve)
- For raster data analysis, use rasterio/xarray instead
- For large-scale distributed geospatial, consider Dask-GeoPandas or Apache Sedona
Prerequisites
pip install geopandas matplotlib
# Optional:
# pip install folium — interactive maps
# pip install mapclassify — classification schemes for choropleth
# pip install contextily — basemaps
# pip install pyarrow — faster I/O (2-4x speedup)
# pip install psycopg2 geoalchemy2 — PostGIS support
Quick Start
import geopandas as gpd
# Read spatial data
gdf = gpd.read_file("data.geojson")
print(f"Shape: {gdf.shape}, CRS: {gdf.crs}")
print(f"Geometry types: {gdf.geometry.geom_type.unique()}")
# Reproject, compute area, save
gdf_proj = gdf.to_crs("EPSG:3857")
gdf_proj['area_m2'] = gdf_proj.geometry.area
gdf_proj.to_file("output.gpkg")
# Quick map
gdf.plot(column='population', legend=True, figsize=(10, 8))
Core API
1. Data I/O
import geopandas as gpd
# Read various formats
gdf = gpd.read_file("data.shp") # Shapefile
gdf = gpd.read_file("data.geojson") # GeoJSON
gdf = gpd.read_file("data.gpkg") # GeoPackage
gdf = gpd.read_file("data.gpkg", layer="roads") # Specific layer
# Filtered reading (load only needed data)
gdf = gpd.read_file("data.gpkg", bbox=(xmin, ymin, xmax, ymax))
gdf = gpd.read_file("data.gpkg", columns=["name", "geometry"])
gdf = gpd.read_file("data.gpkg", where="population > 10000")
# Arrow acceleration (2-4x faster)
gdf = gpd.read_file("data.gpkg", use_arrow=True)
# Parquet/Feather (columnar, fast, preserves CRS)
gdf = gpd.read_parquet("data.parquet")
gdf.to_parquet("output.parquet")
# PostGIS database
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:pass@host/db")
gdf = gpd.read_postgis("SELECT * FROM parcels", con=engine, geom_col='geom')
gdf.to_postgis("output_table", con=engine)
# Write
gdf.to_file("output.gpkg") # GeoPackage (recommended)
gdf.to_file("output.shp") # Shapefile
gdf.to_file("output.geojson", driver="GeoJSON")
2. CRS Management
# Check current CRS
print(gdf.crs) # e.g., EPSG:4326
print(gdf.crs.is_geographic) # True for lat/lon
print(gdf.crs.is_projected) # True for meters
# Reproject (transforms coordinates)
gdf_proj = gdf.to_crs("EPSG:3857") # Web Mercator
gdf_proj = gdf.to_crs(epsg=32633) # UTM zone 33N
# Set CRS (only when metadata missing, does NOT transform coordinates)
gdf = gdf.set_crs("EPSG:4326")
# Estimate appropriate UTM zone
utm_crs = gdf.estimate_utm_crs()
gdf_utm = gdf.to_crs(utm_crs)
Common EPSG codes:
| Code | Name | Use |
|---|---|---|
| 4326 | WGS 84 | GPS coordinates, web data |
| 3857 | Web Mercator | Web mapping (Google/OSM tiles) |
| 326xx | UTM zones (N) | Area/distance calculations |
| 5070 | Albers Equal Area (US) | Area-preserving US maps |
3. Geometric Operations
# Buffer (expand/erode geometry by distance)
buffered = gdf.geometry.buffer(100) # 100 units (meters if projected)
eroded = gdf.geometry.buffer(-50) # Negative = erosion
# Simplify (reduce complexity)
simplified = gdf.geometry.simplify(tolerance=10, preserve_topology=True)
# Centroid, convex hull, envelope
centroids = gdf.geometry.centroid
hulls = gdf.geometry.convex_hull
bounds = gdf.geometry.envelope
# Union all geometries
unified = gdf.geometry.union_all()
# Affine transformations
rotated = gdf.geometry.rotate(angle=45, origin='center')
scaled = gdf.geometry.scale(xfact=2.0, yfact=2.0)
translated = gdf.geometry.translate(xoff=100, yoff=50)
# Geometric properties
areas = gdf.geometry.area # Use projected CRS for accuracy
lengths = gdf.geometry.length # Perimeter for polygons
is_valid = gdf.geometry.is_valid # Validate geometry
total = gdf.geometry.total_bounds # [minx, miny, maxx, maxy]
4. Spatial Analysis
# Spatial join (combine datasets by spatial relationship)
joined = gpd.sjoin(points_gdf, polygons_gdf, predicate='intersects')
joined = gpd.sjoin(gdf1, gdf2, predicate='within')
joined = gpd.sjoin(gdf1, gdf2, predicate='contains', how='left')
# Nearest neighbor join
nearest = gpd.sjoin_nearest(gdf1, gdf2, max_distance=1000, distance_col='dist')
# Overlay operations (set-theoretic)
intersection = gpd.overlay(gdf1, gdf2, how='intersection')
union = gpd.overlay(gdf1, gdf2, how='union')
difference = gpd.overlay(gdf1, gdf2, how='difference')
sym_diff = gpd.overlay(gdf1, gdf2, how='symmetric_difference')
# Dissolve (aggregate by attribute)
dissolved = gdf.dissolve(by='region', aggfunc='sum')
dissolved = gdf.dissolve(by='region', aggfunc={'population': 'sum', 'area': 'mean'})
# Clip to boundary
clipped = gpd.clip(gdf, boundary_gdf)
# Distance calculations (use projected CRS)
distances = gdf.geometry.distance(single_point)
# Spatial predicates
within_mask = gdf1.geometry.within(gdf2.geometry)
intersects_mask = gdf1.geometry.intersects(gdf2.geometry)
5. Visualization
import matplotlib.pyplot as plt
# Basic plot
gdf.plot(figsize=(10, 8))
# Choropleth map
gdf.plot(column='population', cmap='YlOrRd', legend=True, figsize=(12, 8))
# Classification schemes (requires mapclassify)
gdf.plot(column='population', scheme='quantiles', k=5, legend=True)
gdf.plot(column='population', scheme='fisher_jenks', k=5, legend=True)
# Multi-layer map
fig, ax = plt.subplots(figsize=(12, 10))
polygons_gdf.plot(ax=ax, color='lightblue', edgecolor='black')
points_gdf.plot(ax=ax, color='red', markersize=10)
roads_gdf.plot(ax=ax, color='gray', linewidth=0.5)
ax.set_title('Multi-layer Map')
ax.set_axis_off()
# Interactive map (requires folium)
m = gdf.explore(column='population', cmap='YlOrRd', legend=True,
tooltip=['name', 'population'])
m.save('map.html')
# Multi-layer interactive
m = gdf1.explore(color='blue', name='Layer 1')
gdf2.explore(m=m, color='red', name='Layer 2')
import folium
folium.LayerControl().add_to(m)
# Basemap (requires contextily)
import contextily as ctx
gdf_wm = gdf.to_crs(epsg=3857)
ax = gdf_wm.plot(alpha=0.5, figsize=(10, 10))
ctx.add_basemap(ax)
Key Concepts
Data Structures
- GeoSeries: Pandas Series of Shapely geometries with spatial methods (area, distance, buffer, etc.)
- GeoDataFrame: Pandas DataFrame with one or more geometry columns. One column is the "active geometry" used by spatial methods
from shapely.geometry import Point
# Create from coordinates
gdf = gpd.GeoDataFrame(
{'name': ['A', 'B'], 'value': [10, 20]},
geometry=[Point(0, 0), Point(1, 1)],
crs="EPSG:4326"
)
# Multiple geometry columns
gdf['centroid'] = gdf.geometry.centroid
gdf = gdf.set_geometry('centroid') # Switch active geometry
CRS Rules for Spatial Operations
- Always check CRS before any spatial operation:
print(gdf.crs) - Match CRS before spatial joins, overlays, or distance calculations
- Use projected CRS (meters) for area/distance — geographic CRS (degrees) gives wrong results
- set_crs() only adds metadata; to_crs() transforms coordinates
Spatial Indexing
GeoPandas automatically creates spatial indexes (R-tree) for sjoin, overlay, and other spatial operations. For manual queries:
sindex = gdf.sindex
possible_idx = list(sindex.intersection((xmin, ymin, xmax, ymax)))
Common Workflows
Workflow 1: Load → Transform → Analyze → Export
import geopandas as gpd
# Load
gdf = gpd.read_file("parcels.shp")
print(f"CRS: {gdf.crs}, Rows: {len(gdf)}")
# Transform to projected CRS for measurements
gdf = gdf.to_crs(gdf.estimate_utm_crs())
# Analyze
gdf['area_ha'] = gdf.geometry.area / 10000 # hectares
gdf['perimeter_m'] = gdf.geometry.length
print(f"Total area: {gdf['area_ha'].sum():.1f} ha")
# Export
gdf.to_file("parcels_analyzed.gpkg")
Workflow 2: Spatial Join and Aggregate
# Count points per polygon
points_in_poly = gpd.sjoin(points_gdf, polygons_gdf, predicate='within')
counts = points_in_poly.groupby('index_right').agg(
point_count=('geometry', 'size'),
total_value=('value', 'sum')
)
result = polygons_gdf.merge(counts, left_index=True, right_index=True, how='left')
result['point_count'] = result['point_count'].fillna(0)
print(f"Polygons with points: {(result['point_count'] > 0).sum()}/{len(result)}")
Workflow 3: Multi-Source Integration
# Read from different sources, ensure matching CRS
roads = gpd.read_file("roads.shp")
buildings = gpd.read_file("buildings.geojson")
parcels = gpd.read_postgis("SELECT * FROM parcels", con=engine, geom_col='geom')
target_crs = roads.crs
buildings = buildings.to_crs(target_crs)
parcels = parcels.to_crs(target_crs)
# Find buildings within 50m of roads
buildings_near_roads = gpd.sjoin_nearest(
buildings, roads, max_distance=50, distance_col='road_dist'
)
print(f"Buildings near roads: {len(buildings_near_roads)}/{len(buildings)}")
Key Parameters
| Parameter | Function | Default | Effect |
|---|---|---|---|
predicate |
sjoin | 'intersects' |
Spatial relationship: intersects, within, contains, touches, crosses |
how |
sjoin, overlay | 'inner' |
Join type: inner, left, right |
max_distance |
sjoin_nearest | None | Search radius limit (improves performance) |
k |
sjoin_nearest | 1 | Number of nearest neighbors to find |
tolerance |
simplify | Required | Douglas-Peucker tolerance (in CRS units) |
preserve_topology |
simplify | True | Prevents self-intersections |
resolution |
buffer | 16 | Number of segments for buffer curves |
aggfunc |
dissolve | 'first' | Aggregation function for non-geometry columns |
use_arrow |
read_file | False | Enable Arrow acceleration (2-4x faster) |
scheme |
plot | None | Classification: quantiles, equal_interval, fisher_jenks |
k |
plot (with scheme) | 5 | Number of classification bins |
Best Practices
- Always check CRS before spatial operations — mismatched CRS gives wrong or empty results
- Use projected CRS for area and distance calculations — geographic CRS (degrees) is meaningless for measurements
- Match CRS before joins —
gdf2 = gdf2.to_crs(gdf1.crs)beforegpd.sjoin(gdf1, gdf2) - Validate geometries with
.is_validbefore complex operations — invalid geometries cause silent errors - Use GeoPackage over Shapefile — no 10-char column name limit, supports multiple layers, better performance
- Filter during read — use
bbox,columns,whereto load only needed data for large files - Set max_distance in sjoin_nearest — unbounded nearest-neighbor search is slow on large datasets
- Use
.copy()when modifying geometry — avoid unintended side effects on original GeoDataFrame
Common Recipes
Recipe: Count Points in Polygons
When to use: Aggregate point observations (sample sites, observations) by region (county, watershed).
import geopandas as gpd
regions = gpd.read_file("regions.geojson")
points = gpd.read_file("observations.geojson").to_crs(regions.crs)
joined = gpd.sjoin(points, regions, how="inner", predicate="within")
counts = joined.groupby("index_right").size().rename("point_count")
regions_with_counts = regions.join(counts).fillna(0)
regions_with_counts["point_count"] = regions_with_counts["point_count"].astype(int)
print(regions_with_counts[["name", "point_count"]].sort_values("point_count", ascending=False).head())
Recipe: Buffer Around Points and Dissolve Overlaps
When to use: Create service areas or catchment zones from point locations.
import geopandas as gpd
facilities = gpd.read_file("facilities.geojson")
utm_crs = facilities.estimate_utm_crs() # Project to meters
facilities_m = facilities.to_crs(utm_crs)
# 1 km buffer
buffers = facilities_m.copy()
buffers["geometry"] = facilities_m.geometry.buffer(1000)
# Dissolve overlapping buffers into single polygon
service_area = buffers.dissolve()
service_area_wgs84 = service_area.to_crs("EPSG:4326")
service_area_wgs84.to_file("service_area.geojson", driver="GeoJSON")
print(f"Service area: {service_area_wgs84.geometry.area.sum():.0f} sq degrees")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Empty spatial join result | CRS mismatch between GeoDataFrames | Ensure matching CRS: gdf2 = gdf2.to_crs(gdf1.crs) |
| Wrong area/distance values | Using geographic CRS (degrees) | Reproject to projected CRS: gdf.to_crs(gdf.estimate_utm_crs()) |
DriverError on read |
Missing driver or corrupt file | Check file exists; try driver="GeoJSON" explicitly |
| Geometry column lost after merge | Called df.merge(gdf) instead of gdf.merge(df) |
Always call merge ON the GeoDataFrame |
TopologicalError on overlay |
Invalid geometries | Fix with gdf.geometry = gdf.geometry.buffer(0) |
| Slow sjoin_nearest | No distance limit on large dataset | Set max_distance parameter |
| Folium map blank | Geometries not in EPSG:4326 | Reproject to WGS84: gdf.to_crs(epsg=4326).explore() |
| Shapefile column names truncated | Shapefile 10-char limit | Use GeoPackage instead: gdf.to_file("out.gpkg") |
References
- GeoPandas documentation: https://geopandas.org/
- GeoPandas GitHub: https://github.com/geopandas/geopandas
- Shapely documentation: https://shapely.readthedocs.io/
- EPSG registry: https://epsg.io/
Related Skills
- matplotlib-scientific-plotting — advanced map styling and figure export
- polars-dataframes — high-performance tabular analysis before/after spatial operations
- folium — advanced interactive web mapping beyond geopandas.explore()
skills/scientific-computing/hypogenic-hypothesis-generation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill hypogenic-hypothesis-generation -g -y
SKILL.md
Frontmatter
{
"name": "hypogenic-hypothesis-generation",
"license": "MIT",
"description": "LLM-driven hypothesis generation\/testing on tabular data. Three methods: HypoGeniC (data-driven), HypoRefine (literature+data), Union. Iterative refinement, Redis caching, multi-hypothesis inference. Manual: hypothesis-generation; ideation: scientific-brainstorming."
}
HypoGeniC Hypothesis Generation
Overview
HypoGeniC automates scientific hypothesis generation and testing using LLMs on tabular datasets. Given labeled data (e.g., deception detection, AI-content identification), it generates testable hypotheses, iteratively refines them against validation performance, and runs inference to classify new samples. It supports three approaches: purely data-driven (HypoGeniC), literature-integrated (HypoRefine), and mechanistic union of both.
When to Use
- Generating testable hypotheses from labeled observational datasets without prior theory
- Systematically testing multiple competing hypotheses on empirical data
- Combining insights from research papers with data-driven pattern discovery
- Accelerating hypothesis ideation in domains like deception detection, content analysis, mental health indicators
- Benchmarking LLM-based hypothesis generation methods against few-shot baselines
- For manual hypothesis formulation frameworks, use hypothesis-generation knowhow
- For general-purpose ML classification without hypothesis interpretability, use scikit-learn-machine-learning
Prerequisites
- Python packages:
hypogenic - Optional: Redis server (port 6832) for LLM response caching; GROBID for PDF literature processing
- API keys: OpenAI, Anthropic, or compatible LLM API key in environment
- Data: Labeled JSON datasets in HypoGeniC format (see Key Concepts)
pip install hypogenic
# Optional: clone example datasets
git clone https://github.com/ChicagoHAI/HypoGeniC-datasets.git ./data
git clone https://github.com/ChicagoHAI/Hypothesis-agent-datasets.git ./data_lit
Quick Start
from hypogenic import BaseTask
import re
# Custom label extractor (must match dataset label format)
def extract_label(text: str) -> str:
match = re.search(r'final answer:\s+(.*)', text, re.IGNORECASE)
return match.group(1).strip() if match else text.strip()
# 1. Load task from config
task = BaseTask(
config_path="./data/your_task/config.yaml",
extract_label=extract_label
)
# 2. Generate hypotheses (data-driven)
task.generate_hypotheses(
method="hypogenic",
num_hypotheses=20,
output_path="./output/hypotheses.json"
)
# 3. Run inference on test set
results = task.inference(
hypothesis_bank="./output/hypotheses.json",
test_data="./data/your_task/your_task_test.json"
)
print(f"Accuracy: {results['accuracy']:.3f}")
Workflow
Step 1: Prepare Dataset
Create train/val/test JSON files with text features and labels.
import json
# Dataset: each key maps to a list of equal length
dataset = {
"headline_1": [
"What Up, Comet? You Just Got *PROBED*",
"Scientists Made a Breakthrough in Quantum Computing"
],
"headline_2": [
"Scientists Were Holding Their Breath Today. Here's Why.",
"New Quantum Computer Achieves Milestone"
],
"label": [
"Headline 2 has more clicks than Headline 1",
"Headline 1 has more clicks than Headline 2"
]
}
# All lists must have equal length; labels must match extract_label output
for split in ["train", "val", "test"]:
with open(f"my_task_{split}.json", "w") as f:
json.dump(dataset, f, indent=2)
print(f"Created dataset with {len(dataset['label'])} samples")
Step 2: Create Task Configuration
Write a config.yaml defining dataset paths and prompt templates.
# config.yaml structure (write as YAML file)
config = """
task_name: my_task
train_data_path: ./my_task_train.json
val_data_path: ./my_task_val.json
test_data_path: ./my_task_test.json
prompt_templates:
observations: |
Feature 1: ${text_features_1}
Feature 2: ${text_features_2}
Observation: ${label}
batched_generation:
system: "You are a research scientist generating hypotheses."
user: "Generate ${num_hypotheses} testable hypotheses from these observations."
inference:
system: "You are evaluating a hypothesis against data."
user: "Hypothesis: ${hypothesis}\\nSample: ${sample_text}\\nFinal answer: ${label}"
is_relevant:
system: "Check hypothesis relevance."
user: "Is this hypothesis relevant? ${hypothesis}"
"""
with open("config.yaml", "w") as f:
f.write(config)
print("Configuration written to config.yaml")
Step 3: Implement Label Extraction
Define a custom extract_label function matching your label format.
import re
def extract_label(llm_output: str) -> str:
"""Parse LLM output to extract predicted label.
Must return labels matching the 'label' field values in the dataset.
Default: searches for 'final answer: <label>' pattern.
"""
match = re.search(r'final answer:\s+(.*)', llm_output, re.IGNORECASE)
if match:
return match.group(1).strip()
# Domain-specific fallback
if "Final prediction:" in llm_output:
return llm_output.split("Final prediction:")[-1].strip()
return llm_output.strip()
# Test against expected labels
assert extract_label("Final answer: Headline 1") == "Headline 1"
print("Label extractor validated")
Step 4: Generate Hypotheses (HypoGeniC)
Run data-driven hypothesis generation with iterative refinement.
from hypogenic import BaseTask
task = BaseTask(
config_path="./config.yaml",
extract_label=extract_label
)
# Generate hypotheses: initializes from data subset, iteratively refines
task.generate_hypotheses(
method="hypogenic", # Data-driven generation
num_hypotheses=20, # Target number of hypotheses
output_path="./output/hypotheses.json"
)
# CLI equivalent:
# hypogenic_generation --config config.yaml --method hypogenic --num_hypotheses 20
print("Hypothesis bank saved to ./output/hypotheses.json")
Step 5: Run Inference
Test generated hypotheses against the test set.
results = task.inference(
hypothesis_bank="./output/hypotheses.json",
test_data="./my_task_test.json"
)
print(f"Test accuracy: {results['accuracy']:.3f}")
print(f"Predictions: {results['predictions'][:5]}")
# CLI equivalent:
# hypogenic_inference --config config.yaml --hypotheses output/hypotheses.json
Step 6: Literature-Integrated Generation (HypoRefine)
Combine literature insights with data-driven hypotheses.
# Requires GROBID setup and preprocessed PDFs
# bash ./modules/setup_grobid.sh # first time
# bash ./modules/run_grobid.sh # start GROBID service
# python pdf_preprocess.py --task_name my_task
task.generate_hypotheses(
method="hyporefine",
num_hypotheses=15,
literature_path="./literature/my_task/",
output_path="./output/"
)
# Generates 3 hypothesis banks:
# - HypoRefine (integrated literature+data)
# - Literature-only hypotheses
# - Literature union HypoRefine
print("HypoRefine generation complete: 3 hypothesis banks created")
Step 7: Multi-Hypothesis Inference
Test multiple hypotheses simultaneously for ensemble classification.
from examples.multi_hyp_inference import run_multi_hypothesis_inference
results = run_multi_hypothesis_inference(
config_path="./config.yaml",
hypothesis_bank="./output/hypotheses.json",
test_data="./my_task_test.json"
)
print(f"Multi-hypothesis accuracy: {results['accuracy']:.3f}")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
method |
"hypogenic" |
"hypogenic", "hyporefine", "union" |
Generation strategy |
num_hypotheses |
20 |
5-50 |
Number of hypotheses to generate |
batch_size |
5 |
3-10 |
Samples per generation batch |
max_iterations |
10 |
1-50 |
Refinement iterations |
temperature |
0.7 |
0.0-1.0 |
LLM sampling temperature |
confidence_threshold |
0.7 |
0.5-0.95 |
Inference confidence cutoff |
num_papers |
10 |
5-30 |
Papers for HypoRefine literature extraction |
inference_method |
"voting" |
"voting", "weighted", "ensemble" |
How multiple hypotheses combine predictions |
Key Concepts
Dataset Format
HypoGeniC expects JSON files with parallel lists:
{
"text_features_1": ["sample_1_feat1", "sample_2_feat1"],
"text_features_2": ["sample_1_feat2", "sample_2_feat2"],
"label": ["class_A", "class_B"]
}
- All lists must have equal length
- Feature keys are customizable (
review_text,post_content, etc.) - Labels must match the
extract_label()output format exactly - Three splits required:
<TASK>_train.json,<TASK>_val.json,<TASK>_test.json
Three Generation Methods
| Method | Input | Process | Best For |
|---|---|---|---|
| HypoGeniC | Data only | Init from subset, iteratively refine on validation | Exploratory research, novel datasets without literature |
| HypoRefine | Data + PDFs | Extract literature insights, merge with data patterns, refine both | Extending or validating existing theories |
| Union | Literature + HypoGeniC | Mechanistic combination, deduplication | Maximum hypothesis diversity and coverage |
Configuration Template
Minimal required config.yaml structure:
task_name: my_task
train_data_path: ./my_task_train.json
val_data_path: ./my_task_val.json
test_data_path: ./my_task_test.json
model:
name: "gpt-4" # or claude-3, gpt-3.5-turbo
api_key_env: "OPENAI_API_KEY"
temperature: 0.7
generation:
method: "hypogenic"
num_hypotheses: 20
batch_size: 5
max_iterations: 10
cache:
enabled: true # Redis on localhost:6832
host: "localhost"
port: 6832
prompt_templates:
observations: |
Feature 1: ${text_features_1}
Observation: ${label}
batched_generation:
system: "Generate testable hypotheses."
user: "Generate ${num_hypotheses} hypotheses."
inference:
system: "Evaluate hypothesis against sample."
user: "Hypothesis: ${hypothesis}\nSample: ${sample_text}"
is_relevant:
system: "Check relevance."
user: "Is ${hypothesis} relevant?"
Common Recipes
Recipe: Custom Task from Scratch
When to use: creating a new classification task with domain-specific data.
import json
from hypogenic import BaseTask
# 1. Prepare data splits
for split_name, data in [("train", train_data), ("val", val_data), ("test", test_data)]:
with open(f"my_task_{split_name}.json", "w") as f:
json.dump(data, f)
# 2. Define domain-specific label extractor
def my_extractor(text):
if "positive" in text.lower():
return "positive"
elif "negative" in text.lower():
return "negative"
return text.strip()
# 3. Create task and run full pipeline
task = BaseTask(config_path="./my_task/config.yaml", extract_label=my_extractor)
task.generate_hypotheses(method="hypogenic", num_hypotheses=15, output_path="./output/")
results = task.inference(hypothesis_bank="./output/hypotheses.json")
print(f"Custom task accuracy: {results['accuracy']:.3f}")
Recipe: Literature Processing Setup
When to use: setting up GROBID for PDF-to-structured-text conversion before HypoRefine.
# 1. Setup GROBID (first time only)
bash ./modules/setup_grobid.sh
# 2. Place PDFs in literature directory
mkdir -p literature/my_task/raw/
cp papers/*.pdf literature/my_task/raw/
# 3. Start GROBID and process
bash ./modules/run_grobid.sh
cd examples && python pdf_preprocess.py --task_name my_task
# Output: structured text files in literature/my_task/processed/
Recipe: Union Method for Maximum Coverage
When to use: combining literature and data-driven hypotheses for comprehensive coverage.
# Generate literature hypotheses first (via HypoRefine)
task.generate_hypotheses(
method="hyporefine",
num_hypotheses=15,
literature_path="./literature/my_task/",
output_path="./output/"
)
# Union combines and deduplicates both banks
# CLI alternative:
# hypogenic_generation --config config.yaml --method union \
# --literature_hypotheses output/lit_hypotheses.json
# Compare all three approaches
for bank in ["hypogenic", "hyporefine", "union"]:
r = task.inference(hypothesis_bank=f"./output/{bank}_hypotheses.json")
print(f"{bank}: accuracy={r['accuracy']:.3f}")
Expected Outputs
hypotheses.json-- hypothesis bank with ranked, testable hypotheses (typically 10-20)- Inference results with per-sample predictions and overall accuracy
- For HypoRefine: three hypothesis banks (literature-only, integrated, union)
- Reported improvements: ~9% over few-shot baselines, ~16% over literature-only approaches
- 80-84% hypothesis pair diversity (non-redundant insights)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ModuleNotFoundError: hypogenic |
Package not installed | pip install hypogenic |
| Generic/untestable hypotheses | Prompt templates too vague | Add domain-specific context to batched_generation prompt |
| Poor inference accuracy | Few training examples or bad label extraction | Increase training data; verify extract_label matches dataset labels |
| GROBID PDF processing fails | GROBID service not running | bash ./modules/run_grobid.sh; ensure PDFs are valid papers |
| Label extraction mismatches | extract_label output differs from dataset labels |
Print both formats and align; test with assert extract_label(sample) == expected |
| Redis connection errors | Redis not running or wrong port | Start Redis on port 6832 or set cache.enabled: false |
| API rate limit errors | Too many concurrent LLM calls | Reduce batch_size; enable Redis caching to avoid duplicate calls |
| Empty hypothesis bank | Config missing required prompt templates | Include all four templates: observations, batched_generation, inference, is_relevant |
Bundled Resources
This entry is self-contained. The original references/config_template.yaml (151 lines) has been consolidated into the Key Concepts "Configuration Template" subsection, retaining the essential YAML structure, model/cache/generation parameters, and prompt template patterns. Omitted from the template: evaluation metrics block, logging configuration, task-specific feature/label metadata descriptions -- these are standard YAML patterns users can add as needed.
Related Skills
- hypothesis-generation -- knowhow for manual hypothesis formulation frameworks and scientific method
- scikit-learn-machine-learning -- classical ML for classification when hypothesis interpretability is not needed
- pubmed-database -- literature search to find papers for HypoRefine input
References
- Zhou, Y., Liu, H., Srivastava, T., Mei, H., & Tan, C. (2024). "Hypothesis Generation with Large Language Models." EMNLP Workshop NLP for Science. https://aclanthology.org/2024.nlp4science-1.10/
- Liu, H., Zhou, Y., Li, M., Yuan, C., & Tan, C. (2024). "Literature Meets Data: A Synergistic Approach to Hypothesis Generation." arXiv:2410.17309. https://arxiv.org/abs/2410.17309
- Liu, H., Huang, S., Hu, J., Zhou, Y., & Tan, C. (2025). "HypoBench: Towards Systematic and Principled Benchmarking for Hypothesis Generation." arXiv:2504.11524. https://arxiv.org/abs/2504.11524
- GitHub Repository: https://github.com/ChicagoHAI/hypothesis-generation
- PyPI Package: https://pypi.org/project/hypogenic/
skills/scientific-computing/matlab-scientific-computing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill matlab-scientific-computing -g -y
SKILL.md
Frontmatter
{
"name": "matlab-scientific-computing",
"license": "GPL-3.0 (GNU Octave); MATLAB requires commercial license",
"description": "MATLAB\/GNU Octave numerical computing: matrices, linear algebra, ODEs, signal processing, optimization, statistics, scientific visualization. MATLAB-syntax examples run on both. For Python use numpy\/scipy; for statistical modeling use statsmodels."
}
MATLAB/Octave — Scientific Computing
Overview
MATLAB is a numerical computing environment optimized for matrix operations and scientific computing. GNU Octave is a free, open-source alternative with high compatibility. All code examples use MATLAB syntax that runs on both platforms.
When to Use
- Performing matrix operations and linear algebra (eigenvalues, SVD, least squares)
- Solving ordinary and partial differential equations numerically
- Signal processing (FFT, filtering, spectral analysis)
- Creating 2D/3D scientific visualizations and publication figures
- Numerical optimization and root finding
- Statistical analysis and curve fitting
- Batch processing of experimental data files
- For Python-based numerical computing, use numpy/scipy instead
- For statistical modeling with inference, use statsmodels instead
Prerequisites
# GNU Octave (free, open-source)
# macOS
brew install octave
# Ubuntu/Debian
sudo apt install octave
# Running scripts
octave script.m # Octave
matlab -nodisplay -nosplash -r "run('script.m'); exit;" # MATLAB
Note: MATLAB requires a commercial license from MathWorks. GNU Octave is free and runs most MATLAB scripts without modification. Key Octave differences: supports # comments, ++/+= operators; some MATLAB toolbox functions unavailable.
Quick Start
% Load data, fit, and plot
x = linspace(0, 2*pi, 100);
y = sin(x) + 0.1 * randn(size(x));
p = polyfit(x, y, 5);
y_fit = polyval(p, x);
figure;
plot(x, y, 'bo', x, y_fit, 'r-', 'LineWidth', 2);
xlabel('x'); ylabel('y');
legend('Data', 'Polynomial fit');
title('Curve Fitting Example');
saveas(gcf, 'fit_result.png');
Core API
1. Matrix Operations
MATLAB operates fundamentally on matrices and arrays.
% Create matrices
A = [1 2 3; 4 5 6; 7 8 9]; % 3x3 matrix
v = linspace(0, 1, 100); % 100 evenly spaced points
I = eye(3); % Identity matrix
R = rand(3, 3); % Uniform random
N = randn(3, 3); % Normal random
% Operations
B = A'; % Transpose
C = A * B; % Matrix multiplication
D = A .* B; % Element-wise multiplication
x = A \ [1; 2; 3]; % Solve Ax = b (preferred over inv(A)*b)
fprintf('Solution: [%.2f, %.2f, %.2f]\n', x);
% Indexing and manipulation
A = magic(5);
sub = A(1:3, 2:4); % Submatrix (rows 1-3, cols 2-4)
row = A(2, :); % Entire row 2
col = A(:, 3); % Entire column 3
A(A < 5) = 0; % Logical indexing
% Concatenation
C = [A; ones(1, 5)]; % Vertical (add row)
D = [A, zeros(5, 1)]; % Horizontal (add column)
fprintf('Size: %d x %d\n', size(C));
2. Linear Algebra
A = [4 1 2; 1 3 1; 2 1 5];
% Eigendecomposition
[V, D] = eig(A); % V: eigenvectors, D: diagonal eigenvalues
fprintf('Eigenvalues: %.2f, %.2f, %.2f\n', diag(D));
% Singular value decomposition
[U, S, V] = svd(A);
fprintf('Singular values: %.2f, %.2f, %.2f\n', diag(S));
% Matrix decompositions
[L, U, P] = lu(A); % LU with pivoting
[Q, R] = qr(A); % QR decomposition
R_chol = chol(A); % Cholesky (symmetric positive definite)
% Condition number and rank
fprintf('Condition number: %.2f\n', cond(A));
fprintf('Rank: %d\n', rank(A));
3. Plotting and Visualization
% 2D line plots
x = 0:0.1:2*pi;
figure;
plot(x, sin(x), 'b-', 'LineWidth', 2); hold on;
plot(x, cos(x), 'r--', 'LineWidth', 2);
xlabel('x'); ylabel('y');
title('Trigonometric Functions');
legend('sin(x)', 'cos(x)');
grid on;
saveas(gcf, 'trig.png');
% 3D surface plot
[X, Y] = meshgrid(-2:0.1:2, -2:0.1:2);
Z = X.^2 + Y.^2;
figure;
surf(X, Y, Z);
colorbar; xlabel('X'); ylabel('Y'); zlabel('Z');
title('Paraboloid');
print('-dpdf', 'surface.pdf');
% Multi-panel figure
figure;
subplot(2, 2, 1); plot(x, sin(x)); title('sin');
subplot(2, 2, 2); plot(x, cos(x)); title('cos');
subplot(2, 2, 3); bar([1 3 2 5 4]); title('Bar');
subplot(2, 2, 4); histogram(randn(1000, 1), 30); title('Histogram');
saveas(gcf, 'panels.png');
4. Data Import/Export
% CSV / tabular data
T = readtable('data.csv');
M = readmatrix('data.csv');
fprintf('Table: %d rows x %d cols\n', height(T), width(T));
% Write data
writetable(T, 'output.csv');
writematrix(M, 'output.csv');
% MAT files (MATLAB native binary)
A = rand(100, 100);
save('data.mat', 'A'); % Save variable
S = load('data.mat', 'A'); % Load specific variable
% Images
img = imread('image.png');
fprintf('Image size: %d x %d x %d\n', size(img));
imwrite(img, 'output.jpg');
5. Statistics and Data Analysis
data = randn(1000, 1) * 5 + 50;
% Descriptive statistics
fprintf('Mean: %.2f, Std: %.2f, Median: %.2f\n', mean(data), std(data), median(data));
fprintf('Min: %.2f, Max: %.2f\n', min(data), max(data));
% Correlation and covariance
X = randn(100, 3);
R = corrcoef(X);
fprintf('Correlation matrix:\n');
disp(R);
% Linear regression (polyfit)
x = (1:50)';
y = 2.5 * x + 10 + randn(50, 1) * 5;
p = polyfit(x, y, 1);
fprintf('Slope: %.2f, Intercept: %.2f\n', p(1), p(2));
% Moving statistics
y_smooth = movmean(y, 5);
6. Differential Equations
% First-order ODE: dy/dt = -2y, y(0) = 1
f = @(t, y) -2 * y;
[t, y] = ode45(f, [0 5], 1);
figure; plot(t, y, 'b-', 'LineWidth', 2);
xlabel('Time'); ylabel('y(t)');
title('Exponential Decay');
fprintf('Final value: %.4f (expected: %.4f)\n', y(end), exp(-10));
% Second-order ODE: y'' + 0.5y' + 4y = 0 (damped oscillator)
% Convert to system: y1' = y2, y2' = -0.5*y2 - 4*y1
f = @(t, y) [y(2); -0.5*y(2) - 4*y(1)];
[t, y] = ode45(f, [0 20], [1; 0]);
figure; plot(t, y(:,1), 'b-', 'LineWidth', 2);
xlabel('Time'); ylabel('Displacement');
title('Damped Oscillator');
7. Signal Processing
% Generate signal with two frequencies
fs = 1000; % Sampling frequency
t = 0:1/fs:1-1/fs;
signal = sin(2*pi*50*t) + 0.5*sin(2*pi*120*t) + randn(size(t))*0.2;
% FFT
Y = fft(signal);
f = (0:length(Y)-1) * fs / length(Y);
figure;
plot(f(1:length(f)/2), abs(Y(1:length(Y)/2)));
xlabel('Frequency (Hz)'); ylabel('|FFT|');
title('Frequency Spectrum');
% FIR low-pass filter (keep < 80 Hz)
b = fir1(50, 80/(fs/2)); % 50th order, cutoff 80 Hz
filtered = filter(b, 1, signal);
figure;
plot(t, signal, 'b', t, filtered, 'r', 'LineWidth', 1.5);
legend('Original', 'Filtered');
title('Low-pass Filtering');
8. Functions and Programming
% Anonymous functions
f = @(x) x.^2 + 2*x + 1;
fprintf('f(5) = %d\n', f(5)); % 36
% Function files (save as myfunc.m)
% function [rmse, r2] = myfunc(y_true, y_pred)
% residuals = y_true - y_pred;
% rmse = sqrt(mean(residuals.^2));
% ss_res = sum(residuals.^2);
% ss_tot = sum((y_true - mean(y_true)).^2);
% r2 = 1 - ss_res / ss_tot;
% end
% Struct for organized data
experiment.name = 'Trial 1';
experiment.data = rand(10, 3);
experiment.params = struct('temp', 37, 'pH', 7.4);
fprintf('Experiment: %s, %d samples\n', experiment.name, size(experiment.data, 1));
Key Concepts
Vectorization
MATLAB is optimized for vectorized operations. Avoid explicit loops when possible:
% Slow (loop)
n = 1e6;
y = zeros(1, n);
tic;
for i = 1:n
y(i) = sin(i/1000);
end
t_loop = toc;
% Fast (vectorized)
tic;
y = sin((1:n)/1000);
t_vec = toc;
fprintf('Loop: %.3fs, Vectorized: %.3fs, Speedup: %.1fx\n', t_loop, t_vec, t_loop/t_vec);
Element-wise vs Matrix Operations
| Operator | Matrix | Element-wise |
|---|---|---|
| Multiply | A * B |
A .* B |
| Divide | A / B (right div) |
A ./ B |
| Power | A ^ n (matrix power) |
A .^ n |
ODE Solver Selection
| Solver | Order | When to Use |
|---|---|---|
ode45 |
4-5 | Default. Most non-stiff problems |
ode23 |
2-3 | Rough solutions, faster per step |
ode113 |
variable | High-accuracy, expensive evaluations |
ode15s |
variable | Stiff problems (chemical kinetics, circuits) |
ode23s |
2 | Stiff, moderate accuracy |
Common Workflows
Workflow: Data Analysis Pipeline
% 1. Load data
data = readtable('experiment.csv');
% 2. Clean
data = rmmissing(data);
fprintf('After cleaning: %d rows\n', height(data));
% 3. Group analysis
groups = unique(data.Category);
results = table();
for i = 1:length(groups)
mask = strcmp(data.Category, groups{i});
subset = data(mask, :);
row = table(groups(i), mean(subset.Value), std(subset.Value), ...
'VariableNames', {'Category', 'Mean', 'Std'});
results = [results; row];
end
disp(results);
% 4. Visualize and save
figure;
bar(categorical(results.Category), results.Mean);
hold on;
errorbar(1:height(results), results.Mean, results.Std, '.k');
ylabel('Mean Value'); title('Results by Category');
saveas(gcf, 'results.png');
writetable(results, 'summary.csv');
Workflow: Numerical Simulation (Heat Equation)
% 1D heat equation: du/dt = alpha * d2u/dx2
L = 1; N = 100; T_end = 0.5; alpha = 0.01;
dx = L / (N - 1);
dt = 0.4 * dx^2 / alpha; % CFL condition
x = linspace(0, L, N);
nsteps = floor(T_end / dt);
% Initial condition: Gaussian pulse
u = exp(-50 * (x - 0.5).^2);
% Time stepping (explicit finite difference)
figure;
for step = 1:nsteps
u_new = u;
for i = 2:N-1
u_new(i) = u(i) + alpha * dt / dx^2 * (u(i+1) - 2*u(i) + u(i-1));
end
u = u_new;
if mod(step, floor(nsteps/5)) == 0
plot(x, u, 'LineWidth', 1.5); hold on;
end
end
xlabel('Position'); ylabel('Temperature');
title('Heat Equation Evolution');
legend(arrayfun(@(n) sprintf('t=%.2f', n*dt*floor(nsteps/5)), 1:5, 'UniformOutput', false));
saveas(gcf, 'heat_equation.png');
Workflow: Batch File Processing
- List files with
dir('data/*.csv') - Loop through files, load each with
readtable() - Apply analysis function to each file
- Collect results into a summary table with
vertcat() - Export summary with
writetable()
Key Parameters
| Parameter | Function | Default | Range | Effect |
|---|---|---|---|---|
| Order | polyfit |
— | 1–20 | Polynomial degree for fitting |
tspan |
ode45 |
— | [t0, tf] | Integration time interval |
'LineWidth' |
plot |
0.5 | 0.1–5 | Line thickness in plots |
bins |
histogram |
auto | 1–1000 | Number of histogram bins |
| Filter order | fir1 |
— | 10–200 | FIR filter order (higher = sharper) |
'RelTol' |
ode45 |
1e-3 | 1e-12–1e-1 | Relative error tolerance |
'AbsTol' |
ode45 |
1e-6 | 1e-15–1e-1 | Absolute error tolerance |
Best Practices
-
Always vectorize over loops: MATLAB's JIT is good but vectorized code is 10-100x faster for large arrays. Use
bsxfun, logical indexing, and array operations. -
Preallocate arrays: Growing arrays in loops causes repeated memory allocation.
% Bad: y = []; for i=1:n, y = [y, f(i)]; end % Good: y = zeros(1, n); for i = 1:n, y(i) = f(i); end -
Use
\instead ofinv()for linear systems:A\bis numerically more stable and faster thaninv(A)*b. -
Anti-pattern — using
==for floating-point comparison: Useabs(a - b) < tolinstead ofa == bdue to floating-point precision. -
Save figures in vector format for publications: Use
print('-dpdf', 'fig.pdf')orprint('-dsvg', 'fig.svg')instead of PNG for scalable figures. -
Anti-pattern — mixing 0-indexed and 1-indexed logic: MATLAB arrays start at 1. When porting from Python/C, adjust all indices.
-
Use
fprintfoverdispfor formatted output:fprintfgives control over number formatting;disponly shows raw values.
Common Recipes
Recipe: Curve Fitting with Confidence Intervals
x = (1:20)';
y = 3 * exp(-0.2 * x) + 0.5 * randn(size(x));
% Nonlinear fit using lsqcurvefit (Optimization Toolbox)
% Or use polyfit for polynomial:
p = polyfit(x, y, 3);
y_fit = polyval(p, x);
residuals = y - y_fit;
rmse = sqrt(mean(residuals.^2));
fprintf('RMSE: %.4f\n', rmse);
figure;
plot(x, y, 'ko', x, y_fit, 'r-', 'LineWidth', 2);
xlabel('x'); ylabel('y');
title(sprintf('Polynomial Fit (RMSE = %.3f)', rmse));
saveas(gcf, 'curvefit.png');
Recipe: Image Processing Basics
img = imread('sample.png');
gray = rgb2gray(img);
% Edge detection
edges = edge(gray, 'Canny');
% Display
figure;
subplot(1, 3, 1); imshow(img); title('Original');
subplot(1, 3, 2); imshow(gray); title('Grayscale');
subplot(1, 3, 3); imshow(edges); title('Edges');
saveas(gcf, 'image_processing.png');
Recipe: Python Integration
% Call Python from MATLAB (requires Python on PATH)
result = py.numpy.array([1, 2, 3, 4, 5]);
py_mean = py.numpy.mean(result);
fprintf('Python numpy mean: %.1f\n', double(py_mean));
% Call MATLAB from Python (requires matlab.engine)
% import matlab.engine
% eng = matlab.engine.start_matlab()
% result = eng.sqrt(42.0)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Undefined function or variable |
Function not on path or misspelled | Check which funcname; add path with addpath() |
Dimension mismatch in * |
Matrix sizes incompatible | Use .* for element-wise; check size() of both operands |
| ODE solver very slow | Stiff problem with non-stiff solver | Switch to ode15s or ode23s for stiff systems |
Singular matrix warning |
Matrix is rank-deficient | Check cond(A); use pinv() (pseudo-inverse) or regularize |
Octave pkg error |
Package not installed | Run pkg install -forge package_name; pkg load package_name |
| Figure not saving | No figure handle active |
Create figure explicitly with figure; before plotting |
Out of memory |
Large array allocation | Use sparse matrices (sparse()), process in chunks, or increase swap |
Related Skills
- statsmodels-statistical-modeling — Python alternative for statistical modeling with inference tables
- matplotlib-scientific-plotting — Python plotting; use when working in Python ecosystem
- scikit-learn-machine-learning — Python ML; for classification/clustering tasks better suited to Python
References
- GNU Octave documentation — official manual and function reference
- MATLAB documentation — MathWorks official reference
- MATLAB–Octave compatibility — key syntax and feature differences
skills/scientific-computing/nan-safe-correlation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill nan-safe-correlation -g -y
SKILL.md
Frontmatter
{
"name": "nan-safe-correlation",
"license": "CC-BY-4.0",
"description": "Per-feature NaN-safe Spearman\/Pearson correlation across many features (genes, proteins, variants) with missing values. Covers why bulk matrix shortcuts fail, correct pairwise deletion, degenerate input filtering, and large-dataset performance. Use statistical-analysis for test choice; shap-model-explainability for interpretability."
}
NaN-Safe Correlation Computation
Overview
Computing correlations across many features (genes, proteins, variants) when missing values are present is error-prone. The most common mistake is using bulk matrix shortcuts that silently mishandle NaN, producing incorrect correlation values. This guide covers correct per-feature pairwise computation, degenerate input filtering, and performance optimization.
Key Concepts
Pairwise vs Listwise Deletion
- Pairwise deletion: For each feature pair, remove only samples where either value is NaN. Each feature uses the maximum available data.
- Listwise deletion: Remove any sample with NaN in any feature. Wastes valid data and biases results if missingness is not completely random.
- Rule: Always use pairwise deletion for per-feature correlations.
Why Bulk Matrix Shortcuts Fail
Different features have different missing value patterns across samples. Bulk methods handle this inconsistently:
| Method | Problem |
|---|---|
DataFrame.rank() then corrwith() |
rank() assigns NaN ranks; corrwith() may drop globally or per-column inconsistently |
DataFrame.corrwith(method='spearman') |
Implementation varies by pandas version; may use listwise deletion |
np.corrcoef on ranked data |
Propagates NaN to entire result if any value is missing |
Impact of Incorrect Computation
- Correlations can shift by 0.01-0.05 or more
- Features near a threshold (e.g., 0.6) can be misclassified
- Valid sample count per feature is unknown (may silently use fewer samples than expected)
Degenerate Inputs
Features that produce undefined or unstable correlations:
| Type | Description | Effect |
|---|---|---|
| Constant features | All values identical (variance = 0) | Correlation undefined (division by zero) |
| Near-constant features | Very low variance | Correlation numerically unstable |
| Too few valid values | After NaN removal, fewer than min_valid pairs | Statistically unreliable |
| Single-value after filtering | Only one unique value remains post-NaN removal | Correlation undefined |
Decision Framework
Do you have missing values (NaN) in your feature matrix?
├── No NaN at all → Bulk methods are safe (corrwith, np.corrcoef)
└── Yes, NaN present
├── Same NaN pattern across all features? → Listwise deletion is acceptable
└── Different NaN patterns per feature (typical)
├── < 10,000 features → Per-feature loop with scipy.stats.spearmanr
└── > 10,000 features → Parallelized per-feature loop (joblib)
| Scenario | Recommended Approach | Rationale |
|---|---|---|
| No missing data | DataFrame.corrwith() |
Fast, correct when no NaN |
| Sparse NaN, < 10K features | Per-feature spearmanr loop |
Correct pairwise deletion, acceptable speed |
| Sparse NaN, > 10K features | Parallelized per-feature loop | Same correctness, scales with cores |
| Dense NaN (> 50% missing) | Per-feature loop + strict min_valid | Many features will be skipped; report skip count |
| Uniform NaN pattern | Listwise deletion + bulk method | If all features share same NaN rows, pairwise = listwise |
Best Practices
-
Always print NaN summary before analysis: Report total NaN count, features with any NaN, and per-feature NaN distribution. This documents data quality and alerts you to severe missingness patterns.
-
Use scipy.stats.spearmanr per feature in a loop: This is the only method that guarantees correct pairwise NaN removal for each feature independently.
-
Set a minimum valid pair threshold (min_valid): Default to 10. Features with fewer valid pairs after NaN removal produce unreliable correlations and should be skipped with NaN.
-
Filter degenerate inputs before computing correlations: Remove constant features, near-constant features, and features with excessive NaN before the correlation loop. This avoids undefined results and speeds up computation.
-
Track n_valid per feature in the output: The number of valid pairs varies per feature. Report it alongside rho and p-value so downstream analysis can assess reliability.
-
Report how many features were skipped or filtered: Silent feature loss is a common source of confusion. Always print the count of filtered degenerate features and skipped low-data features.
-
Use parallelization for large datasets: For > 10,000 features, use joblib to distribute the per-feature loop across cores. The per-feature computation is embarrassingly parallel.
Common Pitfalls
-
Using bulk rank-then-correlate with NaN present:
df.rank()followed bycorrwith()silently mishandles NaN, producing incorrect correlations.- How to avoid: Always use
scipy.stats.spearmanrper feature when NaN is present.
- How to avoid: Always use
-
Assuming uniform sample count across features: Different features have different NaN patterns, so each correlation is computed on a different number of samples.
- How to avoid: Track and report
n_validfor every feature.
- How to avoid: Track and report
-
Not filtering degenerate inputs: Constant or near-constant features produce undefined correlations or divide-by-zero warnings that can silently corrupt results.
- How to avoid: Run
filter_degenerate()before the correlation loop.
- How to avoid: Run
-
Using listwise deletion when NaN patterns differ: Listwise deletion removes any row with NaN in any feature, potentially discarding most of your data.
- How to avoid: Use pairwise deletion (per-feature NaN removal).
-
Ignoring the NaN summary step: Skipping the data quality report means you cannot verify whether the NaN pattern is severe enough to affect results.
- How to avoid: Always print NaN summary before correlation computation.
-
Setting min_valid too low: With fewer than ~10 valid pairs, Spearman correlation is unreliable and p-values are meaningless.
- How to avoid: Use min_valid >= 10; increase for high-dimensional studies.
Workflow
-
Step 1: Print NaN Summary
- Report dataset shape, total NaN, features with any NaN, mean/max NaN per feature
- Decision point: If > 50% NaN overall, reconsider data quality before proceeding
-
Step 2: Filter Degenerate Features
- Remove constant features (nunique < 3)
- Remove features with excessive NaN (< 50% non-NaN)
- Report count of removed features
-
Step 3: Compute Per-Feature Correlations
- Loop over features with
scipy.stats.spearmanr - Apply pairwise NaN removal per feature
- Skip features with < min_valid valid pairs (record as NaN)
- Loop over features with
-
Step 4: Assemble and Report Results
- Create DataFrame with rho, p-value, n_valid per feature
- Report count of skipped features
- Verify no silent data loss (input features = output features + skipped + filtered)
Reference Implementation
from scipy.stats import spearmanr
import numpy as np
import pandas as pd
def nan_summary(df):
"""Print NaN summary before correlation analysis."""
print(f"Dataset shape: {df.shape}")
print(f"Total NaN: {df.isna().sum().sum()}")
print(f"Features with any NaN: {(df.isna().any()).sum()}")
print(f"NaN per feature (mean): {df.isna().sum().mean():.1f}")
print(f"NaN per feature (max): {df.isna().sum().max()}")
def filter_degenerate(df, min_unique=3, min_nonnan_frac=0.5):
"""Remove degenerate features before correlation analysis.
Args:
df: DataFrame (samples x features)
min_unique: Minimum number of unique non-NaN values required
min_nonnan_frac: Minimum fraction of non-NaN values required
Returns:
Filtered DataFrame, count of removed features
"""
n_samples = len(df)
keep = []
for col in df.columns:
values = df[col].dropna()
if len(values) < n_samples * min_nonnan_frac:
continue
if values.nunique() < min_unique:
continue
keep.append(col)
removed = len(df.columns) - len(keep)
print(f"Filtered {removed} degenerate features out of {len(df.columns)}")
return df[keep], removed
def pairwise_spearman(df_x, df_y, min_valid=10):
"""Compute per-feature Spearman correlation with pairwise NaN removal.
Args:
df_x: DataFrame (samples x features), aligned with df_y
df_y: DataFrame (samples x features), same shape as df_x
min_valid: Minimum number of valid (non-NaN) pairs required
Returns:
DataFrame with columns: rho, pvalue, n_valid
"""
nan_summary(df_x)
nan_summary(df_y)
results = []
for feature in df_x.columns:
x = df_x[feature].values
y = df_y[feature].values
mask = ~(np.isnan(x) | np.isnan(y))
n_valid = mask.sum()
if n_valid < min_valid:
results.append({'feature': feature, 'rho': np.nan,
'pvalue': np.nan, 'n_valid': n_valid})
continue
rho, pval = spearmanr(x[mask], y[mask])
results.append({'feature': feature, 'rho': rho,
'pvalue': pval, 'n_valid': n_valid})
result_df = pd.DataFrame(results).set_index('feature')
skipped = result_df['rho'].isna().sum()
if skipped > 0:
print(f"Skipped {skipped} features with < {min_valid} valid pairs")
return result_df
Anti-Patterns
# WRONG: Bulk rank-then-correlate
ranked_x = df_x.rank()
ranked_y = df_y.rank()
corrs = ranked_x.corrwith(ranked_y)
# WRONG: Bulk corrwith with method parameter
corrs = df_x.corrwith(df_y, method='spearman')
# WRONG: numpy corrcoef on ranked arrays (propagates NaN)
corrs = np.corrcoef(df_x.rank().values.T, df_y.rank().values.T)
Performance Optimization (> 10,000 features)
from joblib import Parallel, delayed
def parallel_spearman(df_x, df_y, min_valid=10, n_jobs=4):
"""Parallelized per-feature Spearman correlation."""
def compute_one(feature):
x = df_x[feature].values
y = df_y[feature].values
mask = ~(np.isnan(x) | np.isnan(y))
n = mask.sum()
if n < min_valid:
return feature, np.nan, np.nan, n
rho, pval = spearmanr(x[mask], y[mask])
return feature, rho, pval, n
results = Parallel(n_jobs=n_jobs)(
delayed(compute_one)(f) for f in df_x.columns
)
return pd.DataFrame(
results, columns=['feature', 'rho', 'pvalue', 'n_valid']
).set_index('feature')
Further Reading
- scipy.stats.spearmanr documentation -- Official API reference
- pandas corrwith documentation -- Known NaN handling behavior
- Pairwise vs Listwise Deletion -- Chapter from Flexible Imputation of Missing Data
Related Skills
statistical-analysis-- General statistical test selection and assumption checkingdegenerate-input-filtering-- Broader guide on filtering uninformative data before any statistical testscikit-learn-machine-learning-- Feature selection and preprocessing pipelines
skills/scientific-computing/networkx-graph-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill networkx-graph-analysis -g -y
SKILL.md
Frontmatter
{
"name": "networkx-graph-analysis",
"license": "BSD-3-Clause",
"description": "Graph and network analysis toolkit. Four graph types (directed, undirected, multi-edge), centrality, shortest paths, community detection, generators, I\/O (GraphML, GML, edge list), matplotlib viz. For large graphs (100K+ nodes) use igraph or graph-tool; for GNNs use PyG."
}
NetworkX Graph Analysis
Overview
NetworkX is a Python library for creating, manipulating, and analyzing complex networks and graphs. It provides data structures for undirected, directed, and multi-edge graphs along with a comprehensive collection of graph algorithms, generators, and I/O utilities. Use NetworkX when working with relationship data in social networks, biological interaction networks, transportation systems, citation graphs, or any domain involving pairwise entity relationships.
When to Use
- Analyzing protein-protein interaction networks, gene regulatory networks, or metabolic pathways
- Computing centrality measures (degree, betweenness, PageRank) to identify important nodes
- Finding shortest paths or optimal routes in transportation or communication networks
- Detecting communities or clusters in social networks or co-expression data
- Generating synthetic networks (scale-free, small-world, random) for simulation or null models
- Reading and writing graph data in standard formats (GraphML, GML, edge lists, JSON)
- Visualizing network topology with node/edge attribute mapping
- Checking graph properties: connectivity, planarity, isomorphism, DAG structure
- For large-scale graphs (100K+ nodes) where speed is critical, use
igraphorgraph-toolinstead - For billion-edge graphs or GPU-accelerated analytics, use
graph-toolwith OpenMP orcuGraph - For graph neural networks and deep learning on graphs, use
torch-geometric-graph-neural-networks
Prerequisites
- Python packages:
networkx,matplotlib,scipy,pandas,numpy - Optional:
pydotorpygraphviz(Graphviz layouts)
pip install networkx matplotlib scipy pandas numpy
Quick Start
import networkx as nx
# Create a graph and add edges with weights
G = nx.karate_club_graph()
print(f"Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}")
# Nodes: 34, Edges: 78
# Compute centrality and find most central node
bc = nx.betweenness_centrality(G)
top_node = max(bc, key=bc.get)
print(f"Most central node: {top_node}, betweenness: {bc[top_node]:.3f}")
# Detect communities
from networkx.algorithms import community
comms = community.greedy_modularity_communities(G)
print(f"Communities found: {len(comms)}")
Core API
Module 1: Graph Creation and Types
import networkx as nx
# Undirected graph (most common)
G = nx.Graph()
G.add_node("protein_A", type="kinase", weight=1.5)
G.add_nodes_from(["protein_B", "protein_C"])
G.add_edge("protein_A", "protein_B", weight=0.9, interaction="phosphorylation")
G.add_edges_from([("protein_B", "protein_C"), ("protein_A", "protein_C")])
print(f"Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}")
# Nodes: 3, Edges: 3
# Directed graph (gene regulation, citations)
D = nx.DiGraph()
D.add_edges_from([("TF1", "geneA"), ("TF1", "geneB"), ("TF2", "geneA")])
print(f"TF1 out-degree: {D.out_degree('TF1')}") # 2
# MultiGraph (multiple relationship types between same nodes)
M = nx.MultiGraph()
M.add_edge("A", "B", key="binding", affinity=0.8)
M.add_edge("A", "B", key="regulation", effect="inhibition")
print(f"Edges between A-B: {M.number_of_edges('A', 'B')}") # 2
Module 2: Node and Edge Operations
import networkx as nx
G = nx.karate_club_graph()
# Query structure
print(f"Degree of node 0: {G.degree(0)}")
print(f"Neighbors of node 0: {list(G.neighbors(0))[:5]}")
print(f"Has edge 0-1: {G.has_edge(0, 1)}")
# Set and get attributes
G.nodes[0]["role"] = "instructor"
nx.set_node_attributes(G, {0: "high", 33: "high"}, "importance")
G[0][1]["weight"] = 0.95
# Iterate with data
for u, v, data in G.edges(data=True):
if "weight" in data:
print(f" Edge {u}-{v}: weight={data['weight']}")
break
# Subgraphs (returns read-only view; use .copy() for mutable)
H = G.subgraph([0, 1, 2, 3, 4, 5]).copy()
print(f"Subgraph: {H.number_of_nodes()} nodes, {H.number_of_edges()} edges")
Module 3: Graph Analysis (Centrality)
import networkx as nx
G = nx.karate_club_graph()
degree_c = nx.degree_centrality(G)
between_c = nx.betweenness_centrality(G, weight="weight")
# For large graphs, approximate: nx.betweenness_centrality(G, k=100)
close_c = nx.closeness_centrality(G)
eigen_c = nx.eigenvector_centrality(G, max_iter=1000)
pr = nx.pagerank(G, alpha=0.85)
# Compare top nodes across measures
for name, metric in [("Degree", degree_c), ("Betweenness", between_c),
("Closeness", close_c), ("PageRank", pr)]:
top = max(metric, key=metric.get)
print(f"{name:12s}: top node={top}, score={metric[top]:.4f}")
Module 4: Path and Connectivity
import networkx as nx
G = nx.karate_club_graph()
# Shortest path
path = nx.shortest_path(G, source=0, target=33)
length = nx.shortest_path_length(G, source=0, target=33)
print(f"Shortest path 0->33: {path} (length {length})")
print(f"Average shortest path length: {nx.average_shortest_path_length(G):.3f}")
# Connected components
print(f"Connected: {nx.is_connected(G)}")
components = list(nx.connected_components(G))
print(f"Components: {len(components)}, largest: {len(max(components, key=len))}")
# For directed graphs: strong/weak connectivity
D = nx.DiGraph([(0,1),(1,2),(2,0),(3,4)])
print(f"Strongly connected: {list(nx.strongly_connected_components(D))}")
# Connectivity measures
print(f"Node connectivity: {nx.node_connectivity(G)}")
print(f"Edge connectivity: {nx.edge_connectivity(G)}")
Module 5: Community Detection
Partition networks into densely connected groups.
import networkx as nx
from networkx.algorithms import community
import itertools
G = nx.karate_club_graph()
# Greedy modularity maximization
comms_greedy = community.greedy_modularity_communities(G)
mod_score = community.modularity(G, comms_greedy)
print(f"Greedy: {len(comms_greedy)} communities, modularity={mod_score:.4f}")
# Label propagation (fast, non-deterministic)
comms_lpa = community.label_propagation_communities(G)
print(f"Label propagation: {len(list(comms_lpa))} communities")
# Girvan-Newman (hierarchical, edge betweenness removal)
gn = community.girvan_newman(G)
# Get first level of partition
first_level = next(gn)
print(f"Girvan-Newman first split: {len(first_level)} groups")
print(f" Sizes: {[len(c) for c in first_level]}")
Module 6: I/O and Serialization
import networkx as nx
import pandas as pd
import json
G = nx.karate_club_graph()
# Edge list (simple text format)
nx.write_edgelist(G, "karate.edgelist")
G_loaded = nx.read_edgelist("karate.edgelist", nodetype=int)
# GraphML (preserves all attributes, XML-based)
nx.write_graphml(G, "karate.graphml")
G_xml = nx.read_graphml("karate.graphml")
# JSON (node-link format, web-friendly for d3.js)
data = nx.node_link_data(G)
with open("karate.json", "w") as f:
json.dump(data, f)
# Pandas integration
df = pd.DataFrame({"source": [1,2,3], "target": [2,3,4], "weight": [0.5,1.0,0.75]})
G_pd = nx.from_pandas_edgelist(df, "source", "target", edge_attr="weight")
df_out = nx.to_pandas_edgelist(G_pd)
print(f"Pandas round-trip: {len(df_out)} edges")
# NumPy/SciPy matrices
A = nx.to_numpy_array(G)
print(f"Adjacency matrix shape: {A.shape}")
A_sparse = nx.to_scipy_sparse_array(G, format="csr") # Memory-efficient
Module 7: Visualization
import networkx as nx
import matplotlib.pyplot as plt
G = nx.karate_club_graph()
pos = nx.spring_layout(G, seed=42)
# Color by degree, size by betweenness centrality
bc = nx.betweenness_centrality(G)
fig, ax = plt.subplots(figsize=(10, 8))
nx.draw(G, pos=pos, ax=ax,
node_color=[G.degree(n) for n in G.nodes()], cmap=plt.cm.viridis,
node_size=[3000 * bc[n] + 100 for n in G.nodes()],
edge_color="gray", alpha=0.8, with_labels=True, font_size=8)
plt.tight_layout()
plt.savefig("network.png", dpi=300, bbox_inches="tight")
plt.savefig("network.pdf", bbox_inches="tight") # Vector format
print("Saved network.png and network.pdf")
Module 8: Generators
import networkx as nx
# Erdos-Renyi random graph: n nodes, edge probability p
G_er = nx.erdos_renyi_graph(n=200, p=0.05, seed=42)
print(f"ER: {G_er.number_of_nodes()} nodes, {G_er.number_of_edges()} edges")
# Barabasi-Albert scale-free (power-law degree distribution)
G_ba = nx.barabasi_albert_graph(n=200, m=3, seed=42)
# Watts-Strogatz small-world
G_ws = nx.watts_strogatz_graph(n=200, k=6, p=0.1, seed=42)
print(f"WS clustering: {nx.average_clustering(G_ws):.3f}")
# Stochastic block model (community structure)
sizes, probs = [50, 50, 50], [[0.25,0.05,0.02],[0.05,0.35,0.07],[0.02,0.07,0.40]]
G_sbm = nx.stochastic_block_model(sizes, probs, seed=42)
# Built-in datasets and classic graphs
G_karate = nx.karate_club_graph() # Zachary's karate club
G_grid = nx.grid_2d_graph(5, 7) # 2D lattice
G_tree = nx.random_tree(n=50, seed=42) # Random tree
G_geo = nx.random_geometric_graph(n=100, radius=0.2, seed=42)
# See references/algorithms_generators.md for full generator catalog
Key Concepts
Graph Types
| Class | Directed | Multi-edge | Self-loops | Use Case |
|---|---|---|---|---|
Graph |
No | No | Yes | Undirected networks: social, PPI |
DiGraph |
Yes | No | Yes | Gene regulation, citations, web |
MultiGraph |
No | Yes | Yes | Multiple relationship types |
MultiDiGraph |
Yes | Yes | Yes | Transportation with routes |
Attribute Patterns
Attributes are stored as dictionaries at graph, node, and edge levels:
import networkx as nx
G = nx.Graph(name="example") # Graph-level attribute
G.add_node(1, label="hub", weight=1.5) # Node attributes
G.add_edge(1, 2, weight=0.8, type="ppi") # Edge attributes
# Bulk set/get
nx.set_node_attributes(G, {1: "red", 2: "blue"}, "color")
colors = nx.get_node_attributes(G, "color") # {1: 'red', 2: 'blue'}
Layout Algorithms
| Layout | Function | Best For |
|---|---|---|
| Spring (force-directed) | spring_layout(G, seed=42) |
General networks |
| Circular | circular_layout(G) |
Regular graphs, cycles |
| Kamada-Kawai | kamada_kawai_layout(G) |
Small-medium networks |
| Spectral | spectral_layout(G) |
Highlighting clusters |
| Shell (concentric) | shell_layout(G, nlist=[[...],[...]]) |
Layered/hierarchical |
| Planar | planar_layout(G) |
Planar graphs only |
Common Workflows
Workflow 1: Social Network Analysis
Goal: Identify influential actors, detect communities, and visualize.
import networkx as nx
import matplotlib.pyplot as plt
from networkx.algorithms import community
# Step 1: Load network and basic stats
G = nx.karate_club_graph()
print(f"Network: {G.number_of_nodes()} actors, {G.number_of_edges()} ties")
print(f"Density: {nx.density(G):.4f}, Clustering: {nx.average_clustering(G):.4f}")
# Step 2: Identify influential nodes
bc = nx.betweenness_centrality(G)
top_bc = sorted(bc.items(), key=lambda x: x[1], reverse=True)[:5]
print("Top 5 by betweenness:", [(n, f"{s:.3f}") for n, s in top_bc])
# Step 3: Detect communities
comms = community.greedy_modularity_communities(G)
print(f"Communities: {len(comms)}, modularity: {community.modularity(G, comms):.4f}")
# Step 4: Visualize with community coloring
pos = nx.spring_layout(G, seed=42)
fig, ax = plt.subplots(figsize=(10, 8))
for i, comm in enumerate(comms):
nx.draw_networkx_nodes(G, pos, nodelist=list(comm), ax=ax,
node_color=[plt.cm.Set2(i)]*len(comm), node_size=400)
nx.draw_networkx_edges(G, pos, ax=ax, alpha=0.3)
nx.draw_networkx_labels(G, pos, ax=ax, font_size=8)
plt.axis("off")
plt.tight_layout()
plt.savefig("social_network_analysis.png", dpi=300, bbox_inches="tight")
print("Saved social_network_analysis.png")
Workflow 2: Biological Interaction Network
Goal: Build a PPI network from tabular data, analyze topology, and identify hub proteins.
import networkx as nx
import pandas as pd
# Step 1: Load interaction data from DataFrame
interactions = pd.DataFrame({
"protein_a": ["TP53","TP53","BRCA1","BRCA1","MDM2","ATM","ATM","CHEK2","RB1","CDK2"],
"protein_b": ["MDM2","BRCA1","ATM","CHEK2","RB1","CHEK2","BRCA2","CDC25A","CDK2","CCNA2"],
"score": [0.99, 0.95, 0.92, 0.88, 0.91, 0.97, 0.85, 0.90, 0.87, 0.93]
})
G = nx.from_pandas_edgelist(interactions, "protein_a", "protein_b",
edge_attr="score")
print(f"PPI network: {G.number_of_nodes()} proteins, {G.number_of_edges()} interactions")
# Step 2: Network statistics
print(f"Connected: {nx.is_connected(G)}")
print(f"Diameter: {nx.diameter(G)}")
print(f"Avg path length: {nx.average_shortest_path_length(G):.2f}")
print(f"Transitivity: {nx.transitivity(G):.4f}")
# Step 3: Hub identification (multiple centrality measures)
degree_c = nx.degree_centrality(G)
between_c = nx.betweenness_centrality(G)
close_c = nx.closeness_centrality(G)
results = pd.DataFrame({
"protein": list(G.nodes()),
"degree_centrality": [degree_c[n] for n in G.nodes()],
"betweenness": [between_c[n] for n in G.nodes()],
"closeness": [close_c[n] for n in G.nodes()],
}).sort_values("betweenness", ascending=False)
print("\nHub proteins:")
print(results.head(5).to_string(index=False))
# Step 4: Export for downstream analysis
nx.write_graphml(G, "ppi_network.graphml")
results.to_csv("protein_centrality.csv", index=False)
print("Exported ppi_network.graphml and protein_centrality.csv")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
weight |
Paths/Centrality | None |
Edge attribute name | Use weighted edges for path/centrality calculations |
alpha |
pagerank |
0.85 |
0.0-1.0 |
Damping factor; lower = more uniform distribution |
k |
betweenness_centrality |
None |
int |
Sample k nodes for approximation on large graphs |
max_iter |
eigenvector_centrality |
100 |
int |
Max iterations for convergence |
seed |
Generators/Layouts | None |
int |
Random seed for reproducibility |
n / p / m |
ER/BA generators | varies | int/float |
Node count, edge probability, edges per new node |
k / p |
Watts-Strogatz | varies | int/float |
Nearest neighbors, rewiring probability |
nodetype |
read_edgelist |
str |
int, float, str |
Type conversion for node identifiers |
edge_attr |
from_pandas_edgelist |
None |
Column name(s) | Edge attribute columns to include from DataFrame |
format |
to_scipy_sparse_array |
"csc" |
"csr", "csc", "coo" |
Sparse matrix format |
Best Practices
-
Always set random seeds for reproducible generators and layouts:
seed=42in botherdos_renyi_graph()andspring_layout(). -
Use approximate algorithms for large graphs:
nx.betweenness_centrality(G, k=500)samples k nodes instead of all pairs. -
Prefer
from_pandas_edgelistover manualadd_edgeloops for bulk data loading -- handles attributes cleanly and is faster. -
Copy subgraphs before modification:
G.subgraph(nodes)returns a read-only view; call.copy()for a mutable independent graph. -
Use GraphML or GML for persistent storage to preserve all node/edge attributes. Edge lists lose metadata unless explicitly handled.
-
Convert graph types explicitly:
D.to_undirected()(DiGraph -> Graph),nx.Graph(M)(MultiGraph -> Graph, collapses multi-edges). -
Use sparse matrices for large adjacency exports:
to_scipy_sparse_array()is far more memory-efficient thanto_numpy_array(). -
Anti-pattern -- Don't use
nx.info(): Deprecated; useG.number_of_nodes(),G.number_of_edges(),nx.density(G)directly. -
Anti-pattern -- Don't assume node ordering: Algorithms may return results in different orders. Always index by node key, not position.
Common Recipes
Recipe: Minimum Spanning Tree
Extract the minimum spanning tree and compare to the original graph.
import networkx as nx
# Create weighted graph
G = nx.erdos_renyi_graph(50, 0.15, seed=42)
for u, v in G.edges():
G[u][v]["weight"] = round(nx.utils.py_random_state(42).random(), 2)
mst = nx.minimum_spanning_tree(G, weight="weight")
print(f"Original: {G.number_of_edges()} edges")
print(f"MST: {mst.number_of_edges()} edges")
total_weight = sum(d["weight"] for _, _, d in mst.edges(data=True))
print(f"MST total weight: {total_weight:.2f}")
Recipe: Graph Coloring and Cliques
Find cliques and compute graph coloring.
import networkx as nx
G = nx.karate_club_graph()
# Find all maximal cliques
cliques = list(nx.find_cliques(G))
print(f"Maximal cliques: {len(cliques)}")
largest_clique = max(cliques, key=len)
print(f"Largest clique size: {len(largest_clique)}, nodes: {largest_clique}")
# Greedy graph coloring
coloring = nx.greedy_color(G, strategy="largest_first")
n_colors = max(coloring.values()) + 1
print(f"Chromatic number (greedy upper bound): {n_colors}")
Recipe: DAG and Topological Sort
Build a directed acyclic graph and find execution order.
import networkx as nx
# Task dependency DAG
D = nx.DiGraph()
D.add_edges_from([
("download_data", "preprocess"),
("download_data", "validate"),
("preprocess", "analyze"),
("validate", "analyze"),
("analyze", "visualize"),
("analyze", "report"),
("visualize", "report"),
])
print(f"Is DAG: {nx.is_directed_acyclic_graph(D)}")
order = list(nx.topological_sort(D))
print(f"Execution order: {order}")
# Find all paths from start to end
paths = list(nx.all_simple_paths(D, "download_data", "report"))
print(f"Paths to report: {len(paths)}")
for p in paths:
print(f" {' -> '.join(p)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
NetworkXError: Graph is not connected |
Algorithm requires connected graph | Extract largest component: G.subgraph(max(nx.connected_components(G), key=len)).copy() |
PowerIterationFailedConvergence |
Eigenvector/PageRank did not converge | Increase max_iter (e.g., 1000) or check for disconnected components |
| Very slow centrality computation | O(n*m) complexity on large graphs | Use k parameter for sampling: betweenness_centrality(G, k=500) |
nx.NetworkXNotImplemented |
Algorithm not available for graph type | Convert graph type: G.to_undirected() or G.to_directed() |
| Memory error on large graphs | Dense adjacency matrix | Use to_scipy_sparse_array() instead of to_numpy_array() |
| Node IDs read as strings from file | read_edgelist defaults to str |
Pass nodetype=int: nx.read_edgelist(f, nodetype=int) |
| Community detection returns frozen sets | Normal return type for communities | Convert: [list(c) for c in communities] |
| Self-loops in generated graphs | Configuration model allows self-loops | Remove: G.remove_edges_from(nx.selfloop_edges(G)) |
| Visualization too cluttered | Too many nodes/edges | Filter to subgraph, adjust alpha, increase figure size, or use interactive tools (Plotly, PyVis) |
Bundled Resources
Migrated from original entry (STUB: 436-line main file + 2,014 lines across 5 reference files, main/total = 17.8%).
references/algorithms_generators.md
Covers: Detailed algorithm parameters for traversal (DFS/BFS), cycles, cliques, graph coloring, isomorphism, matching/covering, tree algorithms (MST variants). Full generator catalog: classic graphs, lattice/grid, tree, bipartite, degree sequence, graph operations (union, compose, complement, products). Relocated inline: Core algorithms (centrality, paths, connectivity, community, flow) -> Core API Modules 3-5. Core generators (ER, BA, WS, SBM) -> Module 8. Omitted: A* heuristic customization, Bellman-Ford negative weights -- consult official docs.
Original file disposition:
algorithms.md(383 lines): Top algorithms relocated to Core API Modules 3-5 + Recipes. Remaining (traversal, cliques, coloring, isomorphism, matching, cycles, trees) -> this reference.generators.md(378 lines): Core generators relocated to Module 8. Full catalog (classic, lattice, tree, bipartite, degree sequence, operators) -> this reference.
references/io_visualization.md
Covers: All I/O formats (adjacency list, GEXF, Pajek, LEDA, Cytoscape JSON, DOT/Graphviz, Matrix Market, CSV, database/SQL, compressed gzip). Format selection guide. Advanced visualization: Plotly interactive, PyVis HTML, Graphviz layouts, 3D networks, bipartite layout, community coloring, subgraph highlighting, multi-panel figures, edge labels, directed arrows.
Relocated inline: Core I/O (edge list, GraphML, JSON, pandas, NumPy/SciPy) -> Module 6. Basic matplotlib -> Module 7.
Omitted: write_gpickle/read_gpickle (deprecated), read_shp/write_shp (removed in NetworkX 3.0; use geopandas).
Original file disposition:
io.md(441 lines): Core formats relocated to Module 6. Remaining formats + format selection guide -> this reference.visualization.md(529 lines): Basic matplotlib relocated to Module 7. Advanced techniques (Plotly, PyVis, 3D, bipartite, community coloring) -> this reference.
Fully consolidated original file
graph-basics.md(283 lines): Fully consolidated into main SKILL.md. Graph types -> Key Concepts. Node/edge operations, attributes, subgraphs -> Core API Modules 1-2. Diagnostics -> Common Workflows. Memory/float-point considerations -> Best Practices + Troubleshooting. Omitted:nx.info()(deprecated).
Related Skills
- torch-geometric-graph-neural-networks -- graph neural networks (GCN, GAT, GraphSAGE) for node/graph classification and link prediction on graph-structured data
- matplotlib-scientific-plotting -- advanced figure customization beyond NetworkX's built-in
nx.draw - plotly-interactive-plots -- interactive network plots with hover, zoom, and pan
- pandas (planned) -- DataFrame operations for preparing edge/node data before graph construction
- scipy (planned) -- sparse matrix operations and numerical algorithms used by NetworkX internally
References
- NetworkX documentation -- official docs and API reference
- NetworkX tutorial -- official getting started guide
- NetworkX GitHub -- source code and issue tracker
- NetworkX gallery -- example gallery with visualizations
- Hagberg, A., Schult, D., & Swart, P. (2008). Exploring network structure, dynamics, and function using NetworkX. SciPy 2008.
skills/scientific-computing/neurokit2/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill neurokit2 -g -y
SKILL.md
Frontmatter
{
"name": "neurokit2",
"license": "MIT",
"description": "Python toolkit for neurophysiological signal processing: ECG (HR, HRV, R-peaks), EEG (complexity, PSD), EMG (activation onset), EDA\/GSR (SCR decomposition), PPG, and RSP. Includes synthetic signal simulation. Alternatives: BioSPPy (less maintained), MNE (EEG\/MEG specialist), heartpy (ECG only), scipy.signal (raw DSP)."
}
NeuroKit2
Overview
NeuroKit2 provides a unified, high-level API for physiological signal processing. Each modality (ECG, EEG, EMG, EDA, PPG, RSP) follows the same nk.{signal}_process() → nk.{signal}_analyze() workflow: raw signal in, cleaned signal + features out. The library handles detrending, filtering, peak detection, artifact correction, and feature extraction automatically, with parameters tuned to biosignal characteristics. Results are returned as pandas DataFrames, making downstream statistics straightforward. NeuroKit2 also provides nk.{signal}_simulate() functions for generating synthetic test signals.
When to Use
- Extracting HRV (heart rate variability) features from ECG recordings for stress or autonomic nervous system analysis
- Detecting R-peaks in ECG and computing RR intervals, SDNN, RMSSD, pNN50, LF/HF ratio
- Processing EDA/GSR signals to separate tonic (SCL) and phasic (SCR) components for psychophysiology research
- Cleaning and segmenting EMG signals for muscle onset/offset detection in biomechanics
- Processing PPG signals from wearable sensors as ECG surrogates for heart rate and SpO2
- Generating synthetic physiological signals for algorithm validation and unit tests
- Use MNE instead when working with multichannel EEG/MEG and source localization; use scipy.signal for raw low-level DSP
Prerequisites
- Python packages:
neurokit2,numpy,pandas,matplotlib - Data requirements: 1D NumPy array or pandas Series of the physiological signal; known sampling rate (Hz)
- Typical sampling rates: ECG 250–1000 Hz, EEG 256–2048 Hz, EDA 4–64 Hz, EMG 1000–2000 Hz
pip install neurokit2 numpy pandas matplotlib scipy
Quick Start
import neurokit2 as nk
import matplotlib.pyplot as plt
# Generate and process synthetic ECG (10 seconds at 500 Hz)
ecg_signal = nk.ecg_simulate(duration=10, sampling_rate=500, heart_rate=70)
signals, info = nk.ecg_process(ecg_signal, sampling_rate=500)
# Plot processed ECG
nk.ecg_plot(signals, info)
plt.savefig("ecg_processed.pdf", bbox_inches="tight")
print(f"R-peaks detected: {len(info['ECG_R_Peaks'])}")
print(signals[["ECG_Clean", "ECG_Rate", "ECG_Quality"]].describe())
Core API
Module 1: ECG Processing
Full pipeline from raw ECG to cleaned signal, R-peaks, and instantaneous heart rate.
import neurokit2 as nk
import numpy as np
# Load real data (example: CSV with one ECG column at 250 Hz)
# ecg_raw = pd.read_csv("ecg_recording.csv")["ecg"].values
# Or use synthetic:
ecg_raw = nk.ecg_simulate(duration=60, sampling_rate=250, heart_rate=72, noise=0.05)
# Full processing pipeline
signals, info = nk.ecg_process(ecg_raw, sampling_rate=250)
# signals: DataFrame with columns ECG_Raw, ECG_Clean, ECG_Rate, ECG_R_Peaks, ...
# info: dict with R-peak indices, P/Q/S/T wave locations
print(f"R-peaks: {len(info['ECG_R_Peaks'])} detected")
print(f"Mean heart rate: {signals['ECG_Rate'].mean():.1f} bpm")
print(f"ECG quality (0-1): {signals['ECG_Quality'].mean():.2f}")
# Get delineated waves (P, Q, S, T)
_, waves_dict = nk.ecg_delineate(signals["ECG_Clean"], info["ECG_R_Peaks"],
sampling_rate=250, method="dwt")
print(f"P-wave peaks found: {np.sum(~np.isnan(waves_dict['ECG_P_Peaks']))}")
# HRV analysis from ECG
hrv_time = nk.hrv_time(info["ECG_R_Peaks"], sampling_rate=250)
hrv_freq = nk.hrv_frequency(info["ECG_R_Peaks"], sampling_rate=250)
hrv_nonlinear = nk.hrv_nonlinear(info["ECG_R_Peaks"], sampling_rate=250)
print("Time-domain HRV:")
print(f" SDNN : {hrv_time['HRV_SDNN'].values[0]:.2f} ms")
print(f" RMSSD : {hrv_time['HRV_RMSSD'].values[0]:.2f} ms")
print(f" pNN50 : {hrv_time['HRV_pNN50'].values[0]:.2f}%")
print("\nFrequency-domain HRV:")
print(f" LF power : {hrv_freq['HRV_LF'].values[0]:.4f} ms²")
print(f" HF power : {hrv_freq['HRV_HF'].values[0]:.4f} ms²")
print(f" LF/HF : {hrv_freq['HRV_LFHF'].values[0]:.3f}")
Module 2: EDA / GSR Processing
Electrodermal activity (EDA) / galvanic skin response (GSR) decomposition into tonic and phasic components.
import neurokit2 as nk
# Simulate EDA signal (10 min at 4 Hz with 3 events)
eda_raw = nk.eda_simulate(duration=600, sampling_rate=4, scr_number=10, noise=0.01)
# Process: detrend, filter, decompose into SCL (tonic) + SCR (phasic)
signals, info = nk.eda_process(eda_raw, sampling_rate=4)
print(f"SCR peaks detected: {len(info['SCR_Peaks'])}")
print(f"SCR recovery times (mean): {signals['SCR_RecoveryTime'].mean():.2f} s")
print(f"SCL (tonic) mean: {signals['EDA_Tonic'].mean():.4f} μS")
print(f"SCR (phasic) amplitude mean: {signals['EDA_Phasic'].mean():.4f} μS")
# Analyze epochs around events
events = nk.events_create(event_onsets=[60, 120, 240], event_durations=3,
desired_length=len(eda_raw))
epoch_df = nk.epochs_create(signals, events, sampling_rate=4,
epochs_start=-5, epochs_end=10)
Module 3: EMG Processing
Muscle activation detection from surface EMG.
import neurokit2 as nk
# Simulate EMG with 3 activations at 1000 Hz
emg_raw = nk.emg_simulate(duration=10, sampling_rate=1000, burst_number=3)
# Process: rectify, envelope, detect activation periods
signals, info = nk.emg_process(emg_raw, sampling_rate=1000)
print(f"EMG activation onsets: {len(info['EMG_Onsets'])}")
print(f"EMG activation offsets: {len(info['EMG_Offsets'])}")
# Activation periods
for onset, offset in zip(info["EMG_Onsets"], info["EMG_Offsets"]):
duration_ms = (offset - onset) / 1000 * 1000 # samples → ms
print(f" Activation: {onset/1000:.2f}s – {offset/1000:.2f}s ({duration_ms:.0f} ms)")
# Plot
nk.emg_plot(signals)
import matplotlib.pyplot as plt
plt.savefig("emg_processed.pdf", bbox_inches="tight")
Module 4: EEG / Complexity Analysis
Signal complexity measures applicable to EEG and other biosignals.
import neurokit2 as nk
import numpy as np
# Generate EEG-like signal
eeg = nk.eeg_simulate(duration=30, sampling_rate=256, brain_information=0.7)
# Band power (delta, theta, alpha, beta, gamma)
bands = nk.eeg_power(eeg, sampling_rate=256,
frequency_band=["Delta", "Theta", "Alpha", "Beta", "Gamma"])
print("EEG Band Power:")
for band, power in bands.items():
print(f" {band}: {power:.4f}")
# Complexity measures (for any 1D biosignal)
signal = np.random.randn(1000) # test signal
entropy_sample = nk.entropy_sample(signal, order=2, r=0.2 * np.std(signal))
entropy_fuzzy = nk.entropy_fuzzy(signal, order=2)
dfa = nk.fractal_dfa(signal)
print(f"Sample entropy: {entropy_sample[0]:.4f}")
print(f"Fuzzy entropy: {entropy_fuzzy[0]:.4f}")
print(f"DFA exponent: {dfa[0]:.4f}")
Module 5: PPG Processing
Photoplethysmography (PPG) from wearable sensors.
import neurokit2 as nk
# Simulate PPG at 100 Hz (common wearable sampling rate)
ppg_raw = nk.ppg_simulate(duration=30, sampling_rate=100, heart_rate=65, noise=0.01)
# Process: clean, detect peaks
signals, info = nk.ppg_process(ppg_raw, sampling_rate=100)
print(f"PPG peaks detected: {len(info['PPG_Peaks'])}")
print(f"Mean heart rate: {signals['PPG_Rate'].mean():.1f} bpm")
# Compute HRV from PPG peaks (as ECG surrogate)
hrv = nk.hrv(info["PPG_Peaks"], sampling_rate=100)
print(f"RMSSD from PPG: {hrv['HRV_RMSSD'].values[0]:.2f} ms")
Module 6: Signal Simulation
Generate synthetic physiological signals for testing and algorithm validation.
import neurokit2 as nk
import matplotlib.pyplot as plt
fig, axes = plt.subplots(5, 1, figsize=(12, 10))
signals_dict = {
"ECG": nk.ecg_simulate(duration=5, sampling_rate=500, heart_rate=70),
"EDA": nk.eda_simulate(duration=5, sampling_rate=64, scr_number=2),
"EMG": nk.emg_simulate(duration=5, sampling_rate=1000, burst_number=2),
"PPG": nk.ppg_simulate(duration=5, sampling_rate=100, heart_rate=70),
"RSP": nk.rsp_simulate(duration=5, sampling_rate=100, respiratory_rate=15),
}
for ax, (name, signal) in zip(axes, signals_dict.items()):
sr = {"ECG": 500, "EDA": 64, "EMG": 1000, "PPG": 100, "RSP": 100}[name]
import numpy as np
t = np.arange(len(signal)) / sr
ax.plot(t, signal, lw=0.8)
ax.set_ylabel(name)
axes[-1].set_xlabel("Time (s)")
plt.tight_layout()
plt.savefig("physiological_signals.pdf", bbox_inches="tight")
print("Synthetic signals plotted")
Key Concepts
Event-Related Analysis (Epochs)
NeuroKit2 uses nk.events_create() to mark stimulus onsets, then nk.epochs_create() to segment the continuous signal into fixed-length windows around each event. This enables ERP (event-related potential) and SCR (skin conductance response) analysis locked to stimulus timing.
Signal Quality Index (SQI)
ECG and PPG processing includes a quality index (0–1) per sample. Quality below 0.5 indicates noisy or artifactual segments. Use signals["ECG_Quality"] > 0.5 to mask unreliable regions before computing HRV features.
Common Workflows
Workflow 1: Multi-Signal Physiological Recording Analysis
import neurokit2 as nk
import pandas as pd
import numpy as np
# Simulate 5-minute multi-modal recording
sr_ecg, sr_eda, sr_ppg = 250, 4, 100
duration = 300 # seconds
ecg = nk.ecg_simulate(duration=duration, sampling_rate=sr_ecg, heart_rate=72)
eda = nk.eda_simulate(duration=duration, sampling_rate=sr_eda, scr_number=15)
ppg = nk.ppg_simulate(duration=duration, sampling_rate=sr_ppg, heart_rate=72)
# Process each modality
ecg_signals, ecg_info = nk.ecg_process(ecg, sampling_rate=sr_ecg)
eda_signals, eda_info = nk.eda_process(eda, sampling_rate=sr_eda)
ppg_signals, ppg_info = nk.ppg_process(ppg, sampling_rate=sr_ppg)
# Extract HRV from ECG
hrv = nk.hrv(ecg_info["ECG_R_Peaks"], sampling_rate=sr_ecg, show=False)
# Summary table
summary = pd.Series({
"HR_mean_bpm": ecg_signals["ECG_Rate"].mean(),
"HRV_RMSSD_ms": hrv["HRV_RMSSD"].values[0],
"HRV_LF_HF": hrv["HRV_LFHF"].values[0],
"SCL_mean_uS": eda_signals["EDA_Tonic"].mean(),
"SCR_count": len(eda_info["SCR_Peaks"]),
"PPG_HR_mean_bpm": ppg_signals["PPG_Rate"].mean(),
})
print(summary.to_string())
Workflow 2: Stress Detection Feature Extraction
import neurokit2 as nk
import pandas as pd
import numpy as np
def extract_stress_features(ecg_array, eda_array, sr_ecg=250, sr_eda=4):
"""Extract HRV + EDA features for stress classification."""
# ECG features
ecg_signals, ecg_info = nk.ecg_process(ecg_array, sampling_rate=sr_ecg)
hrv_time = nk.hrv_time(ecg_info["ECG_R_Peaks"], sampling_rate=sr_ecg)
hrv_freq = nk.hrv_frequency(ecg_info["ECG_R_Peaks"], sampling_rate=sr_ecg)
# EDA features
eda_signals, eda_info = nk.eda_process(eda_array, sampling_rate=sr_eda)
return {
"HR_mean": ecg_signals["ECG_Rate"].mean(),
"RMSSD": hrv_time["HRV_RMSSD"].values[0],
"SDNN": hrv_time["HRV_SDNN"].values[0],
"LF_HF": hrv_freq["HRV_LFHF"].values[0],
"SCL_mean": eda_signals["EDA_Tonic"].mean(),
"SCR_count": len(eda_info["SCR_Peaks"]),
"SCR_amp_mean": eda_signals["EDA_Phasic"].mean(),
}
# Test with simulated data
ecg = nk.ecg_simulate(duration=120, sampling_rate=250, heart_rate=85) # elevated HR
eda = nk.eda_simulate(duration=120, sampling_rate=4, scr_number=8)
features = extract_stress_features(ecg, eda)
df = pd.DataFrame([features])
print(df.T.to_string(header=False))
Key Parameters
| Parameter | Module/Function | Default | Range / Options | Effect |
|---|---|---|---|---|
sampling_rate |
All *_process |
required | 64–2048 Hz (modality-dependent) | Must match actual recording rate for correct timing |
method |
ecg_peaks |
"neurokit" |
"neurokit", "pantompkins1985", "hamilton2002", "elgendi2010" |
R-peak detection algorithm; "neurokit" is most robust |
heart_rate |
ecg_simulate |
70 | 30–200 bpm | Simulated ECG heart rate |
noise |
ecg_simulate |
0.01 | 0–0.5 | Gaussian noise amplitude added to simulation |
scr_number |
eda_simulate |
1 | 0–50 | Number of SCR events in simulated EDA |
order |
entropy_sample |
2 | 1–5 | Embedding dimension for entropy calculation |
r |
entropy_sample |
0.2×SD | 0.1–0.5×SD | Tolerance for sample entropy |
epochs_start |
epochs_create |
-1 | negative float (s) | Seconds before event onset for epoch start |
epochs_end |
epochs_create |
3 | positive float (s) | Seconds after event onset for epoch end |
Common Recipes
Recipe: Batch Process Multiple ECG Files
import neurokit2 as nk
import pandas as pd
from pathlib import Path
import numpy as np
results = []
for ecg_file in Path("ecg_data").glob("*.csv"):
try:
ecg = pd.read_csv(ecg_file)["ecg"].values
_, info = nk.ecg_process(ecg, sampling_rate=250)
hrv = nk.hrv_time(info["ECG_R_Peaks"], sampling_rate=250)
results.append({
"file": ecg_file.stem,
"RMSSD": hrv["HRV_RMSSD"].values[0],
"SDNN": hrv["HRV_SDNN"].values[0],
"pNN50": hrv["HRV_pNN50"].values[0],
})
except Exception as e:
print(f"FAILED {ecg_file.name}: {e}")
df = pd.DataFrame(results)
df.to_csv("hrv_results.csv", index=False)
print(df.describe())
Recipe: Artifact Rejection by Quality Index
import neurokit2 as nk
import numpy as np
ecg = nk.ecg_simulate(duration=60, sampling_rate=250, noise=0.1)
signals, info = nk.ecg_process(ecg, sampling_rate=250)
# Identify low-quality R-peaks
r_peaks = info["ECG_R_Peaks"]
quality_at_peaks = signals["ECG_Quality"].iloc[r_peaks].values
good_peaks = r_peaks[quality_at_peaks > 0.5]
print(f"R-peaks before cleaning: {len(r_peaks)}")
print(f"High-quality R-peaks : {len(good_peaks)}")
# Recompute HRV on clean peaks only
hrv_clean = nk.hrv_time(good_peaks, sampling_rate=250)
print(f"RMSSD (cleaned): {hrv_clean['HRV_RMSSD'].values[0]:.2f} ms")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| R-peaks detected at wrong locations | sampling_rate incorrect |
Double-check recording metadata; wrong SR shifts all peak times |
| Extremely high HRV RMSSD (>200 ms) | Missed beats or extra detections | Switch detection method: try method="pantompkins1985"; inspect raw signal |
ecg_delineate returns all NaN for P/Q/S/T |
Recording too short (<5 seconds) or very noisy | Use ≥10 s recordings; reduce noise before calling delineate |
| EDA shows no SCR peaks in real data | Low-pass filter cutoff too aggressive or EDA sampled at wrong rate | Verify EDA sampling rate; set phasic_method="cvx" in eda_phasic() |
Entropy functions return NaN |
Signal too short or constant (zero variance) | Minimum 200–500 samples; ensure non-constant signal |
| EMG onset detection misses activations | Threshold auto-set too high for low-amplitude signals | Pass custom threshold: nk.emg_activation(signal, threshold=0.5) |
| Memory error on long recordings | DataFrame with millions of rows | Process in 60-second windows; concatenate HRV epoch results |
Related Skills
matplotlib-scientific-plotting— plotting processed neurokit2 signals and HRV Poincaré plotsstatsmodels-statistical-modeling— ANOVA and mixed-effects models on extracted HRV/EDA featuresscikit-learn-machine-learning— stress/emotion classification using extracted features
References
- NeuroKit2 documentation — API reference, tutorials, and comparison of algorithms
- NeuroKit2 paper: Makowski et al. (2021), Behavior Research Methods — methodology and validation
- HRV standards: Task Force (1996), Circulation — authoritative definitions of HRV time and frequency domain measures
- NeuroKit2 GitHub — source, examples, and community
- EDA review: Boucsein (2012), Electrodermal Activity — reference text for EDA signal interpretation
skills/scientific-computing/neuropixels-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill neuropixels-analysis -g -y
SKILL.md
Frontmatter
{
"name": "neuropixels-analysis",
"license": "MIT",
"description": "Pipeline for Neuropixels extracellular electrophysiology: probe geometry (ProbeInterface), Kilosort sorting via SpikeInterface, quality metrics, unit curation (ISI, firing rate, SNR), post-sort analysis (PSTH, tuning curves, population decoding). Supports Neuropixels 1.0\/2.0\/Ultra in rodent\/primate experiments."
}
Neuropixels Analysis
Overview
Neuropixels probes record extracellular voltage from 384 (NP 1.0) or 192 (NP 2.0) simultaneously recorded channels at 30 kHz. Analysis follows a canonical pipeline: raw data → spike sorting (Kilosort) → quality curation → unit analysis. SpikeInterface (Python) provides a unified API across 10+ spike sorters, handles data loading from multiple formats (SpikeGLX, OpenEphys, NWB), computes quality metrics, and exports sorted results. ProbeInterface manages probe geometry and channel maps. Post-sort analysis (PSTHs, firing rate, decoding) uses standard Python scientific stack.
When to Use
- Spike-sorting Neuropixels recordings from SpikeGLX (
.bin) or OpenEphys (.dat) to extract single-unit activity - Applying automatic quality control metrics (ISI violations, SNR, firing rate) to curate sorted units
- Computing peristimulus time histograms (PSTHs) locked to experimental events
- Analyzing population coding: decoding stimulus or behavioral variables from firing rates
- Converting sorted data to NWB (Neurodata Without Borders) format for sharing
- Comparing multiple spike sorters on the same dataset for method validation
- Visualizing unit waveforms, auto-correlograms, and spatial distribution across probe channels
- Use SpikeInterface instead for a unified framework that supports 10+ spike sorters with a common API and comparison tools
Prerequisites
- Python packages:
spikeinterface[full],probeinterface,numpy,pandas,matplotlib,scipy - Spike sorter: Kilosort4 (Python,
pip install kilosort); or MATLAB-based Kilosort2/3 (requires MATLAB license) - Data requirements: raw
.bin(SpikeGLX) or.dat(OpenEphys) recording; probe channel map file (.prbor from ProbeInterface) - Hardware: NVIDIA GPU (4+ GB VRAM) required for Kilosort; CPU fallback available but ~10× slower
pip install spikeinterface[full] probeinterface kilosort
# Optional: Phy for manual curation
pip install phy
Workflow
Step 1: Load Raw Recording
import spikeinterface.full as si
from pathlib import Path
# SpikeGLX recording (most common Neuropixels format)
data_dir = Path("/data/recording/session_001")
recording = si.read_spikeglx(data_dir, stream_name="imec0.ap")
print(f"Probe type: {recording.get_probe().name}")
print(f"Channels: {recording.get_num_channels()}")
print(f"Sampling rate: {recording.get_sampling_frequency()} Hz")
print(f"Duration: {recording.get_total_duration():.1f} s")
print(f"Total samples: {recording.get_total_samples()}")
Step 2: Apply Common Reference and Bandpass Filter
import spikeinterface.preprocessing as spre
# Common median reference (removes common noise across all channels)
recording_cmr = spre.common_reference(recording, reference="global", operator="median")
# Bandpass filter: 300–6000 Hz for spikes
recording_filt = spre.bandpass_filter(recording_cmr, freq_min=300, freq_max=6000)
# Remove bad channels automatically
recording_clean, removed_ids = spre.remove_bad_channels(recording_filt)
print(f"Removed {len(removed_ids)} bad channels: {removed_ids}")
print(f"Clean recording: {recording_clean.get_num_channels()} channels")
Step 3: Run Spike Sorting
import spikeinterface.sorters as ss
from pathlib import Path
output_dir = Path("./sorting_output")
# Kilosort4 (recommended for Neuropixels; requires GPU)
sorting = ss.run_sorter(
"kilosort4",
recording_clean,
output_folder=output_dir / "kilosort4",
remove_existing_folder=True,
verbose=True,
# Kilosort4 parameters
nblocks=5, # number of drift correction blocks
Th_learned=8, # threshold for learned templates
do_correction=True, # drift correction
)
print(f"Units found: {len(sorting.get_unit_ids())}")
print(f"Spike counts (first 5): {[len(sorting.get_unit_spike_train(u, segment_index=0)) for u in sorting.unit_ids[:5]]}")
Step 4: Compute Waveforms and Quality Metrics
import spikeinterface.full as si
import spikeinterface.qualitymetrics as sqm
# Extract waveforms (snippets around each spike)
waveforms = si.extract_waveforms(
recording_clean,
sorting,
folder="./waveforms",
ms_before=1.0, # ms before spike peak
ms_after=2.0, # ms after spike peak
max_spikes_per_unit=1000,
overwrite=True,
n_jobs=4,
)
print(f"Waveform shape per unit: {waveforms.get_waveforms(waveforms.unit_ids[0]).shape}")
# (n_spikes, n_samples, n_channels)
# Compute quality metrics
metrics = sqm.compute_quality_metrics(
waveforms,
metric_names=["snr", "isi_violation", "firing_rate", "presence_ratio",
"amplitude_cutoff", "nearest_neighbor"],
)
print(f"\nQuality metrics summary:")
print(metrics.describe())
Step 5: Curate Units
import pandas as pd
# Curation thresholds (Allen Brain Institute defaults)
thresholds = {
"snr": (5.0, None), # SNR ≥ 5
"isi_violations_ratio": (None, 0.1), # ISI violation ratio ≤ 10%
"firing_rate": (0.1, None), # firing rate ≥ 0.1 Hz
"presence_ratio": (0.9, None), # present ≥ 90% of recording
"amplitude_cutoff": (None, 0.1), # amplitude cutoff ≤ 10%
}
def apply_thresholds(metrics_df, thresholds):
mask = pd.Series(True, index=metrics_df.index)
for metric, (low, high) in thresholds.items():
if metric not in metrics_df.columns:
continue
if low is not None:
mask &= metrics_df[metric] >= low
if high is not None:
mask &= metrics_df[metric] <= high
return mask
good_units_mask = apply_thresholds(metrics, thresholds)
good_unit_ids = metrics[good_units_mask].index.tolist()
print(f"Total units: {len(metrics)}")
print(f"Good units: {len(good_unit_ids)} ({100*len(good_unit_ids)/len(metrics):.0f}%)")
# Filter sorting to good units only
sorting_curated = sorting.select_units(good_unit_ids)
Step 6: Compute PSTHs and Visualize
import numpy as np
import matplotlib.pyplot as plt
def compute_psth(spike_times, event_times, window=(-0.5, 1.0), bin_size=0.01, fs=30000):
"""Compute peri-stimulus time histogram for one unit."""
bins = np.arange(window[0], window[1] + bin_size, bin_size)
spike_times_s = spike_times / fs # samples → seconds
counts = np.zeros(len(bins) - 1)
for t_event in event_times:
rel_times = spike_times_s - t_event
in_window = rel_times[(rel_times >= window[0]) & (rel_times < window[1])]
counts += np.histogram(in_window, bins=bins)[0]
rate = counts / (len(event_times) * bin_size) # convert to Hz
return bins[:-1] + bin_size / 2, rate # bin centers, firing rate
# Example: visual stimulus events at 1.0, 2.5, 4.0, 5.5 s
fs = 30000
event_times_s = np.array([1.0, 2.5, 4.0, 5.5, 7.0, 8.5])
# Plot PSTH for first 4 good units
fig, axes = plt.subplots(2, 2, figsize=(10, 6))
for ax, unit_id in zip(axes.flat, good_unit_ids[:4]):
spikes = sorting_curated.get_unit_spike_train(unit_id, segment_index=0)
times, rate = compute_psth(spikes, event_times_s, fs=fs)
ax.bar(times, rate, width=0.01, color="steelblue", alpha=0.8)
ax.axvline(0, color="red", lw=1.5, linestyle="--", label="Stimulus")
ax.set_xlabel("Time from stimulus (s)")
ax.set_ylabel("Firing rate (Hz)")
ax.set_title(f"Unit {unit_id}")
plt.tight_layout()
plt.savefig("psth_grid.pdf", bbox_inches="tight")
print("Saved psth_grid.pdf")
Step 7: Export to NWB
import spikeinterface.exporters as sexp
# Export sorted data to Neurodata Without Borders format
nwb_path = "./recording_sorted.nwb"
sexp.export_to_nwb(
sorting_curated,
nwb_file_path=nwb_path,
overwrite=True,
)
print(f"Exported to NWB: {nwb_path}")
# Also export waveforms for Phy manual curation
phy_dir = "./phy_export"
sexp.export_to_phy(
waveforms,
output_folder=phy_dir,
compute_pc_features=True,
copy_binary=True,
)
print(f"Phy export ready at: {phy_dir}")
print("Launch with: phy template-gui phy_export/params.py")
Key Parameters
| Parameter | Module/Function | Default | Range / Options | Effect |
|---|---|---|---|---|
freq_min / freq_max |
bandpass_filter |
300/6000 Hz | 150–500 / 3000–10000 | Spike band; NP 1.0 standard is 300–6000 Hz |
nblocks |
Kilosort4 | 5 | 0–10 | Number of drift correction blocks; 0 disables drift correction |
Th_learned |
Kilosort4 | 8 | 6–12 | Detection threshold (× noise level); lower = more spikes, more noise |
ms_before / ms_after |
extract_waveforms |
1.0/2.0 | 0.5–2.0/1.0–3.0 ms | Waveform snippet window around spike peak |
max_spikes_per_unit |
extract_waveforms |
500 | 100–5000 | Maximum spikes per unit for waveform extraction |
snr threshold |
quality metrics | — | 5–10 | Signal-to-noise threshold for unit acceptance |
isi_violations_ratio |
quality metrics | — | 0.05–0.2 | Refractory period violation rate; <0.1 is well-isolated |
presence_ratio |
quality metrics | — | 0.9 | Fraction of recording where unit is active |
bin_size |
PSTH | 0.01 s | 0.001–0.05 s | PSTH temporal resolution; smaller = more detail, noisier |
Key Concepts
Spike Sorting Pipeline
Raw voltage → preprocessing (common reference, bandpass) → detection (threshold crossing or learned templates) → clustering (PCA + k-means or Gaussian mixture) → template matching → quality metrics → curation. Kilosort4 adds drift correction, which is critical for chronic NP recordings (tip drift of 5–50 µm over hours degrades unit isolation).
Quality Metrics
- SNR: peak waveform amplitude / noise level. SNR >5 indicates well-isolated unit
- ISI violation ratio: fraction of inter-spike intervals shorter than the refractory period (~1.5 ms). Ratio <0.1 indicates single-unit isolation
- Presence ratio: fraction of recording epochs where unit fired at least one spike. <0.9 suggests unit disappeared or was lost
- Amplitude cutoff: fraction of spikes missing due to detection threshold. <0.1 ensures near-complete detection
Common Recipes
Recipe: Compare Two Sorters on Same Recording
import spikeinterface.sorters as ss
import spikeinterface.comparison as sc
# Run two sorters
sorting_ks4 = ss.run_sorter("kilosort4", recording_clean, output_folder="./ks4")
sorting_sc = ss.run_sorter("spykingcircus2", recording_clean, output_folder="./sc2")
# Compare outputs
comparison = sc.compare_two_sorters(sorting_ks4, sorting_sc,
sorting1_name="Kilosort4",
sorting2_name="SpykingCircus2")
print(comparison.get_performance())
# Shows: agreement fraction, recall, precision per matched unit pair
Recipe: Population Firing Rate Heatmap
import numpy as np
import matplotlib.pyplot as plt
fs = 30000
duration_s = sorting_curated.get_total_duration()
bin_edges = np.arange(0, duration_s, 0.05) # 50 ms bins
# Build firing rate matrix: (n_units, n_bins)
fr_matrix = np.zeros((len(good_unit_ids), len(bin_edges)-1))
for i, uid in enumerate(good_unit_ids[:50]):
spikes_s = sorting_curated.get_unit_spike_train(uid, segment_index=0) / fs
counts, _ = np.histogram(spikes_s, bins=bin_edges)
fr_matrix[i] = counts / 0.05 # Hz
# Normalize each unit
fr_norm = (fr_matrix - fr_matrix.mean(1, keepdims=True)) / (fr_matrix.std(1, keepdims=True) + 1e-6)
fig, ax = plt.subplots(figsize=(12, 5))
im = ax.imshow(fr_norm, aspect="auto", cmap="RdBu_r",
extent=[0, duration_s, len(good_unit_ids[:50]), 0], vmin=-2, vmax=2)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Unit #")
ax.set_title("Population Activity Heatmap (z-scored FR)")
plt.colorbar(im, ax=ax, label="z-score")
plt.tight_layout()
plt.savefig("population_heatmap.pdf", bbox_inches="tight")
Expected Outputs
| Output | Format | Typical Content |
|---|---|---|
sorting/ |
SpikeInterface folder | Spike times per unit; template waveforms |
waveforms/ |
SpikeInterface folder | Waveform snippets (n_spikes, n_samples, n_channels) |
quality_metrics.csv |
CSV | SNR, ISI violations, firing rate, presence ratio per unit |
psth_grid.pdf |
PSTH plots for curated units | |
recording_sorted.nwb |
NWB/HDF5 | Portable spike-sorted data for sharing |
phy_export/ |
Phy format | .npy + params.py for manual curation GUI |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Kilosort GPU memory error | Recording too long or too many channels for VRAM | Process recording in time chunks using si.SubRecording; use NP 2.0 (192 ch) settings |
| Zero units found | Threshold too high or preprocessing removed all signal | Lower Th_learned to 6; verify recording_clean has non-zero channel data |
| Excessive ISI violations (>50%) | Multi-unit activity merged as single unit | Manual curation with Phy; increase Th_learned to be more conservative |
| Waveform extraction OOM | Too many spikes × channels × samples | Reduce max_spikes_per_unit; increase n_jobs for parallel extraction |
| Drift correction fails | Too few spikes or very short recording | Set nblocks=1 (minimal correction) or nblocks=0 (disable) |
| NWB export fails | Missing session metadata (subject, date) | Provide NWBFile metadata; or use sexp.export_to_nwb(..., metadata={...}) |
| SpikeGLX file not found | Wrong stream name | List streams: si.get_neo_streams("spikeglx", data_dir) |
References
- SpikeInterface documentation — full API, tutorials, and sorter comparison guides
- Kilosort4 paper: Pachitariu et al. (2024), Nature Methods — drift correction and template learning
- SpikeInterface paper: Buccino et al. (2020), eLife — unified framework for extracellular electrophysiology
- ProbeInterface documentation — probe geometry and channel map handling
- Neuropixels documentation — hardware specs, imec reference designs
- NWB documentation — neurophysiology data standard for archiving and sharing
skills/scientific-computing/nextflow-workflow-engine/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill nextflow-workflow-engine -g -y
SKILL.md
Frontmatter
{
"name": "nextflow-workflow-engine",
"license": "Apache-2.0",
"description": "Dataflow workflow engine for scalable bioinformatics pipelines. Defines processes (containerized tasks) connected by channels; runs local, HPC (SLURM\/SGE), cloud (AWS\/GCP\/Azure), or Kubernetes via a single config change. Powers nf-core. Use Snakemake for rule-based Python workflows; use Nextflow for containerized, cloud-native, and nf-core pipelines."
}
Nextflow — Scalable Scientific Workflow Engine
Overview
Nextflow implements a dataflow programming model where processes (containerized execution units) consume and emit data through channels (asynchronous queues). This design enables implicit parallelization — processes run as soon as their input channels have data, without manual dependency management. Nextflow handles process orchestration across local machines, HPC clusters (SLURM, SGE, PBS), and cloud platforms (AWS Batch, Google Cloud Life Sciences, Azure Batch) by swapping a single configuration profile. The nf-core community provides 100+ validated Nextflow pipelines (RNA-seq, WGS, ChIP-seq, scRNA-seq) following best practices with automated testing.
When to Use
- Building containerized bioinformatics pipelines that must run on HPC, AWS, and local environments without code changes
- Using nf-core community pipelines (nf-core/rnaseq, nf-core/sarek, nf-core/chipseq) out of the box
- Processing thousands of samples with implicit parallelization across a SLURM cluster
- Writing pipelines where each step runs inside a Docker or Singularity container for reproducibility
- Monitoring pipeline execution and resuming from checkpoints after failures with
-resume - Use Snakemake instead for Python-native rule-based workflows where Python integration is prioritized
- Use WDL/Cromwell instead for clinical genomics pipelines that require CWL/WDL standards compliance
Prerequisites
- Software: Java 11+, Nextflow (self-contained launcher)
- Containers: Docker or Singularity for process isolation (recommended)
- Optional: nf-core tools for community pipeline management
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v nextflowfirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run nextflowrather than barenextflow.
# Install Nextflow (self-contained JAR — no sudo required)
curl -s https://get.nextflow.io | bash
chmod +x nextflow
export PATH="$PWD:$PATH"
# Verify
nextflow -version
# Nextflow version 24.10.1
# Install nf-core tools (Python)
pip install nf-core
# Pull an nf-core pipeline
nextflow pull nf-core/rnaseq
Quick Start
// hello.nf — minimal Nextflow pipeline
nextflow.enable.dsl = 2
process GREET {
input: val name
output: stdout
script: "echo 'Hello, ${name}!'"
}
workflow {
Channel.of('World', 'Nextflow') | GREET | view
}
# Run the pipeline
nextflow run hello.nf
# Hello, World!
# Hello, Nextflow!
Core API
Module 1: Processes — Containerized Task Units
Define processes with inputs, outputs, and shell/script directives.
// process_example.nf
nextflow.enable.dsl = 2
process ALIGN_READS {
// Container for this process
container 'quay.io/biocontainers/star:2.7.11a--h0033a41_0'
// Resource directives
cpus 16
memory '32 GB'
// I/O declarations
input:
tuple val(sample_id), path(reads_r1), path(reads_r2)
path genome_index
output:
tuple val(sample_id), path("${sample_id}.Aligned.sortedByCoord.out.bam")
path "${sample_id}.Log.final.out", emit: log
// Shell command
script:
"""
STAR --runThreadN ${task.cpus} \\
--genomeDir ${genome_index} \\
--readFilesIn ${reads_r1} ${reads_r2} \\
--readFilesCommand zcat \\
--outSAMtype BAM SortedByCoordinate \\
--outFileNamePrefix ${sample_id}.
"""
}
Module 2: Channels — Data Queues Between Processes
Create and transform channels for flexible data routing.
nextflow.enable.dsl = 2
workflow {
// Value channel (broadcast)
genome_ch = Channel.value(file("GRCh38.fa"))
// List channel
samples_ch = Channel.of('ctrl_1', 'ctrl_2', 'treat_1', 'treat_2')
// File channel from glob pattern
reads_ch = Channel.fromFilePairs("data/*_{R1,R2}.fastq.gz")
// Emits: [sample_id, [R1_file, R2_file]]
// From a CSV sample sheet
sample_sheet = Channel.fromPath("samplesheet.csv")
.splitCsv(header: true)
.map { row -> tuple(row.sample, file(row.fastq_1), file(row.fastq_2)) }
// Channel operators
filtered = reads_ch
.filter { id, files -> id.startsWith("ctrl") }
.view { id, files -> "Processing: ${id}" }
}
Module 3: Workflow Block — Pipeline DAG Definition
Connect processes with channels to define the pipeline DAG.
nextflow.enable.dsl = 2
include { FASTP } from './modules/fastp'
include { STAR_ALIGN } from './modules/star'
include { FEATURECOUNTS } from './modules/featurecounts'
workflow RNA_SEQ {
take:
reads_ch // tuple: [sample_id, [R1, R2]]
genome_idx // path: STAR index directory
gtf // path: annotation GTF file
main:
// Trim reads
FASTP(reads_ch)
// Align trimmed reads
STAR_ALIGN(FASTP.out.reads, genome_idx)
// Count reads (join on sample_id)
FEATURECOUNTS(STAR_ALIGN.out.bam, gtf)
emit:
counts = FEATURECOUNTS.out.counts
logs = STAR_ALIGN.out.log.mix(FASTP.out.log)
}
workflow {
reads = Channel.fromFilePairs("data/*_{R1,R2}.fastq.gz")
genome_idx = Channel.value(file("genome/star_index"))
gtf = Channel.value(file("genome/annotation.gtf"))
RNA_SEQ(reads, genome_idx, gtf)
RNA_SEQ.out.counts | view
}
Module 4: Configuration — Profiles for Different Environments
Configure execution profiles for local, HPC, and cloud environments.
// nextflow.config — profile-based configuration
profiles {
local {
process.executor = 'local'
process.cpus = 4
process.memory = '8 GB'
docker.enabled = true
}
slurm {
process.executor = 'slurm'
process.queue = 'batch'
process.clusterOptions = '--account=myproject'
singularity.enabled = true
singularity.autoMounts = true
// Per-process resource configuration
process {
withName: STAR_ALIGN {
cpus = 16
memory = '32 GB'
time = '4h'
}
withName: FASTP {
cpus = 8
memory = '8 GB'
}
}
}
aws {
process.executor = 'awsbatch'
process.queue = 'arn:aws:batch:us-east-1:123456789:job-queue/my-queue'
aws.region = 'us-east-1'
aws.batch.cliPath = '/home/ec2-user/miniconda/bin/aws'
docker.enabled = true
}
}
// Global params
params {
outdir = 'results'
genome = 'GRCh38'
max_cpus = 16
max_memory = '128 GB'
max_time = '72h'
}
Module 5: Operators — Channel Transformations
Transform channels using built-in operators.
nextflow.enable.dsl = 2
workflow {
// Map: transform each element
Channel.fromPath("data/*.fastq.gz")
.map { file -> tuple(file.baseName.replaceAll(/_R[12]/, ''), file) }
.groupTuple() // group by sample_id → [sample_id, [R1, R2]]
.view { id, files -> "Sample: ${id} (${files.size()} files)" }
// Filter by condition
Channel.of(1, 2, 3, 4, 5)
.filter { it > 3 }
.view() // emits: 4, 5
// Combine channels
samples = Channel.of('A', 'B', 'C')
refs = Channel.value(file("genome.fa"))
samples.combine(refs).view() // [A, genome.fa], [B, genome.fa], [C, genome.fa]
// Collect all outputs into a list
Channel.of(1, 2, 3).collect().view() // [[1, 2, 3]]
}
Module 6: Error Handling and Resuming
Handle failures, retry strategies, and pipeline resuming.
// nextflow.config — retry and error handling
process {
// Retry failed processes up to 3 times with increasing memory
errorStrategy = { task.exitStatus in [137, 140] ? 'retry' : 'finish' }
maxRetries = 3
memory = { 8.GB * task.attempt } // 8GB → 16GB → 24GB on retries
// Ignore errors for optional steps
withName: OPTIONAL_STEP {
errorStrategy = 'ignore'
}
}
# Resume a pipeline from the last successful checkpoint
nextflow run pipeline.nf -resume
# View process execution statistics
nextflow log amazing_fermi # use run name from .nextflow.log
# Inspect work directories
ls work/ab/cdef1234*/
Key Parameters
| Parameter | Default | Range/Options | Effect |
|---|---|---|---|
-resume |
off | flag | Resume from last successful checkpoint using cached results |
-profile |
— | local, slurm, aws, custom |
Select execution profile from nextflow.config |
-params-file |
— | JSON/YAML path | Load pipeline parameters from a file |
-w / --work-dir |
./work |
directory path | Work directory for intermediate files |
process.cpus |
1 |
integer | Default CPUs per process; override with withName |
process.memory |
1 GB |
memory string | Default memory per process |
process.executor |
local |
local, slurm, sge, awsbatch, k8s |
Job scheduler or cloud executor |
process.errorStrategy |
terminate |
retry, ignore, finish |
How to handle process failures |
process.maxRetries |
0 |
integer | Maximum automatic retries before failure |
docker.enabled |
false |
boolean | Enable Docker container runtime |
Common Workflows
Workflow 1: Complete RNA-seq Pipeline (nf-core/rnaseq)
# Run nf-core/rnaseq with a samplesheet
# samplesheet.csv: sample,fastq_1,fastq_2,strandedness
cat > samplesheet.csv << 'EOF'
sample,fastq_1,fastq_2,strandedness
ctrl_1,data/ctrl_1_R1.fastq.gz,data/ctrl_1_R2.fastq.gz,auto
ctrl_2,data/ctrl_2_R1.fastq.gz,data/ctrl_2_R2.fastq.gz,auto
treat_1,data/treat_1_R1.fastq.gz,data/treat_1_R2.fastq.gz,auto
EOF
# Run nf-core/rnaseq (downloads pipeline and containers automatically)
nextflow run nf-core/rnaseq \
-profile docker \
--input samplesheet.csv \
--genome GRCh38 \
--outdir results/ \
-resume
echo "Results in: results/star_salmon/ results/multiqc/"
Workflow 2: Custom Modular Pipeline with Sub-workflows
// main.nf — modular pipeline structure
nextflow.enable.dsl = 2
include { QC_TRIM } from './subworkflows/qc_trim'
include { ALIGN_COUNT } from './subworkflows/align_count'
include { MULTIQC } from './modules/multiqc'
workflow {
// Input: sample sheet CSV
ch_samples = Channel
.fromPath(params.samplesheet)
.splitCsv(header: true)
.map { row -> tuple(row.sample, file(row.fastq_1), file(row.fastq_2)) }
// QC and trimming
QC_TRIM(ch_samples)
// Alignment and counting
ALIGN_COUNT(
QC_TRIM.out.reads,
file(params.genome_index),
file(params.gtf)
)
// Aggregate QC
all_logs = QC_TRIM.out.logs.mix(ALIGN_COUNT.out.logs).collect()
MULTIQC(all_logs)
// Publish count matrix
ALIGN_COUNT.out.counts.view { id, file ->
"Counts: ${id} → ${file}"
}
}
Common Recipes
Recipe 1: Monitor Running Pipeline and View Logs
# View current pipeline run status
nextflow log
# Detailed log for a specific run
nextflow log amazing_fermi -f name,status,exit,duration,realtime,rss
# Watch pipeline progress in real-time
tail -f .nextflow.log
# Generate HTML execution report and timeline
nextflow run pipeline.nf -with-report report.html -with-timeline timeline.html
open report.html
Recipe 2: Run nf-core Pipeline with Custom Config
# Create custom config for institutional HPC
cat > custom.config << 'EOF'
process {
executor = 'slurm'
queue = 'normal'
clusterOptions = '--account=myproject'
withName: STAR_ALIGN {
cpus = 16
memory = '32 GB'
time = '4h'
}
}
singularity {
enabled = true
autoMounts = true
cacheDir = '/scratch/singularity_cache'
}
EOF
nextflow run nf-core/rnaseq \
-profile singularity \
-c custom.config \
--input samplesheet.csv \
--genome GRCh38 \
--outdir results/ \
-resume
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Cannot find a matching process |
DSL2 module not included | Add include { PROCESS_NAME } from './modules/...' |
| Work directory fills disk | Accumulated intermediate files | Run nextflow clean -f to remove old work directories |
| Container pull fails | Registry authentication or network issue | Pre-pull: docker pull quay.io/biocontainers/tool:version; use --pull never |
resume doesn't skip completed steps |
Process inputs changed or cache invalidated | Check if input files or params changed; use nextflow log to inspect cache |
| SLURM job pending indefinitely | Insufficient queue resources | Check with squeue; reduce memory and cpus in config |
OutOfMemoryError in Java |
Nextflow JVM heap too small | export NXF_JVM_ARGS="-Xms1g -Xmx8g" |
| Process output not found | Wrong output path in output: block |
Check work directory: ls work/xx/yyyyyy*/; verify script creates expected files |
| nf-core pipeline fails checksum | Stale cached pipeline | nextflow pull nf-core/rnaseq -r latest to update |
References
- Nextflow documentation — official language reference and executor guides
- Nextflow GitHub: nextflow-io/nextflow — source code and releases
- Di Tommaso P et al. (2017) "Nextflow enables reproducible computational workflows" — Nature Biotechnology 35:316-319. DOI:10.1038/nbt.3820
- nf-core — community curated Nextflow pipelines with automated testing and containers
skills/scientific-computing/polars-dataframes/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill polars-dataframes -g -y
SKILL.md
Frontmatter
{
"name": "polars-dataframes",
"license": "MIT",
"description": "Fast in-memory DataFrame with lazy evaluation, parallel execution, Arrow backend. Use for tabular data in RAM (1–100 GB) when pandas is too slow. Expression API: select, filter, group_by, joins, pivots, window. Lazy mode enables predicate\/projection pushdown. Reads CSV, Parquet, JSON, Excel, DBs, cloud. Larger-than-RAM: Dask; GPU: cuDF."
}
Polars DataFrames
Overview
Polars is a high-performance DataFrame library for Python built on Apache Arrow with a Rust backend. It provides an expression-based API with lazy evaluation and automatic parallelization for efficient data processing, transformation, and analysis.
When to Use
- Processing tabular datasets from 100 MB to 100 GB that fit in RAM
- ETL pipelines requiring fast read/transform/write cycles
- Replacing pandas when performance matters (10–100x speedup typical)
- Lazy query pipelines with automatic optimization (predicate/projection pushdown)
- Joining, pivoting, and reshaping large tables
- Reading Parquet, CSV, JSON, or cloud-stored data efficiently
- Window functions and complex grouped aggregations
- For larger-than-RAM data, use Dask or Vaex instead
- For GPU-accelerated DataFrames, use cuDF instead
Prerequisites
pip install polars
# Optional extras:
pip install polars[all] # All I/O backends
pip install polars[pandas] # Pandas interop
pip install polars[numpy] # NumPy interop
pip install connectorx sqlalchemy # Database connectivity
Quick Start
import polars as pl
# Create DataFrame
df = pl.DataFrame({
"name": ["Alice", "Bob", "Charlie", "Diana"],
"dept": ["Sales", "Eng", "Sales", "Eng"],
"salary": [70000, 85000, 72000, 90000],
})
# Expression-based pipeline
result = (
df.filter(pl.col("salary") > 71000)
.with_columns(bonus=pl.col("salary") * 0.1)
.group_by("dept")
.agg(
pl.col("salary").mean().alias("avg_salary"),
pl.len().alias("count"),
)
)
print(result)
# shape: (2, 3)
# ┌───────┬────────────┬───────┐
# │ dept ┆ avg_salary ┆ count │
# ├───────┼────────────┼───────┤
# │ Eng ┆ 87500.0 ┆ 2 │
# │ Sales ┆ 72000.0 ┆ 1 │
# └───────┴────────────┴───────┘
Core API
1. DataFrame Operations
Select, filter, add/modify columns, sort, and sample rows.
import polars as pl
df = pl.DataFrame({
"id": [1, 2, 3, 4, 5],
"name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"age": [25, 30, 35, 28, 32],
"score": [88.5, 92.0, 76.3, 95.1, 84.7],
})
# Select columns (with computed expressions)
selected = df.select(
"name",
pl.col("age"),
(pl.col("score") / 100).alias("score_pct"),
)
print(selected.shape) # (5, 3)
# Filter rows (multiple conditions → implicit AND)
filtered = df.filter(
pl.col("age") > 27,
pl.col("score") > 80,
)
print(filtered.shape) # (3, 4) — Bob, Diana, Eve
# Add columns (preserves existing)
enriched = df.with_columns(
grade=pl.when(pl.col("score") >= 90).then(pl.lit("A"))
.when(pl.col("score") >= 80).then(pl.lit("B"))
.otherwise(pl.lit("C")),
age_months=pl.col("age") * 12,
)
print(enriched.columns)
# ['id', 'name', 'age', 'score', 'grade', 'age_months']
# Sort
df.sort("score", descending=True).head(3)
2. GroupBy & Aggregations
Group rows and compute summary statistics.
import polars as pl
sales = pl.DataFrame({
"region": ["East", "West", "East", "West", "East", "West"],
"product": ["A", "A", "B", "B", "A", "B"],
"revenue": [100, 150, 200, 180, 120, 210],
"units": [10, 15, 20, 18, 12, 21],
})
# Basic group_by
summary = sales.group_by("region").agg(
pl.col("revenue").sum().alias("total_rev"),
pl.col("revenue").mean().alias("avg_rev"),
pl.len().alias("n_transactions"),
)
print(summary)
# Multiple keys + conditional aggregation
by_rp = sales.group_by("region", "product").agg(
pl.col("revenue").sum(),
(pl.col("units") > 15).sum().alias("large_orders"),
)
print(by_rp)
# Window functions with over() — add group stats without collapsing rows
enriched = sales.with_columns(
region_avg=pl.col("revenue").mean().over("region"),
rank_in_region=pl.col("revenue").rank(descending=True).over("region"),
pct_of_region=pl.col("revenue") / pl.col("revenue").sum().over("region"),
)
print(enriched.select("region", "product", "revenue", "region_avg", "rank_in_region"))
3. Joins
Combine DataFrames on shared keys.
import polars as pl
customers = pl.DataFrame({
"cid": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Charlie", "Diana"],
})
orders = pl.DataFrame({
"oid": [101, 102, 103, 104],
"cid": [1, 2, 1, 5],
"amount": [100, 200, 150, 300],
})
# Inner join — only matching rows
inner = customers.join(orders, on="cid", how="inner")
print(inner.shape) # (3, 4) — cid 1 (×2), cid 2
# Left join — all left rows, nulls where no match
left = customers.join(orders, on="cid", how="left")
print(left.shape) # (4, 4) — Charlie and Diana have null amount
# Anti join — left rows WITHOUT a match in right
no_orders = customers.join(orders, on="cid", how="anti")
print(no_orders["name"].to_list()) # ['Charlie', 'Diana']
# Join on different column names
customers.join(orders, left_on="cid", right_on="cid", suffix="_order")
# Asof join — match to nearest timestamp (time-series alignment)
quotes = pl.DataFrame({
"time": [1.0, 2.0, 3.0, 4.0],
"price": [100, 101, 102, 103],
}).cast({"time": pl.Float64})
trades = pl.DataFrame({
"time": [1.5, 3.2],
"qty": [50, 75],
}).cast({"time": pl.Float64})
result = trades.join_asof(quotes, on="time", strategy="backward")
print(result)
# time=1.5 matched price=100, time=3.2 matched price=102
4. Reshaping
Pivot, unpivot, explode, and transpose operations.
import polars as pl
# --- Pivot (long → wide) ---
long = pl.DataFrame({
"date": ["Jan", "Jan", "Feb", "Feb"],
"product": ["A", "B", "A", "B"],
"sales": [100, 150, 120, 160],
})
wide = long.pivot(values="sales", index="date", columns="product")
print(wide)
# date | A | B
# Jan | 100 | 150
# Feb | 120 | 160
# --- Unpivot (wide → long) ---
back_to_long = wide.unpivot(
index="date", on=["A", "B"],
variable_name="product", value_name="sales",
)
print(back_to_long.shape) # (4, 3)
# --- Explode list columns ---
nested = pl.DataFrame({
"id": [1, 2],
"tags": [["a", "b", "c"], ["d", "e"]],
})
flat = nested.explode("tags")
print(flat.shape) # (5, 2)
5. Data I/O
Read and write CSV, Parquet, JSON, Excel, databases, and cloud storage.
import polars as pl
# --- CSV ---
df = pl.read_csv("data.csv")
df.write_csv("output.csv")
# --- Parquet (recommended for performance) ---
df = pl.read_parquet("data.parquet")
df.write_parquet("output.parquet", compression="zstd")
# --- JSON / NDJSON ---
df = pl.read_ndjson("data.ndjson")
df.write_ndjson("output.ndjson")
# --- Excel ---
df = pl.read_excel("data.xlsx", sheet_name="Sheet1")
df.write_excel("output.xlsx")
# --- Lazy scan (preferred for large files) ---
lf = pl.scan_csv("large.csv")
result = lf.filter(pl.col("value") > 0).select("id", "value").collect()
print(result.shape)
# --- Database ---
df = pl.read_database_uri(
"SELECT * FROM users WHERE age > 25",
uri="postgresql://user:pass@localhost/db",
)
# --- Cloud storage (S3, GCS, Azure) ---
df = pl.read_parquet("s3://bucket/data.parquet")
df = pl.scan_parquet("gs://bucket/data/*.parquet").collect()
# --- Partitioned Parquet (Hive-style) ---
df.write_parquet("output_dir", partition_by=["year", "month"])
lf = pl.scan_parquet("output_dir/**/*.parquet")
6. Expression API
String, datetime, list, and conditional operations.
import polars as pl
from datetime import date
df = pl.DataFrame({
"text": ["Hello World", "foo bar", "POLARS"],
"dt": [date(2023, 1, 15), date(2023, 6, 30), date(2024, 12, 1)],
"values": [[1, 2, 3], [4, 5], [6]],
})
# String operations
strings = df.select(
lower=pl.col("text").str.to_lowercase(),
length=pl.col("text").str.len_chars(),
contains_o=pl.col("text").str.contains("o"),
split=pl.col("text").str.split(" "),
)
print(strings)
# Datetime operations
dates = df.select(
year=pl.col("dt").dt.year(),
month=pl.col("dt").dt.month(),
weekday=pl.col("dt").dt.weekday(),
quarter=pl.col("dt").dt.quarter(),
)
print(dates)
# List operations
lists = df.select(
list_len=pl.col("values").list.len(),
list_sum=pl.col("values").list.sum(),
first=pl.col("values").list.first(),
)
print(lists)
# Conditional expressions (when/then/otherwise)
df = pl.DataFrame({"score": [45, 72, 88, 95, 60]})
result = df.with_columns(
grade=pl.when(pl.col("score") >= 90).then(pl.lit("A"))
.when(pl.col("score") >= 80).then(pl.lit("B"))
.when(pl.col("score") >= 70).then(pl.lit("C"))
.otherwise(pl.lit("F")),
)
print(result)
# Null handling
df2 = pl.DataFrame({"x": [1, None, 3, None, 5]})
filled = df2.with_columns(
filled=pl.col("x").fill_null(0),
forward=pl.col("x").fill_null(strategy="forward"),
is_null=pl.col("x").is_null(),
)
print(filled)
# Multi-column operations with regex selector
df3 = pl.DataFrame({"val_a": [1, 2], "val_b": [3, 4], "name": ["x", "y"]})
doubled = df3.select(pl.col("^val_.*$") * 2)
print(doubled)
7. Lazy Evaluation
Build optimized query plans before execution.
import polars as pl
# Lazy mode: build plan, optimize, then execute
lf = pl.scan_csv("large_dataset.csv")
result = (
lf
.select("user_id", "category", "amount", "date") # projection pushdown
.filter(pl.col("amount") > 100) # predicate pushdown
.with_columns(pl.col("date").str.to_date())
.group_by("category")
.agg(
pl.col("amount").sum().alias("total"),
pl.col("user_id").n_unique().alias("unique_users"),
)
.sort("total", descending=True)
)
# Inspect the optimized plan
print(result.explain())
# Execute
df = result.collect()
print(df)
# Streaming mode for very large data
lf = pl.scan_parquet("data/*.parquet")
result = (
lf
.filter(pl.col("year") >= 2023)
.group_by("region")
.agg(pl.col("sales").sum())
.collect(streaming=True) # processes in batches
)
print(result)
# Sink directly to file (no full materialization)
lf.filter(pl.col("active")).sink_parquet("filtered_output.parquet")
Key Concepts
Lazy vs Eager Comparison
| Aspect | Eager (DataFrame) |
Lazy (LazyFrame) |
|---|---|---|
| Created by | pl.read_*(), pl.DataFrame() |
pl.scan_*(), df.lazy() |
| Execution | Immediate | On .collect() |
| Optimization | None | Predicate/projection pushdown, join reordering |
| Streaming | No | collect(streaming=True) |
| Best for | Small data, interactive | Large data, pipelines |
Polars Data Types
| Type | Python equivalent | Notes |
|---|---|---|
Int8/16/32/64 |
int |
Choose smallest sufficient size |
UInt8/16/32/64 |
int |
Unsigned |
Float32/64 |
float |
Float64 default |
Boolean |
bool |
|
Utf8 |
str |
String type |
Categorical |
— | Low-cardinality strings (faster groupby) |
Date |
datetime.date |
Date without time |
Datetime |
datetime.datetime |
With microsecond precision |
Duration |
datetime.timedelta |
Time difference |
List |
list |
Variable-length lists |
Struct |
dict |
Named fields |
Null |
None |
All-null column |
Key Differences from Pandas
- No index: Row access by position only; no
.loc/.ilocwith labels - Strict typing: No silent type coercion; explicit
.cast()required - Expressions, not methods:
pl.col("x").mean()instead ofdf["x"].mean() - Parallel by default: All column operations run in parallel
- Lazy evaluation: Available via
LazyFramefor query optimization
Common Workflows
1. ETL Pipeline (CSV → Clean → Parquet)
import polars as pl
# Extract
lf = pl.scan_csv(
"raw_data.csv",
dtypes={"id": pl.Int64, "date": pl.Utf8, "amount": pl.Float64},
)
# Transform
cleaned = (
lf
.with_columns(pl.col("date").str.to_date("%Y-%m-%d"))
.filter(pl.col("amount").is_not_null())
.with_columns(
year=pl.col("date").dt.year(),
month=pl.col("date").dt.month(),
amount_log=pl.col("amount").log(),
)
.drop_nulls()
)
# Load
cleaned.collect().write_parquet("clean_data.parquet", compression="zstd")
print("ETL complete")
2. Multi-Source Join and Aggregation
import polars as pl
# Simulate three data sources
users = pl.DataFrame({
"uid": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Charlie", "Diana"],
"region": ["East", "West", "East", "West"],
})
orders = pl.DataFrame({
"oid": range(1, 7),
"uid": [1, 1, 2, 3, 3, 3],
"amount": [100, 200, 150, 50, 75, 125],
})
products = pl.DataFrame({
"oid": range(1, 7),
"category": ["Elec", "Books", "Elec", "Books", "Elec", "Elec"],
})
# Join → aggregate
result = (
orders
.join(users, on="uid", how="left")
.join(products, on="oid", how="left")
.group_by("region", "category")
.agg(
pl.col("amount").sum().alias("total"),
pl.col("amount").mean().alias("avg_order"),
pl.len().alias("n_orders"),
)
.sort("total", descending=True)
)
print(result)
3. Time-Series Feature Engineering
Uses: GroupBy, Window functions, Joins, Expression API.
- Load time-series data with
pl.scan_csv()orpl.scan_parquet() - Parse dates:
.with_columns(pl.col("date").str.to_date()) - Sort by entity and date:
.sort("entity_id", "date") - Add lag features:
pl.col("value").shift(n).over("entity_id") - Add rolling statistics:
pl.col("value").rolling_mean(window_size=7).over("entity_id") - Compute percent change:
(pl.col("value") - pl.col("value").shift(1)) / pl.col("value").shift(1) - Collect and write:
.collect().write_parquet("features.parquet")
Key Parameters
| Parameter | Function | Default | Range/Options | Effect |
|---|---|---|---|---|
how |
.join() |
"inner" |
inner, left, outer, cross, semi, anti | Join type |
strategy |
.join_asof() |
"backward" |
backward, forward, nearest | Asof match direction |
streaming |
.collect() |
False |
True/False | Process in batches for large data |
compression |
.write_parquet() |
"zstd" |
snappy, gzip, brotli, lz4, zstd, uncompressed | Parquet compression |
partition_by |
.write_parquet() |
None | List of columns | Hive-style partitioning |
rechunk |
pl.concat() |
False |
True/False | Rechunk memory after concat |
aggregate_function |
.pivot() |
"first" |
first, sum, mean, max, min, count | Duplicate handling in pivot |
n_rows |
pl.read_csv() |
None | Positive int | Limit rows read (for sampling) |
parallel |
pl.read_csv() |
"auto" |
auto, columns, row_groups, none | Parallel reading strategy |
dtypes |
pl.read_csv() |
None | Dict of column→type | Override type inference |
Best Practices
-
Use lazy mode for large datasets:
pl.scan_csv()notpl.read_csv(). Enables query optimization and streaming. -
Stay in the expression API: Avoid
.map_elements()(runs Python, no parallelism). Prefer native Polars operations — string, datetime, list namespaces cover most needs. -
Select early, filter early: Place
.select()and.filter()as early as possible in lazy pipelines. The optimizer can push these down but explicit placement helps. -
Use Categorical for low-cardinality strings:
df.with_columns(pl.col("region").cast(pl.Categorical))— dramatically speeds up groupby and joins on repeated string values. -
Prefer Parquet over CSV: Parquet preserves types, supports predicate pushdown, and is 5–10x smaller. Use
compression="zstd"for best compression/speed balance. -
Anti-pattern — Python loops over rows: Never iterate rows with
for row in df.iter_rows()for computation. Use expressions instead. -
Anti-pattern — chaining
.with_columns()calls: Combine multiple column additions into a single.with_columns()call for parallel execution.
Common Recipes
Recipe: Pandas Migration Pattern
import polars as pl
import pandas as pd
# Convert pandas → polars
pd_df = pd.DataFrame({"col": [1, 2, 3], "group": ["a", "b", "a"]})
pl_df = pl.from_pandas(pd_df)
# Key operation mapping:
# pandas: df["col"] → polars: df.select("col")
# pandas: df[df["col"] > 1] → polars: df.filter(pl.col("col") > 1)
# pandas: df.assign(x=...) → polars: df.with_columns(x=...)
# pandas: df.groupby().agg() → polars: df.group_by().agg()
# pandas: df.groupby().transform → polars: pl.col(...).over(...)
# pandas: df.merge() → polars: df.join()
# pandas: df.melt() → polars: df.unpivot()
# Convert back
pd_result = pl_df.to_pandas()
Recipe: Complex Aggregation Report
import polars as pl
df = pl.DataFrame({
"dept": ["Sales", "Eng", "Sales", "Eng", "Sales", "Eng"],
"level": ["Jr", "Sr", "Sr", "Jr", "Jr", "Sr"],
"salary": [50000, 95000, 75000, 70000, 55000, 100000],
})
report = (
df.group_by("dept", "level")
.agg(
pl.col("salary").mean().alias("avg_sal"),
pl.col("salary").median().alias("med_sal"),
pl.col("salary").std().alias("std_sal"),
pl.len().alias("count"),
)
.pivot(values="avg_sal", index="dept", columns="level")
.with_columns(
diff=pl.col("Sr") - pl.col("Jr"),
)
)
print(report)
Recipe: Reading Multiple Files with Schema Alignment
import polars as pl
from pathlib import Path
# Read multiple CSVs with potentially different columns
files = sorted(Path("data/").glob("*.csv"))
dfs = [pl.read_csv(f) for f in files]
# Diagonal concat handles mismatched schemas (fills nulls)
combined = pl.concat(dfs, how="diagonal")
print(f"Combined: {combined.shape}")
print(f"Columns: {combined.columns}")
# Or use lazy scan for Parquet (automatic parallel)
lf = pl.scan_parquet("data/**/*.parquet")
result = lf.filter(pl.col("date") > "2023-01-01").collect()
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
SchemaError: column not found |
Column name typo or case mismatch | Check df.columns; Polars is case-sensitive |
ComputeError: cannot cast |
Type mismatch in operation | Use .cast(pl.Type) explicitly |
OutOfMemoryError on collect |
Data too large for eager mode | Use lf.collect(streaming=True) or filter first |
Slow .map_elements() |
Python UDF prevents parallelism | Rewrite using native expressions (str/dt/list namespaces) |
| Join produces more rows than expected | Duplicate keys in right DataFrame | Deduplicate first: df.unique(subset=["key"]) |
InvalidOperationError: join on different types |
Key columns have different dtypes | Cast both to same type: .cast(pl.Int64) |
.over() returns wrong values |
Forgetting to include all group columns | Include all grouping columns in .over("col1", "col2") |
| Parquet file unreadable | Written with incompatible compression | Specify compression="snappy" for maximum compatibility |
| CSV dates read as strings | No automatic date parsing in CSV reader | Parse after reading: pl.col("date").str.to_date("%Y-%m-%d") |
concat fails with different schemas |
Columns don't match across DataFrames | Use how="diagonal" to fill missing columns with null |
Bundled Resources
-
references/pandas_migration.md— Pandas-to-Polars migration guide with operation mapping tables (selection, filtering, column ops, aggregation, window functions, joins, reshaping, string ops, datetime ops, missing data, I/O), interoperability code, common migration patterns with side-by-side code, migration pitfalls, and migration checklist.- Covers: all operation mapping content from original pandas_migration.md
- Relocated inline: key pandas differences summary → SKILL.md Key Concepts "Key Differences from Pandas" section; basic conversion recipe → SKILL.md Common Recipes "Pandas Migration Pattern"
- Omitted: anti-pattern code examples for row iteration and sequential pipe — covered in io_best_practices.md
-
references/advanced_operations.md— Rolling windows (time-based and row-based), cumulative operations (cum_sum/max/min/prod), shift/lag/lead with grouped contexts, struct operations (create/access/unnest), list column manipulation (stats, eval, filter, explode), unique/duplicate detection, advanced sorting (nulls_last, expression-based, top-N per group), column renaming (dict, suffix/prefix/programmatic), sampling (fixed n, fraction, bootstrap), transpose, and advanced reshaping patterns (wide-long-wide, nested JSON to flat, multi-level unpivot, horizontal concat).- Covers: advanced operations from original operations.md + transformations from original transformations.md not in main SKILL.md
- Relocated inline: basic selection/filtering → SKILL.md Core API section 1; groupby/aggregation → section 2; basic joins/asof → section 3; basic pivot/unpivot/explode/concat → section 4; string/date/list/conditional basics → section 6; basic window functions → section 2
- Omitted: join performance tips (simple; covered in SKILL.md Best Practices); concatenation options (rechunk covered in Key Parameters table)
-
references/io_best_practices.md— Full I/O format guide (CSV options, Parquet options with partitioning, JSON/NDJSON, Excel multi-sheet, Arrow IPC), database connectivity (PostgreSQL, MySQL, SQLite, BigQuery), cloud storage (S3, Azure, GCS), in-memory format conversions (dict, NumPy, pandas, Arrow), format selection decision guide, schema management and error handling, expression composition and reuse patterns, column selection patterns (by type, regex, exclude), memory management (estimated_size, type optimization, streaming), pipeline functions for composable transforms, testing/debugging (query plans, schema validation, profiling), performance anti-patterns (sequential pipe, many DataFrames, in-place mutation, unspecified types), and version compatibility notes.- Covers: all I/O content from original io_guide.md + expression/memory/testing/performance content from original best_practices.md + format selection and version notes from original core_concepts.md
- Relocated inline: basic CSV/Parquet/JSON/Excel/database read/write → SKILL.md Core API section 5; lazy vs eager comparison → SKILL.md Key Concepts table; basic expression context/syntax → SKILL.md section 6; parallelization/type system concepts → SKILL.md Key Concepts + Best Practices; null handling → SKILL.md section 6; categorical recommendation → SKILL.md Best Practices item 4
- Omitted: detailed expression fundamentals (what are expressions, expression contexts) — fully covered in SKILL.md Core API; basic conditional logic examples — covered in SKILL.md section 6; basic aggregation patterns — covered in SKILL.md section 2
Per-Reference-File Disposition (Original 6 files)
| Original File | Lines | Disposition | Target |
|---|---|---|---|
operations.md |
603 | Consolidated | Advanced ops → references/advanced_operations.md; basic selection/filter/groupby/window/string/date → SKILL.md Core API sections 1-2, 6 |
transformations.md |
550 | Consolidated | Reshaping/transpose → references/advanced_operations.md; basic joins/pivot/unpivot/explode/concat → SKILL.md Core API sections 3-4 |
io_guide.md |
558 | Consolidated | Full I/O detail → references/io_best_practices.md; basic read/write → SKILL.md Core API section 5 |
best_practices.md |
650 | Consolidated | Expression reuse, memory, testing, anti-patterns → references/io_best_practices.md; core best practices → SKILL.md Best Practices |
core_concepts.md |
379 | Consolidated | Format selection, version notes → references/io_best_practices.md; data types, lazy/eager, parallelism → SKILL.md Key Concepts |
pandas_migration.md |
418 | Migrated | → references/pandas_migration.md (expanded with window/string/datetime/missing data tables) |
Intentional Omissions
- Row iteration examples (operations.md): Not documented as a positive capability; only referenced as anti-pattern in Best Practices
- Expression fundamentals tutorial (core_concepts.md): Expression syntax, contexts, and expansion are fully covered by SKILL.md Core API sections; a separate tutorial would duplicate
- Detailed parallelization internals (core_concepts.md): "What gets parallelized" list omitted — users only need the Best Practices guidance to stay in the expression API
- Copy-on-write comparison (core_concepts.md): Pandas 2.0+ copy-on-write details omitted — migration-focused, not Polars-centric
Related Skills
- zarr-python — Chunked array storage; Polars can read/write Parquet that Zarr processes
- matplotlib-scientific-plotting — Visualization; convert to pandas with
.to_pandas()for plotting - scikit-learn-machine-learning — ML pipelines; use
.to_numpy()or.to_pandas()for sklearn input
References
- Polars User Guide: https://docs.pola.rs/
- Polars API Reference: https://docs.pola.rs/api/python/stable/reference/
- GitHub: https://github.com/pola-rs/polars
- Polars Cookbook: https://docs.pola.rs/user-guide/
skills/scientific-computing/pyhealth/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pyhealth -g -y
SKILL.md
Frontmatter
{
"name": "pyhealth",
"license": "BSD-3-Clause",
"description": "Python library for healthcare ML on EHR data: process MIMIC-III\/IV, eICU, OMOP-CDM; encode medical codes (ICD, ATC, NDC); build patient-level datasets; train Transformer, RETAIN, GRASP, MedBERT for mortality, drug recommendation, readmission, diagnosis prediction. Alternatives: FIDDLE (preprocessing), clinical-longformer (clinical NLP), ehr-ml (embeddings)."
}
PyHealth
Overview
PyHealth provides an end-to-end pipeline for healthcare ML on EHR data: data loading → medical code processing → patient-level dataset construction → model training → evaluation. It natively supports MIMIC-III, MIMIC-IV, eICU-CRD, and OMOP-CDM structured databases, and handles the idiosyncratic data formats of each. Medical codes (ICD-9, ICD-10, ATC, NDC, SNOMED) are organized in a hierarchical code system that supports code-level embedding and cross-ontology mapping. Pre-built tasks — mortality prediction, drug recommendation, readmission, length-of-stay, diagnosis code prediction — can be instantiated in a few lines. Custom tasks follow a standardized interface.
When to Use
- Training clinical outcome prediction models (mortality, readmission, LOS) from MIMIC-III or MIMIC-IV
- Building drug recommendation or drug interaction prediction models using ATC code hierarchy
- Processing OMOP-CDM formatted data from institutional EHR systems for ML
- Using pretrained clinical models (RETAIN, GRASP, MedBERT) as baselines on healthcare benchmarks
- Constructing patient visit sequences with temporal structure for RNN/Transformer models
- Evaluating clinical prediction models with appropriate metrics (AUROC, AUPRC, F1, Jaccard)
- Use FIDDLE for pure EHR preprocessing without ML; use clinical-longformer for clinical note NLP
Prerequisites
- Python packages:
pyhealth,torch,pandas,scikit-learn - Data requirements: MIMIC-III/IV CSV files (requires PhysioNet credentialing), eICU, or OMOP-CDM database
- MIMIC access: request at physionet.org (free; requires CITI training, ~1 week)
pip install pyhealth torch pandas scikit-learn
# Download MIMIC-III: https://physionet.org/content/mimiciii/
# Download MIMIC-IV: https://physionet.org/content/mimiciv/
Quick Start
from pyhealth.datasets import MIMIC3Dataset
# Load MIMIC-III (specify path to downloaded CSV files)
dataset = MIMIC3Dataset(
root="path/to/mimic-iii/",
tables=["DIAGNOSES_ICD", "PRESCRIPTIONS", "PROCEDURES_ICD"],
code_mapping={"ICD9CM": "CCSCM"}, # map ICD-9 codes to CCS multi-level
dev=True, # dev=True uses 1% of data for fast testing
)
print(f"Patients: {dataset.stat()['num_patients']}")
print(f"Visits: {dataset.stat()['num_visits']}")
Core API
Module 1: Dataset Loading
Load MIMIC-III, MIMIC-IV, eICU, and OMOP-CDM datasets.
from pyhealth.datasets import MIMIC3Dataset, MIMIC4Dataset, eICUDataset
# MIMIC-III
mimic3 = MIMIC3Dataset(
root="data/mimic-iii/",
tables=["DIAGNOSES_ICD", "PRESCRIPTIONS", "PROCEDURES_ICD", "LABEVENTS"],
code_mapping={"ICD9CM": "CCSCM", "NDC": "ATC3"}, # standardize codes
dev=False,
)
stats = mimic3.stat()
print(f"MIMIC-III: {stats['num_patients']} patients, {stats['num_visits']} visits")
# MIMIC-IV
mimic4 = MIMIC4Dataset(
root="data/mimic-iv/",
tables=["diagnoses_icd", "prescriptions", "procedures_icd"],
code_mapping={"ICD10CM": "CCSCM"},
dev=True,
)
# eICU
eicu = eICUDataset(
root="data/eicu/",
tables=["diagnosis", "medication", "treatment"],
dev=True,
)
print(f"eICU loaded: {eicu.stat()}")
# Explore dataset structure
patient_id = list(mimic3.patients.keys())[0]
patient = mimic3.patients[patient_id]
print(f"Patient {patient_id}: {len(patient.visits)} visits")
for visit in patient.visits[:2]:
print(f" Visit {visit.visit_id}:")
print(f" Diagnoses: {visit.get_code_list('CCSCM')[:5]}")
print(f" Medications: {visit.get_code_list('ATC3')[:3]}")
Module 2: Task Construction
Convert raw datasets into ML-ready task datasets.
from pyhealth.tasks import mortality_prediction_mimic3_fn
from pyhealth.datasets import SampleDataset
# Mortality prediction task
# Each sample: patient's visit history → binary mortality label
mortality_dataset = SampleDataset(
dataset=mimic3,
task_fn=mortality_prediction_mimic3_fn,
)
print(f"Task: mortality prediction")
print(f"Samples: {len(mortality_dataset)}")
# Inspect a sample
sample = mortality_dataset[0]
print(f"Sample keys: {list(sample.keys())}")
print(f"Conditions (ICD codes): {sample['conditions'][:3]}")
print(f"Drugs (ATC codes): {sample['drugs'][:3]}")
print(f"Label (mortality): {sample['label']}")
# Custom task: 30-day readmission prediction
def readmission_30day_fn(patient):
"""Custom task function: predict 30-day readmission after discharge."""
samples = []
for i, visit in enumerate(patient.visits[:-1]):
next_visit = patient.visits[i + 1]
# Compute days between discharge and next admission
days_gap = (next_visit.encounter_time - visit.discharge_time).days
label = int(days_gap <= 30)
samples.append({
"visit_id": visit.visit_id,
"patient_id": patient.patient_id,
"conditions": visit.get_code_list("CCSCM"),
"drugs": visit.get_code_list("ATC3"),
"procedures": visit.get_code_list("ICD9PROC"),
"label": label,
})
return samples
readmission_dataset = SampleDataset(dataset=mimic3, task_fn=readmission_30day_fn)
print(f"Readmission samples: {len(readmission_dataset)}")
pos_rate = sum(s["label"] for s in readmission_dataset) / len(readmission_dataset)
print(f"Positive rate (30-day readmission): {pos_rate:.2%}")
Module 3: Medical Code Systems
Work with ICD, ATC, NDC, and other hierarchical medical code systems.
from pyhealth.medcode import InnerMap
# ICD-9-CM diagnosis codes
icd9 = InnerMap.load("ICD9CM")
code = "428.0" # Heart failure, unspecified
print(f"Code: {code}")
print(f"Description: {icd9.lookup(code)}")
print(f"Ancestors: {icd9.get_ancestors(code)}")
print(f"Children: {icd9.get_children(code)[:5]}")
# ATC drug classification
atc = InnerMap.load("ATC")
drug_code = "A10BA02" # Metformin
print(f"\nATC: {drug_code}")
print(f"Drug: {atc.lookup(drug_code)}")
print(f"L1 class: {atc.get_ancestors(drug_code)}")
# Cross-ontology code mapping
from pyhealth.medcode import CrossMap
# Map NDC (drug product codes) to ATC level 3
ndc_to_atc = CrossMap.load("NDC", "ATC3")
ndc_code = "0069-2587-30" # example NDC
atc3_codes = ndc_to_atc.map(ndc_code)
print(f"NDC {ndc_code} → ATC3: {atc3_codes}")
# Map ICD-9 to ICD-10
icd9_to_icd10 = CrossMap.load("ICD9CM", "ICD10CM")
icd10 = icd9_to_icd10.map("428.0")
print(f"ICD-9 428.0 → ICD-10: {icd10}")
Module 4: Model Training
Train pre-implemented clinical ML models.
from pyhealth.models import Transformer, RETAIN
from pyhealth.datasets import split_by_patient, get_dataloader
import torch
# Train/val/test split (patient-level, no leakage)
train_ds, val_ds, test_ds = split_by_patient(mortality_dataset, [0.7, 0.1, 0.2])
train_loader = get_dataloader(train_ds, batch_size=32, shuffle=True)
val_loader = get_dataloader(val_ds, batch_size=64, shuffle=False)
test_loader = get_dataloader(test_ds, batch_size=64, shuffle=False)
print(f"Train: {len(train_ds)} | Val: {len(val_ds)} | Test: {len(test_ds)}")
# Transformer model for EHR sequence modeling
model = Transformer(
dataset=mortality_dataset,
feature_keys=["conditions", "drugs", "procedures"],
label_key="label",
mode="binary", # binary classification
embedding_dim=128,
num_heads=4,
num_layers=2,
dropout=0.1,
)
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
# RETAIN: Reverse Time Attention model (interpretable clinical ML)
retain_model = RETAIN(
dataset=mortality_dataset,
feature_keys=["conditions", "drugs"],
label_key="label",
mode="binary",
embedding_dim=64,
)
Module 5: Training and Evaluation
from pyhealth.trainer import Trainer
trainer = Trainer(
model=model,
metrics=["pr_auc", "roc_auc", "f1"], # PR-AUC, ROC-AUC, F1
)
trainer.train(
train_dataloader=train_loader,
val_dataloader=val_loader,
epochs=20,
optimizer_params={"lr": 1e-3},
weight_decay=1e-5,
monitor="pr_auc", # early stopping metric
monitor_criterion="max",
load_best_model_after_train=True,
)
# Evaluate on test set
results = trainer.evaluate(test_loader)
print("Test results:")
for metric, value in results.items():
print(f" {metric}: {value:.4f}")
Module 6: Drug Recommendation
Predict which drugs a patient should receive based on visit history.
from pyhealth.tasks import drug_recommendation_mimic3_fn
from pyhealth.models import GAMENet
from pyhealth.datasets import SampleDataset, split_by_patient, get_dataloader
# Drug recommendation task
drug_dataset = SampleDataset(
dataset=mimic3,
task_fn=drug_recommendation_mimic3_fn,
)
train_ds, val_ds, test_ds = split_by_patient(drug_dataset, [0.7, 0.1, 0.2])
# GAMENet: graph-augmented memory network for drug recommendation
gamenet = GAMENet(
dataset=drug_dataset,
feature_keys=["conditions", "procedures"],
label_key="drugs",
mode="multilabel", # recommend multiple drugs per visit
embedding_dim=64,
)
train_loader = get_dataloader(train_ds, batch_size=16, shuffle=True)
trainer = Trainer(model=gamenet, metrics=["jaccard", "f1", "prauc"])
trainer.train(train_dataloader=train_loader,
val_dataloader=get_dataloader(val_ds, 32),
epochs=30, monitor="jaccard")
print("Drug recommendation model trained")
Key Concepts
Patient-Visit-Event Hierarchy
PyHealth organizes EHR data as Patient → Visit → medical codes. Each Visit contains timestamped events across multiple tables (diagnoses, medications, procedures, labs). ML models see each patient as a sequence of visits, each visit as a set of medical codes, capturing temporal disease progression.
Code Mapping and Standardization
Raw EHR codes (ICD-9, NDC) are highly specific and numerous. PyHealth's code_mapping parameter automatically converts them to coarser ontologies (CCSCM has ~260 categories vs. ~15,000 ICD-9 codes), reducing vocabulary size and enabling transfer between datasets.
Common Workflows
Workflow 1: Full Mortality Prediction Pipeline
from pyhealth.datasets import MIMIC3Dataset, SampleDataset, split_by_patient, get_dataloader
from pyhealth.tasks import mortality_prediction_mimic3_fn
from pyhealth.models import Transformer
from pyhealth.trainer import Trainer
# 1. Load data
dataset = MIMIC3Dataset(
root="data/mimic-iii/",
tables=["DIAGNOSES_ICD", "PRESCRIPTIONS", "PROCEDURES_ICD"],
code_mapping={"ICD9CM": "CCSCM", "NDC": "ATC3"},
dev=True,
)
# 2. Build task dataset
task_ds = SampleDataset(dataset, task_fn=mortality_prediction_mimic3_fn)
print(f"Samples: {len(task_ds)}, Positive rate: {sum(s['label'] for s in task_ds)/len(task_ds):.2%}")
# 3. Split
train_ds, val_ds, test_ds = split_by_patient(task_ds, [0.7, 0.1, 0.2])
# 4. Model
model = Transformer(
dataset=task_ds,
feature_keys=["conditions", "drugs"],
label_key="label",
mode="binary",
embedding_dim=128,
)
# 5. Train
trainer = Trainer(model=model, metrics=["pr_auc", "roc_auc"])
trainer.train(
train_dataloader=get_dataloader(train_ds, 32, shuffle=True),
val_dataloader=get_dataloader(val_ds, 64),
epochs=15,
monitor="pr_auc",
)
# 6. Evaluate
results = trainer.evaluate(get_dataloader(test_ds, 64))
print(f"Test PR-AUC: {results['pr_auc']:.4f}")
print(f"Test ROC-AUC: {results['roc_auc']:.4f}")
Workflow 2: Model Comparison Benchmark
from pyhealth.models import Transformer, RETAIN, MedBERT
from pyhealth.trainer import Trainer
from pyhealth.datasets import get_dataloader
import pandas as pd
models = {
"Transformer": Transformer(task_ds, ["conditions", "drugs"], "label", "binary"),
"RETAIN": RETAIN(task_ds, ["conditions", "drugs"], "label", "binary"),
}
results_list = []
for name, model in models.items():
trainer = Trainer(model=model, metrics=["pr_auc", "roc_auc", "f1"])
trainer.train(
train_dataloader=get_dataloader(train_ds, 32, shuffle=True),
val_dataloader=get_dataloader(val_ds, 64),
epochs=10,
monitor="pr_auc",
)
test_results = trainer.evaluate(get_dataloader(test_ds, 64))
test_results["model"] = name
results_list.append(test_results)
print(f"{name}: PR-AUC={test_results['pr_auc']:.4f}, ROC-AUC={test_results['roc_auc']:.4f}")
results_df = pd.DataFrame(results_list).set_index("model")
results_df.to_csv("model_comparison.csv")
print(results_df)
Key Parameters
| Parameter | Module/Class | Default | Range / Options | Effect |
|---|---|---|---|---|
tables |
MIMIC3Dataset |
— | list of MIMIC table names | Which EHR tables to load; more tables = richer features, slower load |
code_mapping |
MIMIC3Dataset |
{} |
{"ICD9CM": "CCSCM"} |
Maps raw codes to standardized ontologies |
dev |
MIMIC3Dataset |
False |
True/False |
True uses 1% of data for fast development |
feature_keys |
All models | — | list of code type strings | Which medical code types to use as model input |
embedding_dim |
Transformer, RETAIN | 128 | 64–512 | Hidden size for code and patient embeddings |
num_heads |
Transformer |
4 | 1–16 | Multi-head attention heads (must divide embedding_dim) |
num_layers |
Transformer |
2 | 1–6 | Number of Transformer encoder layers |
dropout |
All models | 0.1 | 0–0.5 | Dropout rate for regularization |
mode |
All models | — | "binary", "multiclass", "multilabel" |
Prediction task type |
monitor |
Trainer.train |
"loss" |
"pr_auc", "roc_auc", "f1" |
Metric for early stopping and model selection |
Best Practices
-
Always use
split_by_patient, never random split: Splitting by visit (random) causes data leakage — the same patient can appear in train and test sets across different visits. PyHealth'ssplit_by_patientensures strict patient-level separation. -
Use
dev=Trueduring development: MIMIC-III contains ~46,000 patients. Loading the full dataset takes minutes; dev mode loads ~460 patients in seconds. Switch todev=Falseonly for final training runs. -
Apply code mapping to standardize across datasets: Raw ICD-9 codes have ~15,000 unique values; CCSCM reduces this to ~260 clinically meaningful groups. This dramatically reduces model vocabulary and enables meaningful code embeddings, especially important for small datasets.
-
Evaluate with PR-AUC alongside ROC-AUC: Clinical datasets are typically highly imbalanced (e.g., 10% mortality rate). PR-AUC is more informative than ROC-AUC for imbalanced tasks — a model that predicts all negatives achieves ROC-AUC ~0.5 but PR-AUC approaching the positive rate (~0.1).
Common Recipes
Recipe: Export Patient Embeddings
import torch
from pyhealth.datasets import get_dataloader
import numpy as np
model.eval()
embeddings = []
labels = []
with torch.no_grad():
for batch in get_dataloader(test_ds, batch_size=64):
# Get patient-level representation (before classification head)
hidden = model.get_patient_representation(batch)
embeddings.append(hidden.cpu().numpy())
labels.append(batch["label"].numpy())
embeddings = np.concatenate(embeddings, axis=0)
labels = np.concatenate(labels, axis=0)
np.save("patient_embeddings.npy", embeddings)
np.save("patient_labels.npy", labels)
print(f"Embeddings: {embeddings.shape}")
Recipe: Class-Imbalance Handling with Weighted Sampler
import torch
from torch.utils.data import WeightedRandomSampler
from pyhealth.datasets import get_dataloader
# Compute class weights for imbalanced mortality prediction
labels = [sample["label"] for sample in train_ds]
pos = sum(labels)
neg = len(labels) - pos
weights = [1.0/neg if l == 0 else 1.0/pos for l in labels]
sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)
# Use sampler in dataloader
balanced_loader = torch.utils.data.DataLoader(
train_ds, batch_size=32, sampler=sampler,
collate_fn=train_ds.collate_fn
)
print(f"Balanced loader: {len(balanced_loader)} batches")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
FileNotFoundError on dataset load |
Wrong root path or missing MIMIC CSV files |
Verify root contains the correct CSV files; list contents with ls data/mimic-iii/*.csv |
KeyError for code type in feature_keys |
Code type not loaded or mapping failed | Ensure tables includes the required table and code_mapping maps to the expected ontology |
All samples have label=0 in task dataset |
Task function logic error or data issue | Print task_fn(patient) on a single patient; check label derivation logic |
| Memory error loading full MIMIC | Large dataset; insufficient RAM | Use dev=True during development; use chunked loading or reduce tables list |
| ROC-AUC ~0.5 on test set | Severe class imbalance causing trivial predictor | Use WeightedRandomSampler; report PR-AUC instead; lower classification threshold |
| CUDA out of memory during training | Batch size too large | Reduce batch_size from 32 to 8 or 16; use gradient accumulation |
| Code mapping returns empty list | Code not found in cross-map | Check code format (e.g., ICD-9 with decimal vs. without); try InnerMap.load("ICD9CM").lookup(code) |
Related Skills
statsmodels-statistical-modeling— logistic regression baselines for clinical outcome predictionscikit-learn-machine-learning— traditional ML baselines (random forest, gradient boosting) on PyHealth featuresclinical-decision-support-documents— translating clinical ML model outputs to decision support tools
References
- PyHealth documentation — API reference, tutorials, and task descriptions
- PyHealth paper: Zhao et al. (2021), arXiv — library design and benchmark tasks
- MIMIC-III paper: Johnson et al. (2016), Nature Scientific Data — dataset description
- MIMIC-IV paper: Johnson et al. (2023), Nature Scientific Data — updated dataset
- RETAIN paper: Choi et al. (2016), NeurIPS — interpretable clinical prediction model
- PyHealth GitHub — source code and examples
skills/scientific-computing/pymoo/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pymoo -g -y
SKILL.md
Frontmatter
{
"name": "pymoo",
"license": "Apache-2.0",
"description": "Python framework for single- and multi-objective optimization with evolutionary algorithms. Define vectorized objectives and constraints; solve with NSGA-II, NSGA-III, MOEA\/D, GAs, or differential evolution. Analyze Pareto fronts, visualize trade-offs, customize operators and callbacks. For engineering design, hyperparameter search, and conflicting objectives. Alternatives: scipy.optimize (single-objective, gradient), platypus, jMetalPy (Java)."
}
pymoo
Overview
pymoo provides a unified API for multi-objective optimization via population-based evolutionary algorithms. Users define a problem by subclassing Problem or ElementwiseProblem, specifying objectives (n_obj), decision variables (n_var), and optional constraints (n_ieq_constr). Algorithms like NSGA-II and NSGA-III return a Result object containing the Pareto-optimal population, objective values, and decision variable values. pymoo separates problem definition, algorithm configuration, operator selection, and analysis — each component is independently replaceable.
When to Use
- Optimizing a design with two or more conflicting objectives (e.g., minimizing cost while maximizing performance)
- Running evolutionary algorithms (GA, DE, PSO) as black-box optimizers when gradients are unavailable
- Performing multi-objective hyperparameter search for ML models where accuracy and inference time trade off
- Computing Pareto fronts for portfolio optimization or multi-criteria decision analysis
- Customizing crossover/mutation operators for domain-specific solution encodings (binary, permutation, real-valued)
- Benchmarking optimization algorithms on standard test problems (ZDT, DTLZ, CTP)
- Use
scipy.optimizeinstead for single-objective, gradient-available, smooth optimization
Prerequisites
- Python packages:
pymoo,numpy,matplotlib - Data requirements: objective function(s) and optional constraint functions; variable bounds
- Environment: CPU sufficient for most problems; GPU not used by pymoo core
pip install pymoo numpy matplotlib
Quick Start
import numpy as np
from pymoo.core.problem import Problem
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.optimize import minimize
class SimpleBiObjective(Problem):
def __init__(self):
super().__init__(n_var=2, n_obj=2, xl=np.array([-2, -2]), xu=np.array([2, 2]))
def _evaluate(self, X, out, *args, **kwargs):
f1 = X[:, 0] ** 2 + X[:, 1] ** 2
f2 = (X[:, 0] - 1) ** 2 + X[:, 1] ** 2
out["F"] = np.column_stack([f1, f2])
algorithm = NSGA2(pop_size=100)
res = minimize(SimpleBiObjective(), algorithm, ("n_gen", 200), seed=1, verbose=False)
print(f"Pareto front size: {len(res.F)}")
print(f"Objective range: F1=[{res.F[:,0].min():.3f}, {res.F[:,0].max():.3f}]")
Core API
Module 1: Problem Definition
Define optimization problems via subclassing. Use Problem for vectorized evaluation (faster), ElementwiseProblem for scalar evaluation (simpler to write).
import numpy as np
from pymoo.core.problem import Problem, ElementwiseProblem
# Vectorized problem (preferred for performance)
class ZDT1(Problem):
"""ZDT1 benchmark: 30 variables, 2 objectives, known Pareto front."""
def __init__(self):
super().__init__(n_var=30, n_obj=2, xl=0.0, xu=1.0)
def _evaluate(self, X, out, *args, **kwargs):
f1 = X[:, 0]
g = 1 + 9 * X[:, 1:].mean(axis=1)
f2 = g * (1 - np.sqrt(f1 / g))
out["F"] = np.column_stack([f1, f2])
# Elementwise problem with inequality constraints
class ConstrainedProblem(ElementwiseProblem):
def __init__(self):
super().__init__(n_var=2, n_obj=1, n_ieq_constr=2,
xl=np.array([-5, -5]), xu=np.array([5, 5]))
def _evaluate(self, x, out, *args, **kwargs):
out["F"] = (x[0] - 1) ** 2 + (x[1] - 2) ** 2 # objective
out["G"] = np.array([
x[0] + x[1] - 2, # g1 <= 0
x[0] ** 2 - x[1], # g2 <= 0
])
print(f"ZDT1: {ZDT1().n_var} vars, {ZDT1().n_obj} objectives")
# Mixed-variable problem: some integer, some real
from pymoo.core.variable import Real, Integer, Choice
class MixedProblem(ElementwiseProblem):
def __init__(self):
vars = {
"x": Real(bounds=(-2, 2)),
"n": Integer(bounds=(1, 10)),
}
super().__init__(vars=vars, n_obj=1)
def _evaluate(self, X, out, *args, **kwargs):
x, n = X["x"], X["n"]
out["F"] = (x - n) ** 2
Module 2: Algorithm Selection
pymoo provides 20+ algorithms. Key choices by problem type:
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.algorithms.moo.nsga3 import NSGA3
from pymoo.algorithms.moo.moead import MOEAD
from pymoo.algorithms.soo.nonconvex.ga import GA
from pymoo.algorithms.soo.nonconvex.de import DE
from pymoo.util.ref_dirs import get_reference_directions
# NSGA-II: best for 2-3 objectives, most widely used
nsga2 = NSGA2(pop_size=100)
# NSGA-III: designed for 3+ objectives; needs reference directions
ref_dirs = get_reference_directions("das-dennis", 3, n_partitions=12) # ~91 dirs
nsga3 = NSGA3(pop_size=len(ref_dirs), ref_dirs=ref_dirs)
# MOEA/D: decomposition-based, good for many objectives
moead = MOEAD(ref_dirs=ref_dirs, n_neighbors=15, prob_neighbor_mating=0.7)
# GA: single-objective genetic algorithm
ga = GA(pop_size=100)
# DE: Differential Evolution, good for continuous problems
de = DE(pop_size=100, variant="DE/rand/1/bin", CR=0.9, F=0.8)
print("Algorithms initialized")
Module 3: Operators (Crossover & Mutation)
Operators define how solutions evolve. Replace defaults to match variable type.
from pymoo.operators.crossover.sbx import SBX
from pymoo.operators.mutation.pm import PM
from pymoo.operators.crossover.pntx import TwoPointCrossover
from pymoo.operators.mutation.bitflip import BitflipMutation
from pymoo.operators.sampling.rnd import FloatRandomSampling, BinaryRandomSampling
# Real-valued: Simulated Binary Crossover + Polynomial Mutation (defaults for NSGA-II)
alg_real = NSGA2(
pop_size=100,
sampling=FloatRandomSampling(),
crossover=SBX(prob=0.9, eta=15), # eta: distribution index (higher = closer to parents)
mutation=PM(eta=20), # eta: higher = smaller perturbation
eliminate_duplicates=True
)
# Binary encoding
alg_bin = GA(
pop_size=50,
sampling=BinaryRandomSampling(),
crossover=TwoPointCrossover(),
mutation=BitflipMutation(prob=0.02),
)
print("Custom operators configured")
Module 4: Termination Criteria
Control when the algorithm stops.
from pymoo.termination.default import DefaultMultiObjectiveTermination
from pymoo.termination import get_termination
# Simple: fixed number of generations or evaluations
term_gen = get_termination("n_gen", 500) # stop after 500 generations
term_eval = get_termination("n_eval", 10000) # stop after 10,000 function evaluations
# Convergence-based (recommended for multi-objective)
term_conv = DefaultMultiObjectiveTermination(
xtol=1e-8, # design space tolerance
cvtol=1e-6, # constraint violation tolerance
ftol=0.0025, # objective space tolerance
period=30, # check every 30 generations
n_max_gen=500, # hard limit
n_max_evals=100_000,
)
print("Termination criteria set")
Module 5: Result Analysis and Pareto Front
from pymoo.optimize import minimize
import numpy as np
problem = ZDT1()
algorithm = NSGA2(pop_size=100)
res = minimize(problem, algorithm, ("n_gen", 200), seed=42, verbose=False)
# Access results
print(f"Pareto front solutions: {len(res.F)}")
print(f"Objective values (first 3):\n{res.F[:3]}")
print(f"Decision variables (first 3):\n{res.X[:3]}")
print(f"Algorithm generations: {res.algorithm.n_gen}")
# Filter for feasibility (if constraints exist)
if res.G is not None:
feasible = (res.G <= 0).all(axis=1)
print(f"Feasible solutions: {feasible.sum()}/{len(feasible)}")
# Performance indicators
from pymoo.indicators.hv import HV
from pymoo.indicators.igd import IGD
ref_point = np.array([1.1, 1.1]) # reference point for HV (must dominate all solutions)
hv = HV(ref_point=ref_point)
print(f"Hypervolume indicator: {hv(res.F):.4f}")
Module 6: Visualization
import matplotlib.pyplot as plt
from pymoo.visualization.scatter import Scatter
# Scatter plot for 2D/3D Pareto fronts
plot = Scatter(title="ZDT1 Pareto Front")
plot.add(res.F, color="blue", label="NSGA-II result")
plot.show()
# Manual matplotlib plot
fig, ax = plt.subplots(figsize=(6, 5))
ax.scatter(res.F[:, 0], res.F[:, 1], s=10, color="steelblue", alpha=0.8)
ax.set_xlabel("Objective 1 (f₁)")
ax.set_ylabel("Objective 2 (f₂)")
ax.set_title("Pareto Front — ZDT1")
plt.tight_layout()
plt.savefig("pareto_front.pdf", bbox_inches="tight")
print("Saved pareto_front.pdf")
# Parallel Coordinate Plot for 3+ objectives
from pymoo.visualization.pcp import PCP
# Generate 3-objective result for visualization
from pymoo.problems import get_problem
dtlz2 = get_problem("dtlz2")
ref_dirs = get_reference_directions("das-dennis", 3, n_partitions=12)
res3 = minimize(dtlz2, NSGA3(pop_size=len(ref_dirs), ref_dirs=ref_dirs),
("n_gen", 200), seed=1)
pcp = PCP(title="DTLZ2 — 3 Objectives", labels=["f1", "f2", "f3"])
pcp.add(res3.F)
pcp.show()
Key Concepts
Pareto Dominance
Solution a dominates b if a is no worse than b on all objectives and strictly better on at least one. The Pareto front is the set of non-dominated solutions — there is no single "best" solution, only trade-offs. NSGA-II uses non-dominated sorting + crowding distance to maintain a diverse Pareto approximation.
Constraint Handling
pymoo uses the constraint violation approach: infeasible solutions are penalized but kept in the population. A solution with constraint violation G[i] > 0 is dominated by any feasible solution regardless of objective values. This means the algorithm first drives the population toward feasibility, then optimizes objectives.
Common Workflows
Workflow 1: Two-Objective Engineering Design
import numpy as np
from pymoo.core.problem import Problem
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.optimize import minimize
import matplotlib.pyplot as plt
# Beam design: minimize weight and minimize deflection
class BeamDesign(Problem):
"""
Variables: x[0] = width (0.1–5 cm), x[1] = height (0.5–10 cm)
Obj 1: minimize cross-sectional area (weight proxy)
Obj 2: minimize deflection (1/I, where I = bh³/12)
"""
def __init__(self):
super().__init__(n_var=2, n_obj=2,
xl=np.array([0.1, 0.5]),
xu=np.array([5.0, 10.0]))
def _evaluate(self, X, out, *args, **kwargs):
b, h = X[:, 0], X[:, 1]
area = b * h # objective 1: area (minimize)
I = b * h**3 / 12
deflection = 1 / I # objective 2: deflection (minimize)
out["F"] = np.column_stack([area, deflection])
res = minimize(BeamDesign(), NSGA2(pop_size=100), ("n_gen", 300), seed=1)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].scatter(res.F[:, 0], res.F[:, 1], s=15, c="steelblue")
axes[0].set_xlabel("Cross-sectional area (weight)")
axes[0].set_ylabel("Deflection (1/I)")
axes[0].set_title("Pareto Front")
axes[1].scatter(res.X[:, 0], res.X[:, 1], s=15, c="coral")
axes[1].set_xlabel("Width b (cm)")
axes[1].set_ylabel("Height h (cm)")
axes[1].set_title("Design Space")
plt.tight_layout()
plt.savefig("beam_design.pdf", bbox_inches="tight")
print(f"Pareto solutions: {len(res.F)}")
Workflow 2: Algorithm Comparison with Callback
import numpy as np
from pymoo.core.problem import Problem
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.algorithms.moo.nsga3 import NSGA3
from pymoo.optimize import minimize
from pymoo.core.callback import Callback
from pymoo.indicators.hv import HV
from pymoo.util.ref_dirs import get_reference_directions
class HVCallback(Callback):
def __init__(self, ref_point):
super().__init__()
self.hv_indicator = HV(ref_point=ref_point)
self.history = []
def notify(self, algorithm):
F = algorithm.opt.get("F")
if F is not None and len(F) > 0:
self.history.append(self.hv_indicator(F))
problem = ZDT1()
ref_point = np.array([1.1, 1.1])
results = {}
for name, alg in [("NSGA-II", NSGA2(pop_size=100))]:
cb = HVCallback(ref_point)
res = minimize(problem, alg, ("n_gen", 200), callback=cb, seed=42)
results[name] = {"res": res, "hv": cb.history}
print(f"{name}: final HV = {cb.history[-1]:.4f}")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 4))
for name, data in results.items():
ax.plot(data["hv"], label=name)
ax.set_xlabel("Generation")
ax.set_ylabel("Hypervolume")
ax.legend()
plt.tight_layout()
plt.savefig("hv_convergence.pdf", bbox_inches="tight")
Key Parameters
| Parameter | Module/Class | Default | Range / Options | Effect |
|---|---|---|---|---|
pop_size |
All algorithms | 100 | 50–500 | Population per generation; larger = better diversity, slower |
n_gen |
Termination | — | 100–5000 | Maximum generations to run |
eta (crossover) |
SBX |
15 | 5–30 | Distribution index; higher = offspring closer to parents |
eta (mutation) |
PM |
20 | 5–50 | Perturbation strength; higher = smaller mutation steps |
CR |
DE |
0.9 | 0–1 | Crossover probability in differential evolution |
F |
DE |
0.8 | 0.1–2.0 | Scaling factor for differential evolution mutation |
n_partitions |
get_reference_directions |
12 | 4–20 | Density of reference directions for NSGA-III/MOEA/D |
n_neighbors |
MOEAD |
15 | 5–30 | Neighborhood size for MOEA/D weight vector selection |
prob |
SBX |
0.9 | 0.5–1.0 | Probability of applying crossover to a pair |
Best Practices
-
Profile your objective function first: pymoo calls the objective function
pop_size × n_gentimes. If one evaluation takes >1 ms, parallelize usingpymoo.core.problem.StarmapParallelizationordask. Profile before optimizing. -
Start with NSGA-II for 2 objectives, NSGA-III for 3+: NSGA-II is the de facto standard for biobjective problems. For 3+ objectives, crowding distance degrades — use NSGA-III with Das-Dennis reference directions or MOEA/D.
-
Set termination based on convergence, not fixed generations:
DefaultMultiObjectiveTerminationdetects stagnation automatically. Fixedn_genwastes compute if the Pareto front converges early, or terminates too soon if the problem is hard. -
Use vectorized
ProblemnotElementwiseProblemfor speed:ElementwiseProblemevaluates one solution at a time;Problemevaluates the whole population in one NumPy call. For numpy-compatible functions this is 10–100× faster. -
Normalize objectives before computing indicators: Hypervolume and IGD are sensitive to objective scale. If objectives have different units (e.g., mass in kg vs. deflection in m⁻¹), normalize to [0, 1] before comparison.
Common Recipes
Recipe: Parallelize Expensive Objective Evaluations
from multiprocessing.pool import Pool
from pymoo.core.problem import StarmapParallelization
# Wrap a slow objective function with parallel evaluation
def expensive_objective(x):
import time; time.sleep(0.01) # simulate slow call
return [x[0]**2 + x[1]**2, (x[0]-1)**2 + x[1]**2]
n_workers = 4
pool = Pool(n_workers)
runner = StarmapParallelization(pool.starmap)
class ParallelProblem(Problem):
def __init__(self, runner):
super().__init__(n_var=2, n_obj=2, xl=-2, xu=2, elementwise=True,
elementwise_runner=runner)
def _evaluate(self, x, out, *args, **kwargs):
out["F"] = expensive_objective(x)
res = minimize(ParallelProblem(runner), NSGA2(pop_size=50), ("n_gen", 50))
pool.close()
print(f"Pareto solutions: {len(res.F)}")
Recipe: Restart from Previous Population
import numpy as np
from pymoo.core.population import Population
# Warm-start: use previous result's population as initial population
# Run initial optimization
res1 = minimize(ZDT1(), NSGA2(pop_size=100), ("n_gen", 100), seed=1)
# Continue from checkpoint
initial_pop = Population.new("X", res1.X)
from pymoo.algorithms.moo.nsga2 import NSGA2
alg_warmstart = NSGA2(pop_size=100, sampling=initial_pop)
res2 = minimize(ZDT1(), alg_warmstart, ("n_gen", 100), seed=1)
print(f"Continued optimization: {len(res2.F)} Pareto solutions")
Recipe: Single-Objective GA with Custom Fitness
from pymoo.algorithms.soo.nonconvex.ga import GA
from pymoo.core.problem import ElementwiseProblem
from pymoo.optimize import minimize
class Sphere(ElementwiseProblem):
def __init__(self):
super().__init__(n_var=5, n_obj=1, xl=-5.0, xu=5.0)
def _evaluate(self, x, out, *args, **kwargs):
out["F"] = sum(xi**2 for xi in x)
res = minimize(Sphere(), GA(pop_size=100), ("n_gen", 200), seed=42)
print(f"Best solution: f={res.F[0][0]:.6f}")
print(f"Best x: {res.X}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Pareto front has only 1–2 solutions | pop_size too small or n_gen too few |
Increase pop_size (≥100) and n_gen (≥200 for 2 objectives) |
| All solutions infeasible after many generations | Constraints too tight or initial sampling misses feasible region | Add a feasible seed solution via sampling parameter; relax constraints for warm-start |
NaN or inf in objective values |
Numerical instability in problem definition | Add bounds checks in _evaluate; use np.clip before division |
StarmapParallelization hangs |
Pool not closed; lambda/closure not picklable |
Use pool.close() + pool.join(); define objective as module-level function |
| NSGA-III performs worse than NSGA-II on 2 objectives | NSGA-III designed for 3+ objectives; fewer selection pressures for 2D | Use NSGA-II for 2 objectives; NSGA-III for 3+ |
| Convergence stalled early | Population converged, no diversity | Increase eta in mutation (larger perturbations); increase pop_size; use DE instead |
| Result changes drastically between runs | High stochasticity; no seed set | Set seed=42 in minimize() for reproducibility |
Related Skills
scipy-optimization— single-objective, gradient-based optimization for smooth problemspymatgen— materials property optimization using pymoo for multi-objective crystal structure searchscikit-learn-machine-learning— hyperparameter tuning (use pymoo for multi-objective HPO: accuracy vs. latency)
References
- pymoo documentation — algorithm catalog, API reference, and tutorials
- pymoo paper: Blank & Deb (2020), IEEE Access — algorithm descriptions and benchmarks
- NSGA-II paper: Deb et al. (2002), IEEE TEC — foundational multi-objective EA
- NSGA-III paper: Deb & Jain (2014), IEEE TEC — many-objective extension
- pymoo GitHub — source, examples, and issue tracker
skills/scientific-computing/scikit-learn-machine-learning/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scikit-learn-machine-learning -g -y
SKILL.md
Frontmatter
{
"name": "scikit-learn-machine-learning",
"license": "BSD-3-Clause",
"description": "Classical ML in Python: classification, regression, clustering, dim reduction, evaluation, tuning, preprocessing pipelines. Linear models, tree ensembles, SVMs, K-Means, PCA, t-SNE. Use PyTorch\/TF for deep learning; XGBoost\/LightGBM for scale."
}
scikit-learn
Overview
scikit-learn is the standard Python library for classical machine learning. It provides consistent APIs for supervised learning (classification, regression), unsupervised learning (clustering, dimensionality reduction), model evaluation, and preprocessing, with seamless integration into NumPy/pandas workflows.
When to Use
- Building classification models for labeled data (spam detection, disease diagnosis, species identification)
- Predicting continuous outcomes with regression (price prediction, dose-response modeling)
- Clustering unlabeled data into groups (patient stratification, gene expression clusters)
- Reducing dimensionality for visualization or feature engineering (PCA, t-SNE on multi-omics data)
- Evaluating and comparing model performance with cross-validation
- Tuning hyperparameters systematically (grid search, random search)
- Building reproducible ML pipelines with preprocessing and modeling steps
- For deep learning tasks (images, NLP), use
pytorchortransformersinstead - For large-scale gradient boosting, use
xgboostorlightgbminstead
Prerequisites
- Python packages:
scikit-learn,numpy,pandas - Optional:
matplotlib,seabornfor visualization - Data: Tabular data as NumPy arrays or pandas DataFrames
pip install scikit-learn numpy pandas matplotlib seaborn
Quick Start
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report
from sklearn.datasets import load_breast_cancer
# Load dataset, split, train, evaluate in 10 lines
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
clf = RandomForestClassifier(n_estimators=100, random_state=42)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(classification_report(y_test, y_pred, target_names=["malignant", "benign"]))
Core API
Module 1: Data Preprocessing
Scaling, encoding, imputation, and feature engineering.
from sklearn.preprocessing import StandardScaler, MinMaxScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
import numpy as np
# Scaling: zero mean, unit variance
X = np.array([[1, 2], [3, 4], [5, 6]])
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
print(f"Mean: {X_scaled.mean(axis=0)}, Std: {X_scaled.std(axis=0)}")
# Mean: [0. 0.], Std: [1. 1.]
# Imputation: fill missing values
X_missing = np.array([[1, np.nan], [3, 4], [np.nan, 6]])
imputer = SimpleImputer(strategy="median")
X_filled = imputer.fit_transform(X_missing)
print(f"Filled:\n{X_filled}")
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, LabelEncoder
# One-hot encoding for nominal categories
enc = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
X_cat = np.array([["red"], ["blue"], ["green"], ["red"]])
X_encoded = enc.fit_transform(X_cat)
print(f"Categories: {enc.categories_}")
print(f"Encoded shape: {X_encoded.shape}") # (4, 3)
Module 2: Supervised Learning — Classification
Classifiers for discrete target prediction.
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# Compare classifiers
classifiers = {
"LogisticRegression": LogisticRegression(max_iter=200),
"RandomForest": RandomForestClassifier(n_estimators=100, random_state=42),
"SVM": SVC(kernel="rbf", C=1.0),
"GradientBoosting": GradientBoostingClassifier(n_estimators=100, random_state=42),
}
for name, clf in classifiers.items():
clf.fit(X_train, y_train)
print(f"{name}: accuracy = {clf.score(X_test, y_test):.3f}")
Module 3: Supervised Learning — Regression
Regressors for continuous target prediction.
from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import make_regression
from sklearn.metrics import mean_squared_error, r2_score
X, y = make_regression(n_samples=200, n_features=10, noise=10, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
models = {
"Linear": LinearRegression(),
"Ridge": Ridge(alpha=1.0),
"Lasso": Lasso(alpha=0.1),
"RandomForest": RandomForestRegressor(n_estimators=100, random_state=42),
}
for name, model in models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(f"{name}: RMSE={mean_squared_error(y_test, y_pred, squared=False):.2f}, R²={r2_score(y_test, y_pred):.3f}")
Module 4: Unsupervised Learning — Clustering
Clustering algorithms for unlabeled data.
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering
from sklearn.metrics import silhouette_score
from sklearn.datasets import make_blobs
X, y_true = make_blobs(n_samples=300, centers=4, random_state=42)
# K-Means with elbow method
for k in [2, 3, 4, 5, 6]:
km = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = km.fit_predict(X)
sil = silhouette_score(X, labels)
print(f"k={k}: silhouette={sil:.3f}, inertia={km.inertia_:.1f}")
# DBSCAN — no need to specify k
from sklearn.cluster import DBSCAN
db = DBSCAN(eps=0.5, min_samples=5)
labels = db.fit_predict(X)
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
n_noise = (labels == -1).sum()
print(f"DBSCAN: {n_clusters} clusters, {n_noise} noise points")
Module 5: Dimensionality Reduction
PCA, t-SNE, and other methods for visualization and feature reduction.
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)
print(f"Original shape: {X.shape}") # (1797, 64)
# PCA — preserve 95% variance
pca = PCA(n_components=0.95)
X_pca = pca.fit_transform(X)
print(f"PCA: {X_pca.shape[1]} components, explained variance: {pca.explained_variance_ratio_.sum():.3f}")
# t-SNE — 2D visualization
tsne = TSNE(n_components=2, perplexity=30, random_state=42)
X_tsne = tsne.fit_transform(X)
print(f"t-SNE shape: {X_tsne.shape}") # (1797, 2)
Module 6: Model Evaluation & Selection
Cross-validation, metrics, hyperparameter tuning.
from sklearn.model_selection import cross_val_score, GridSearchCV, StratifiedKFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.datasets import load_iris
X, y = load_iris(return_X_y=True)
# Cross-validation
clf = RandomForestClassifier(n_estimators=100, random_state=42)
scores = cross_val_score(clf, X, y, cv=StratifiedKFold(5), scoring="accuracy")
print(f"CV accuracy: {scores.mean():.3f} ± {scores.std():.3f}")
# Hyperparameter tuning with GridSearchCV
param_grid = {
"n_estimators": [50, 100, 200],
"max_depth": [5, 10, None],
"min_samples_split": [2, 5]
}
grid = GridSearchCV(
RandomForestClassifier(random_state=42),
param_grid, cv=5, scoring="accuracy", n_jobs=-1
)
grid.fit(X, y)
print(f"Best params: {grid.best_params_}")
print(f"Best score: {grid.best_score_:.3f}")
Module 7: Pipelines
Chain preprocessing and models; prevent data leakage.
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import GradientBoostingClassifier
# Mixed-type preprocessing
numeric_features = ["age", "income"]
categorical_features = ["gender", "occupation"]
preprocessor = ColumnTransformer([
("num", Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
]), numeric_features),
("cat", Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))
]), categorical_features),
])
pipe = Pipeline([
("preprocessor", preprocessor),
("classifier", GradientBoostingClassifier(random_state=42))
])
# pipe.fit(X_train, y_train); pipe.predict(X_test)
print("Pipeline steps:", [name for name, _ in pipe.steps])
Common Workflows
Workflow 1: End-to-End Classification
Goal: Complete classification workflow from data loading to evaluation.
import pandas as pd
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report
from sklearn.datasets import load_breast_cancer
# Load data
X, y = load_breast_cancer(return_X_y=True, as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# Build pipeline
pipe = Pipeline([
("scaler", StandardScaler()),
("clf", RandomForestClassifier(n_estimators=200, random_state=42))
])
# Cross-validate
cv_scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="f1")
print(f"CV F1: {cv_scores.mean():.3f} ± {cv_scores.std():.3f}")
# Final evaluation
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
print(classification_report(y_test, y_pred))
Workflow 2: Clustering with Visualization
Goal: Cluster data and visualize with dimensionality reduction.
from sklearn.datasets import make_blobs
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt
# Generate and scale data
X, _ = make_blobs(n_samples=500, centers=4, random_state=42)
X_scaled = StandardScaler().fit_transform(X)
# Cluster
km = KMeans(n_clusters=4, random_state=42, n_init=10)
labels = km.fit_predict(X_scaled)
print(f"Silhouette: {silhouette_score(X_scaled, labels):.3f}")
# Visualize
X_2d = PCA(n_components=2).fit_transform(X_scaled)
plt.scatter(X_2d[:, 0], X_2d[:, 1], c=labels, cmap="viridis", s=20, alpha=0.7)
plt.title("K-Means Clustering (PCA projection)")
plt.savefig("clustering_result.png", dpi=150, bbox_inches="tight")
print("Saved clustering_result.png")
Workflow 3: Feature Selection + Model Pipeline
Goal: Select best features and build a tuned model.
from sklearn.datasets import make_classification
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.model_selection import GridSearchCV
X, y = make_classification(n_samples=500, n_features=50, n_informative=10, random_state=42)
pipe = Pipeline([
("scaler", StandardScaler()),
("selector", SelectKBest(f_classif)),
("svm", SVC(kernel="rbf"))
])
param_grid = {
"selector__k": [5, 10, 20],
"svm__C": [0.1, 1, 10],
"svm__gamma": ["scale", "auto"]
}
grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy", n_jobs=-1)
grid.fit(X, y)
print(f"Best params: {grid.best_params_}")
print(f"Best accuracy: {grid.best_score_:.3f}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
n_estimators |
RandomForest, GradientBoosting | 100 |
50-1000 |
Number of trees; higher = better but slower |
max_depth |
Tree-based models | None |
1-50, None |
Tree depth; None = no limit (can overfit) |
C |
SVM, LogisticRegression | 1.0 |
0.001-1000 |
Regularization strength (inverse); lower = more regularization |
alpha |
Ridge, Lasso | 1.0 |
0.001-100 |
Regularization strength; higher = more regularization |
n_clusters |
KMeans | required | 2-N |
Number of clusters to form |
eps |
DBSCAN | 0.5 |
0.01-10 |
Neighborhood radius; smaller = more clusters |
n_components |
PCA | required | 1-N or 0.0-1.0 |
Components to keep; float = variance ratio |
perplexity |
t-SNE | 30 |
5-50 |
Balance local/global structure |
cv |
GridSearchCV | 5 |
2-10 |
Cross-validation folds |
scoring |
GridSearchCV, cross_val_score | varies | accuracy, f1, roc_auc, etc. |
Evaluation metric |
Common Recipes
Recipe: Feature Importance Analysis
When to use: Understanding which features drive model predictions.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
X, y = load_iris(return_X_y=True)
clf = RandomForestClassifier(n_estimators=200, random_state=42).fit(X, y)
importances = clf.feature_importances_
indices = np.argsort(importances)[::-1]
feature_names = load_iris().feature_names
for i in range(X.shape[1]):
print(f"{feature_names[indices[i]]}: {importances[indices[i]]:.4f}")
Recipe: Learning Curve Diagnosis
When to use: Diagnosing overfitting vs underfitting.
from sklearn.model_selection import learning_curve
import matplotlib.pyplot as plt
import numpy as np
train_sizes, train_scores, val_scores = learning_curve(
clf, X, y, cv=5, train_sizes=np.linspace(0.1, 1.0, 10), scoring="accuracy"
)
plt.plot(train_sizes, train_scores.mean(axis=1), label="Train")
plt.plot(train_sizes, val_scores.mean(axis=1), label="Validation")
plt.xlabel("Training size"); plt.ylabel("Accuracy"); plt.legend()
plt.savefig("learning_curve.png", dpi=150, bbox_inches="tight")
print("Saved learning_curve.png")
Recipe: Save and Load Models
When to use: Persisting trained models for later use.
import joblib
# Save
joblib.dump(pipe, "model_pipeline.joblib")
print("Model saved to model_pipeline.joblib")
# Load
loaded_pipe = joblib.load("model_pipeline.joblib")
y_pred = loaded_pipe.predict(X_test)
print(f"Loaded model predictions: {y_pred[:5]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ConvergenceWarning |
Model didn't converge | Increase max_iter (e.g., 1000) or scale features with StandardScaler |
| High train accuracy, low test accuracy | Overfitting | Add regularization, reduce max_depth, use cross-validation |
ValueError: unknown categories |
New categories in test data | Use OneHotEncoder(handle_unknown='ignore') |
MemoryError with large data |
Full dataset in memory | Use SGDClassifier/MiniBatchKMeans for incremental learning |
| Poor clustering results | Unscaled features or wrong k | Scale features first; use silhouette score to find optimal k |
NotFittedError |
Predict before fit | Call model.fit(X_train, y_train) first |
| Different results each run | Missing random_state |
Set random_state=42 in model and train_test_split |
| Slow GridSearchCV | Large parameter grid | Use RandomizedSearchCV or HalvingGridSearchCV; add n_jobs=-1 |
References
- scikit-learn User Guide — official documentation
- scikit-learn API Reference — complete API
- scikit-learn Examples Gallery — tutorials
- Pedregosa et al. (2011). Scikit-learn: Machine Learning in Python. JMLR 12:2825-2830.
skills/scientific-computing/shap-model-explainability/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill shap-model-explainability -g -y
SKILL.md
Frontmatter
{
"name": "shap-model-explainability",
"license": "MIT",
"description": "Model interpretability via SHAP (Shapley values from game theory). Covers explainer choice (Tree, Deep, Linear, Kernel, Gradient, Permutation), feature attribution, and plots (waterfall, beeswarm, bar, scatter, force, heatmap). Use to explain ML predictions, rank features, debug models, audit fairness, or compare models. Works with tree, deep, linear, and black-box models."
}
SHAP Model Explainability
Overview
SHAP (SHapley Additive exPlanations) is a unified framework for explaining machine learning model predictions using Shapley values from cooperative game theory. It quantifies each feature's contribution to individual predictions and provides both local (per-instance) and global (dataset-level) explanations with theoretical guarantees of consistency and additivity.
When to Use
- Explaining which features drive a model's predictions (global importance)
- Understanding why a model made a specific prediction (local explanation)
- Debugging model behavior and identifying data leakage
- Analyzing model fairness across demographic groups
- Comparing feature importance across multiple models
- Generating interpretable model explanations for stakeholders
- For tree-based model interpretation, prefer SHAP over permutation importance or Gini importance (more accurate, instance-level)
- For deep learning interpretation on images, consider GradCAM; use SHAP for tabular/structured data
Prerequisites
pip install shap matplotlib
# Optional: xgboost lightgbm tensorflow torch (depending on model)
Quick Start
import shap
import xgboost as xgb
from sklearn.model_selection import train_test_split
# Load example data
X, y = shap.datasets.adult()
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# Train model
model = xgb.XGBClassifier(n_estimators=100).fit(X_train, y_train)
# Explain: select explainer → compute → visualize
explainer = shap.TreeExplainer(model)
shap_values = explainer(X_test)
shap.plots.beeswarm(shap_values) # Global importance
shap.plots.waterfall(shap_values[0]) # Single prediction
print(f"Base value: {shap_values.base_values[0]:.3f}")
print(f"SHAP values shape: {shap_values.values.shape}") # (n_samples, n_features)
Workflow
Step 1: Select the Right Explainer
Choose based on model type:
| Model Type | Explainer | Speed | Exactness |
|---|---|---|---|
| Tree-based (XGBoost, LightGBM, RF, CatBoost) | TreeExplainer |
Fast | Exact |
| Linear (LogReg, GLM, Ridge) | LinearExplainer |
Instant | Exact |
| Deep learning (TensorFlow, PyTorch) | DeepExplainer |
Fast | Approximate |
| Deep learning (gradient-based) | GradientExplainer |
Fast | Approximate |
| Any model (black-box) | KernelExplainer |
Slow | Approximate |
| Any model (permutation-based) | PermutationExplainer |
Very slow | Exact |
| Unsure? | shap.Explainer |
Auto | Auto |
# Tree-based models (most common)
explainer = shap.TreeExplainer(model)
# Linear models
explainer = shap.LinearExplainer(model, X_train)
# Deep learning
explainer = shap.DeepExplainer(model, X_train[:100])
# Any model (model-agnostic, slower)
explainer = shap.KernelExplainer(model.predict, shap.kmeans(X_train, 50))
# Auto-select
explainer = shap.Explainer(model, X_train)
Step 2: Compute SHAP Values
shap_values = explainer(X_test)
# shap_values object contains:
# .values — SHAP values array (n_samples, n_features)
# .base_values — Expected model output (baseline)
# .data — Original feature values
# Verify additivity: prediction = base_value + sum(SHAP values)
print(f" {shap_values.base_values[0]:.3f} + {shap_values.values[0].sum():.3f} = "
f"{shap_values.base_values[0] + shap_values.values[0].sum():.3f}")
Step 3: Global Explanations
# Beeswarm: feature importance + value distributions (most informative)
shap.plots.beeswarm(shap_values, max_display=15)
# Bar: clean mean |SHAP| importance
shap.plots.bar(shap_values)
Step 4: Local Explanations (Individual Predictions)
# Waterfall: detailed breakdown of one prediction
shap.plots.waterfall(shap_values[0])
# Force: additive force visualization
shap.plots.force(shap_values[0])
Step 5: Feature Relationships
# Scatter: how a feature affects predictions
shap.plots.scatter(shap_values[:, "Age"])
# Colored by interaction feature
shap.plots.scatter(shap_values[:, "Age"], color=shap_values[:, "Education-Num"])
Step 6: Advanced Visualizations
# Heatmap: multi-sample SHAP grid
shap.plots.heatmap(shap_values[:100])
# Decision plot: cumulative SHAP paths
shap.plots.decision(shap_values.base_values[0], shap_values.values[:10],
feature_names=X_test.columns.tolist())
# Cohort comparison
import numpy as np
mask_a = X_test["Age"] < 40
shap.plots.bar({
"Under 40": shap_values[mask_a],
"40+": shap_values[~mask_a]
})
Key Parameters
| Parameter | Explainer/Function | Default | Effect |
|---|---|---|---|
feature_perturbation |
TreeExplainer | "tree_path_dependent" |
"interventional" for causal interpretation (requires background data) |
model_output |
TreeExplainer | "raw" |
"probability" to explain probabilities instead of log-odds |
data (background) |
KernelExplainer, DeepExplainer | Required | 100-1000 representative samples; use shap.kmeans(X, 50) for efficiency |
nsamples |
KernelExplainer | "auto" |
Higher = more accurate but slower; minimum 2×features |
max_display |
All plot functions | 10 | Number of features shown in plots |
alpha |
scatter/beeswarm | 1.0 | Point transparency for dense datasets |
show |
All plot functions | True | Set False to get matplotlib figure for saving |
clustering |
beeswarm | None | shap.utils.hclust(...) to cluster correlated features |
Key Concepts
SHAP Value Properties
SHAP values have three theoretical guarantees (unique among explanation methods):
- Additivity:
prediction = base_value + sum(SHAP values)— exact decomposition - Consistency: If a feature becomes more important in the model, its SHAP value increases
- Missingness: Features not present receive zero attribution
Interpretation: Positive SHAP → pushes prediction higher; Negative → lower; Magnitude → strength of impact.
Model Output Types
Understand what your model outputs — SHAP explains the output space:
- Regression: SHAP values in target units (e.g., dollars, temperature)
- Classification (log-odds): Default for tree classifiers. Use
model_output="probability"for probability explanations - Classification (probability): SHAP values sum to probability deviation from baseline
SHAP vs Other Methods
| Method | Local | Global | Consistent | Model-agnostic |
|---|---|---|---|---|
| SHAP | Yes | Yes | Yes | Yes |
| Permutation importance | No | Yes | No | Yes |
| Gini/split importance | No | Yes | No | Trees only |
| LIME | Yes | No | No | Yes |
| Integrated Gradients | Yes | No | Partial | NN only |
Interaction Values (TreeExplainer only)
shap_interaction = explainer.shap_interaction_values(X_test)
# Shape: (n_samples, n_features, n_features)
# Diagonal = main effects; off-diagonal = pairwise interactions
Background Data Selection
Background data establishes the baseline (expected model output). Selection affects SHAP magnitudes but not relative importance.
- Random sample from training data: 100-500 samples
- Use
shap.kmeans(X_train, 50)for efficient summarization - For TreeExplainer with
tree_path_dependent: no background data needed (uses tree structure) - For DeepExplainer/KernelExplainer: 100-1000 samples balance accuracy vs speed
Common Recipes
Recipe: Model Debugging
import numpy as np
# Find misclassified samples
predictions = model.predict(X_test)
errors = predictions != y_test
error_indices = np.where(errors)[0]
# Explain errors
for idx in error_indices[:3]:
print(f"Sample {idx}: predicted={predictions[idx]}, actual={y_test.iloc[idx]}")
shap.plots.waterfall(shap_values[idx])
# Check for data leakage: unexpected high-importance features
mean_abs_shap = np.abs(shap_values.values).mean(0)
top_features = X_test.columns[mean_abs_shap.argsort()[-5:]]
print(f"Top features (check for leakage): {list(top_features)}")
Recipe: Fairness Analysis
# Compare SHAP distributions across groups
group_a = shap_values[X_test["Sex"] == 0]
group_b = shap_values[X_test["Sex"] == 1]
shap.plots.bar({"Female": group_a, "Male": group_b})
# Check protected attribute importance
sex_importance = np.abs(shap_values[:, "Sex"].values).mean()
total_importance = np.abs(shap_values.values).mean()
print(f"Sex contribution: {sex_importance/total_importance:.1%} of total importance")
Recipe: Production Caching
import joblib
# Save explainer for reuse
joblib.dump(explainer, 'explainer.pkl')
explainer = joblib.load('explainer.pkl')
# Batch computation for API responses
def explain_batch(X_batch, explainer, top_n=5):
sv = explainer(X_batch)
results = []
for i in range(len(X_batch)):
top_idx = np.abs(sv.values[i]).argsort()[-top_n:]
results.append({
'prediction': sv.base_values[i] + sv.values[i].sum(),
'top_features': {X_batch.columns[j]: sv.values[i][j] for j in top_idx}
})
return results
Recipe: MLflow Integration
import mlflow
import matplotlib.pyplot as plt
with mlflow.start_run():
model = xgb.XGBClassifier().fit(X_train, y_train)
explainer = shap.TreeExplainer(model)
shap_values = explainer(X_test)
shap.plots.beeswarm(shap_values, show=False)
mlflow.log_figure(plt.gcf(), "shap_beeswarm.png")
plt.close()
for feat, imp in zip(X_test.columns, np.abs(shap_values.values).mean(0)):
mlflow.log_metric(f"shap_{feat}", imp)
Expected Outputs
| Output | Type | Description |
|---|---|---|
shap_values |
shap.Explanation |
Object with .values (n_samples, n_features), .base_values (baseline), .data (input features) |
| Waterfall plot | matplotlib figure | Single-instance explanation showing feature contributions from base value to prediction |
| Beeswarm plot | matplotlib figure | Global summary: feature importance × direction for all samples |
| Bar plot | matplotlib figure | Mean absolute SHAP values per feature (global importance ranking) |
| Force plot | HTML/matplotlib | Interactive or static visualization of a single prediction |
mean_abs_shap |
pd.Series |
Per-feature mean absolute SHAP value for ranking and reporting |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Very slow computation | Using KernelExplainer for tree model | Use TreeExplainer for tree-based models |
| Slow on large dataset | Computing all samples at once | Sample subset: explainer(X_test[:1000]) or batch |
| SHAP values don't sum to prediction | Wrong model output type | Check model_output parameter; verify additivity |
| Log-odds vs probability confusion | Tree classifier defaults to log-odds | Use TreeExplainer(model, model_output="probability") |
| Plots too cluttered | Too many features shown | Set max_display=10 or use feature clustering |
| DeepExplainer error | Background data too small | Use 100-1000 background samples |
| Memory error | Large dataset + many features | Reduce background data with shap.kmeans(X, 50) |
| Force plot not rendering | Missing JS in notebook | Run shap.initjs() at notebook start |
| Inconsistent importance across runs | KernelExplainer sampling variance | Increase nsamples or use deterministic explainer |
| Negative importance for relevant feature | Feature interactions or correlations | Use feature_perturbation="interventional" or scatter plots |
Bundled Resources
references/theory.md— Mathematical foundations: Shapley value formula, key properties (additivity, symmetry, dummy, monotonicity), computation algorithms (Tree SHAP, Kernel SHAP, Deep SHAP, Linear SHAP), conditional expectations (interventional vs observational), comparison with LIME/DeepLIFT/LRP/Integrated Gradients, interaction values, theoretical limitations
Not migrated from original: references/explainers.md (340 lines) — detailed constructor parameters, methods, and performance benchmarks for each explainer class. Explainer selection guide and common usage are covered inline in Workflow Step 1 and Key Parameters.
Not migrated from original: references/plots.md (508 lines) — comprehensive parameter reference for all 9 plot types with advanced customization (violin, decision, feature clustering). Main plot types are covered inline in Workflow Steps 3-6.
Not migrated from original: references/workflows.md (606 lines) — detailed step-by-step workflows for feature engineering, model comparison, deep learning explanation, production deployment, and time series. Core patterns are covered in Common Recipes; consult original for extended workflows.
Best Practices
- Choose specialized explainers first —
TreeExplainer>LinearExplainer>DeepExplainer>KernelExplainer. Only use model-agnostic explainers when no specialized one exists - Start global, then go local — begin with beeswarm/bar for overall importance, then waterfall/scatter for individual predictions and feature relationships
- Use multiple visualizations — different plots reveal different insights; combine global (beeswarm) + local (waterfall) + relationship (scatter)
- Select appropriate background data — 100-500 representative samples from training data; use
shap.kmeans()for efficiency - Validate with domain knowledge — unexpectedly high feature importance may indicate data leakage, not true predictive power
- Remember SHAP shows association, not causation — a feature's high SHAP importance means the model uses it, not that it causally affects the outcome
- Consider feature correlations — correlated features share SHAP importance; use
feature_perturbation="interventional"for causal interpretation or feature clustering for grouped importance
References
- Lundberg & Lee (2017). "A Unified Approach to Interpreting Model Predictions" (NeurIPS)
- Lundberg et al. (2020). "From local explanations to global understanding with explainable AI for trees" (Nature Machine Intelligence)
- Official documentation: https://shap.readthedocs.io/
- GitHub: https://github.com/shap/shap
Related Skills
- scikit-learn-machine-learning — model training for SHAP analysis
- matplotlib-scientific-plotting — custom SHAP plot styling and export
- statistical-analysis — statistical testing to complement SHAP interpretation
skills/scientific-computing/simpy-discrete-event-simulation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill simpy-discrete-event-simulation -g -y
SKILL.md
Frontmatter
{
"name": "simpy-discrete-event-simulation",
"license": "MIT",
"description": "Process-based discrete-event simulation. Model queues, shared resources, timed events: manufacturing, service ops, network traffic, logistics. Processes are Python generators yielding events. Resources: capacity-limited (Resource\/Priority\/Preemptive), bulk (Container), objects (Store, FilterStore). For continuous use SciPy ODEs; for agent-based use Mesa."
}
SimPy — Discrete-Event Simulation
Overview
SimPy is a process-based discrete-event simulation framework using standard Python generators. Model systems where entities (customers, vehicles, packets) interact with shared resources (servers, machines, bandwidth) over time, with event-driven scheduling and optional real-time synchronization.
When to Use
- Modeling queue-based systems with resource contention (servers, machines, staff)
- Manufacturing process simulation (production lines, scheduling, bottleneck analysis)
- Network simulation (packet routing, bandwidth allocation, latency analysis)
- Capacity planning (determining optimal resource levels for target throughput)
- Healthcare operations (ER patient flow, staff allocation, bed management)
- Logistics and transportation (warehouse operations, vehicle routing)
- For continuous-time ODE systems → use SciPy
solve_ivp - For agent-based modeling → use Mesa
Prerequisites
# pip install simpy
import simpy
import random
Quick Start
import simpy
import random
def customer(env, name, server):
"""Customer arrives, waits for server, gets served, departs."""
arrival = env.now
with server.request() as req:
yield req # Wait in queue
wait = env.now - arrival
yield env.timeout(random.expovariate(1/3)) # Service time
print(f'{name}: waited {wait:.1f}, served at {env.now:.1f}')
def arrivals(env, server):
for i in range(20):
yield env.timeout(random.expovariate(1/2)) # Inter-arrival
env.process(customer(env, f'C{i}', server))
env = simpy.Environment()
server = simpy.Resource(env, capacity=2)
env.process(arrivals(env, server))
env.run(until=50)
Core API
1. Environment & Processes
import simpy
# Standard environment
env = simpy.Environment(initial_time=0)
# Processes are Python generators that yield events
def machine(env, name, repair_time):
while True:
yield env.timeout(random.expovariate(1/10)) # Time to failure
print(f'{name} broke at {env.now:.1f}')
yield env.timeout(repair_time)
print(f'{name} repaired at {env.now:.1f}')
# Start processes — returns a Process event
proc = env.process(machine(env, 'Machine-1', repair_time=2))
# Run until time limit or no events remain
env.run(until=100)
# env.run() # Run until no more events
# Current simulation time
print(f'Final time: {env.now}')
# Processes can return values and be awaited
def subtask(env, duration):
yield env.timeout(duration)
return f'completed in {duration}'
def main_task(env):
# Sequential: wait for one process
result = yield env.process(subtask(env, 5))
print(f'Subtask {result} at {env.now}')
# Parallel: wait for ALL (AllOf)
t1 = env.process(subtask(env, 3))
t2 = env.process(subtask(env, 4))
results = yield t1 & t2 # AllOf — resumes when both done
print(f'Both done at {env.now}')
# Race: wait for ANY (AnyOf)
t3 = env.process(subtask(env, 2))
t4 = env.process(subtask(env, 6))
result = yield t3 | t4 # AnyOf — resumes when first completes
print(f'First done at {env.now}')
env = simpy.Environment()
env.process(main_task(env))
env.run()
2. Resources
import simpy
env = simpy.Environment()
# Basic resource — capacity-limited (e.g., 2 servers)
server = simpy.Resource(env, capacity=2)
print(f'Capacity: {server.capacity}, In use: {server.count}, Queue: {len(server.queue)}')
# Priority resource — lower number = higher priority
priority_server = simpy.PriorityResource(env, capacity=1)
def vip_customer(env, res):
with res.request(priority=1) as req: # Higher priority
yield req
yield env.timeout(3)
def regular_customer(env, res):
with res.request(priority=10) as req: # Lower priority
yield req
yield env.timeout(3)
# Preemptive resource — high priority interrupts low priority
preemptive = simpy.PreemptiveResource(env, capacity=1)
def urgent_job(env, res):
with res.request(priority=0, preempt=True) as req:
yield req # May interrupt current user
yield env.timeout(1)
# Container — bulk material (fuel, water, inventory)
tank = simpy.Container(env, capacity=100, init=50)
def refuel(env, tank):
yield tank.put(30) # Add 30 units
print(f'Tank level: {tank.level}/{tank.capacity}')
def consume(env, tank):
yield tank.get(20) # Remove 20 units
print(f'Tank level: {tank.level}/{tank.capacity}')
# Store — FIFO object storage
warehouse = simpy.Store(env, capacity=10)
def producer(env, store):
for i in range(5):
yield env.timeout(2)
yield store.put(f'Item-{i}')
def consumer(env, store):
while True:
item = yield store.get()
print(f'Got {item} at {env.now}')
yield env.timeout(3)
# FilterStore — selective retrieval
parts = simpy.FilterStore(env, capacity=20)
def picker(env, store):
# Get specific item matching condition
item = yield store.get(lambda x: x['color'] == 'red')
print(f'Found red item: {item}')
3. Events & Synchronization
import simpy
env = simpy.Environment()
# Basic event — manual trigger for signaling between processes
signal = env.event()
def waiter(env, event):
print(f'Waiting at {env.now}')
value = yield event # Blocks until triggered
print(f'Got signal "{value}" at {env.now}')
def sender(env, event):
yield env.timeout(5)
event.succeed(value='go') # Trigger with value
env.process(waiter(env, signal))
env.process(sender(env, signal))
env.run()
# Output: Waiting at 0, Got signal "go" at 5
# Timeout — most common event
yield env.timeout(delay=5)
# Process interruption
def interruptible(env, name):
try:
yield env.timeout(10)
except simpy.Interrupt as interrupt:
print(f'{name} interrupted: {interrupt.cause} at {env.now}')
def interruptor(env, proc):
yield env.timeout(3)
proc.interrupt('maintenance')
proc = env.process(interruptible(env, 'Worker'))
env.process(interruptor(env, proc))
# Barrier synchronization — wait for N processes
class Barrier:
def __init__(self, env, n):
self.env = env
self.n = n
self.count = 0
self.event = env.event()
def wait(self):
self.count += 1
if self.count >= self.n:
self.event.succeed()
return self.event
def phase_worker(env, name, barrier):
yield env.timeout(random.uniform(1, 5)) # Phase work
print(f'{name} reached barrier at {env.now:.1f}')
yield barrier.wait() # Wait for all workers
print(f'{name} passed barrier at {env.now:.1f}')
env = simpy.Environment()
barrier = Barrier(env, n=3)
for i in range(3):
env.process(phase_worker(env, f'W{i}', barrier))
env.run()
4. Monitoring & Statistics
import simpy
# Inline statistics collection
class Stats:
def __init__(self):
self.wait_times = []
self.queue_lengths = []
def report(self):
if self.wait_times:
avg_wait = sum(self.wait_times) / len(self.wait_times)
max_wait = max(self.wait_times)
print(f'Avg wait: {avg_wait:.2f}, Max wait: {max_wait:.2f}')
print(f'Customers served: {len(self.wait_times)}')
def customer(env, name, server, stats):
arrival = env.now
with server.request() as req:
yield req
wait = env.now - arrival
stats.wait_times.append(wait)
stats.queue_lengths.append(len(server.queue))
yield env.timeout(random.expovariate(1/3))
env = simpy.Environment()
server = simpy.Resource(env, capacity=2)
stats = Stats()
def gen(env, server, stats):
for i in range(100):
yield env.timeout(random.expovariate(1/2))
env.process(customer(env, f'C{i}', server, stats))
env.process(gen(env, server, stats))
env.run(until=200)
stats.report()
# Resource monitoring via monkey-patching
def patch_resource(resource, data):
"""Patch resource to log request/release events."""
original_request = resource.request
original_release = resource.release
def monitored_request(*args, **kwargs):
req = original_request(*args, **kwargs)
data.append((resource._env.now, 'request', resource.count, len(resource.queue)))
return req
def monitored_release(*args, **kwargs):
result = original_release(*args, **kwargs)
data.append((resource._env.now, 'release', resource.count, len(resource.queue)))
return result
resource.request = monitored_request
resource.release = monitored_release
log = []
patch_resource(server, log)
# After simulation: analyze log for utilization, queue dynamics
5. Real-Time Simulation
import simpy.rt
# Real-time environment — synchronized with wall clock
env = simpy.rt.RealtimeEnvironment(factor=1.0) # 1 sim unit = 1 second
# factor=0.1 → 10x faster (1 sim unit = 0.1 seconds)
# factor=60 → 1 sim unit = 1 minute
# Strict mode raises RuntimeError if simulation can't keep up
env_strict = simpy.rt.RealtimeEnvironment(factor=1.0, strict=True)
# Non-strict mode (default) allows slower-than-real-time execution
env_relaxed = simpy.rt.RealtimeEnvironment(factor=1.0, strict=False)
def periodic_task(env, interval):
while True:
print(f'Tick at sim time {env.now:.1f}')
yield env.timeout(interval)
env = simpy.rt.RealtimeEnvironment(factor=1.0)
env.process(periodic_task(env, 2.0))
env.run(until=10)
# Prints "Tick" every ~2 real seconds
Key Concepts
Resource Selection Guide
| Need | Resource Type | Key Feature |
|---|---|---|
| Limited servers/machines | Resource |
FIFO queue, capacity limit |
| Priority queuing | PriorityResource |
Lower number = higher priority |
| Preemptive scheduling | PreemptiveResource |
High priority interrupts current user |
| Bulk material (fuel, water) | Container |
put(amount) / get(amount), continuous level |
| Object queue (FIFO) | Store |
put(item) / get(), ordered retrieval |
| Conditional retrieval | FilterStore |
get(lambda x: condition) |
| Priority-ordered items | PriorityStore |
Items sorted by priority |
Process Interaction Mechanisms
| Mechanism | Use When | Code Pattern |
|---|---|---|
| Event signaling | Broadcast to multiple waiters | event = env.event() → yield event / event.succeed() |
| Process yield | Sequential or parallel execution | yield env.process(func()) or yield p1 & p2 |
| Interruption | Preemption, maintenance, cancellation | proc.interrupt(cause) + try/except simpy.Interrupt |
| Timeout racing | Timeout with cancellation | `yield event |
Common Workflows
1. Manufacturing Line Simulation
import simpy
import random
def part(env, name, machines, buffer, stats):
"""Part flows through sequential machines with intermediate buffer."""
for i, machine in enumerate(machines):
with machine.request() as req:
yield req
process_time = random.triangular(1, 3, 2)
yield env.timeout(process_time)
if buffer.level < buffer.capacity:
yield buffer.put(1)
stats['produced'] += 1
def part_generator(env, machines, buffer, stats):
i = 0
while True:
yield env.timeout(random.expovariate(1/2))
env.process(part(env, f'Part-{i}', machines, buffer, stats))
i += 1
random.seed(42)
env = simpy.Environment()
machines = [simpy.Resource(env, capacity=1) for _ in range(3)]
output_buffer = simpy.Container(env, capacity=100, init=0)
stats = {'produced': 0}
env.process(part_generator(env, machines, output_buffer, stats))
env.run(until=480) # 8-hour shift
print(f'Parts produced: {stats["produced"]}')
print(f'Buffer level: {output_buffer.level}')
2. Multi-Server Queue with Priority
import simpy
import random
def patient(env, name, priority, er, stats):
arrival = env.now
with er.request(priority=priority) as req:
yield req
wait = env.now - arrival
stats['waits'].append((name, priority, wait))
service = random.expovariate(1/15) # ~15 min avg
yield env.timeout(service)
def patient_arrivals(env, er, stats):
i = 0
while True:
yield env.timeout(random.expovariate(1/5)) # ~5 min between arrivals
pri = random.choices([1, 2, 3], weights=[0.1, 0.3, 0.6])[0]
env.process(patient(env, f'P{i}', pri, er, stats))
i += 1
random.seed(42)
env = simpy.Environment()
er = simpy.PriorityResource(env, capacity=3)
stats = {'waits': []}
env.process(patient_arrivals(env, er, stats))
env.run(until=480)
# Analyze by priority
for pri in [1, 2, 3]:
waits = [w for _, p, w in stats['waits'] if p == pri]
if waits:
print(f'Priority {pri}: avg wait {sum(waits)/len(waits):.1f}, n={len(waits)}')
3. Producer-Consumer with Monitoring
Text-only workflow (combines Core API modules 2, 3, 4):
- Create
simpy.Storewith bounded capacity (Module 2: Resources) - Implement producer process that
yield store.put(item)with production delay (Module 2) - Implement consumer process that
yield store.get()with processing delay (Module 2) - Add event signaling for backpressure when store full (Module 3: Events)
- Collect throughput, queue length, and idle time statistics (Module 4: Monitoring)
- Run simulation and generate report
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
capacity |
Resource | 1 | 1–∞ | Number of concurrent users |
priority |
PriorityResource.request | 0 | int | Lower = higher priority |
preempt |
PreemptiveResource.request | True | bool | Whether to interrupt lower-priority |
capacity |
Container | float('inf') | 0–∞ | Maximum level |
init |
Container | 0 | 0–capacity | Initial level |
capacity |
Store | float('inf') | 0–∞ | Maximum items |
factor |
RealtimeEnvironment | 1.0 | >0 | Sim-to-wall-clock ratio |
strict |
RealtimeEnvironment | False | bool | Raise error if behind schedule |
initial_time |
Environment | 0 | any float | Simulation start time |
Best Practices
- Always use context managers for resources:
with resource.request() as req: yield reqensures automatic release even on exceptions - Set random seeds for reproducibility:
random.seed(42)before creating processes; usenumpy.randomfor more distributions - Collect statistics inline: Append to lists during simulation, compute aggregates after
env.run()— don't query mid-simulation - Use triangular distribution for process times:
random.triangular(min, max, mode)is more realistic than uniform for service times - Anti-pattern — forgetting yield:
env.timeout(5)withoutyieldcreates the event but doesn't pause the process. Alwaysyield env.timeout(5) - Anti-pattern — reusing events: Events can only be triggered once. Create new
env.event()for each signal cycle; for repeatable signals, create fresh events in a loop
Common Recipes
Recipe: Simulation with Multiple Replications
import simpy
import random
import statistics
def run_single(seed, sim_time=480, n_servers=2):
random.seed(seed)
env = simpy.Environment()
server = simpy.Resource(env, capacity=n_servers)
waits = []
def customer(env, server):
arrival = env.now
with server.request() as req:
yield req
waits.append(env.now - arrival)
yield env.timeout(random.expovariate(1/3))
def gen(env, server):
while True:
yield env.timeout(random.expovariate(1/2))
env.process(customer(env, server))
env.process(gen(env, server))
env.run(until=sim_time)
return sum(waits) / len(waits) if waits else 0
# Run 30 replications
results = [run_single(seed=i) for i in range(30)]
print(f'Mean avg wait: {statistics.mean(results):.2f}')
print(f'95% CI: ±{1.96 * statistics.stdev(results) / len(results)**0.5:.2f}')
Recipe: Interrupt-Based Maintenance
import simpy
import random
def machine(env, name, repair_crew):
while True:
try:
# Operate until failure
ttf = random.expovariate(1/50) # Mean 50 time units to failure
yield env.timeout(ttf)
print(f'{name} failed at {env.now:.1f}')
except simpy.Interrupt:
print(f'{name} interrupted for maintenance at {env.now:.1f}')
# Repair (needs repair crew)
with repair_crew.request() as req:
yield req
repair = random.uniform(2, 5)
yield env.timeout(repair)
print(f'{name} repaired at {env.now:.1f}')
def maintenance_scheduler(env, machines_procs):
"""Periodic preventive maintenance every 40 time units."""
while True:
yield env.timeout(40)
for proc in machines_procs:
if proc.is_alive:
proc.interrupt('scheduled maintenance')
env = simpy.Environment()
repair_crew = simpy.Resource(env, capacity=1)
procs = [env.process(machine(env, f'M{i}', repair_crew)) for i in range(3)]
env.process(maintenance_scheduler(env, procs))
env.run(until=200)
Recipe: Container-Based Supply Chain
import simpy
import random
def supplier(env, warehouse):
"""Deliver batch when level drops below reorder point."""
while True:
if warehouse.level < 20: # Reorder point
yield env.timeout(random.uniform(5, 10)) # Lead time
amount = min(50, warehouse.capacity - warehouse.level)
yield warehouse.put(amount)
print(f'Delivered {amount} units at {env.now:.1f}, level={warehouse.level}')
yield env.timeout(1) # Check interval
def demand(env, warehouse, stats):
while True:
yield env.timeout(random.expovariate(1/2))
qty = random.randint(1, 5)
if warehouse.level >= qty:
yield warehouse.get(qty)
stats['fulfilled'] += qty
else:
stats['stockouts'] += 1
env = simpy.Environment()
warehouse = simpy.Container(env, capacity=100, init=80)
stats = {'fulfilled': 0, 'stockouts': 0}
env.process(supplier(env, warehouse))
env.process(demand(env, warehouse, stats))
env.run(until=500)
print(f'Fulfilled: {stats["fulfilled"]}, Stockouts: {stats["stockouts"]}')
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Process doesn't pause | Missing yield before event |
Always yield env.timeout(x), not just env.timeout(x) |
RuntimeError: Event already triggered |
Reusing a triggered event | Create new env.event() for each signal cycle |
| Resource never released | Not using context manager | Use with resource.request() as req: pattern |
| Simulation runs forever | No until parameter and infinite process |
Add env.run(until=time) or ensure processes terminate |
simpy.Interrupt not caught |
Missing try/except in interruptible process | Wrap yield in try: ... except simpy.Interrupt: |
| Wrong queue order | Using Resource instead of PriorityResource | Switch to simpy.PriorityResource for priority queuing |
| Real-time too slow | Computation exceeds wall-clock budget | Set strict=False or increase factor |
Container put blocks |
Container at capacity | Check container.level < container.capacity before put |
FilterStore get blocks forever |
No matching items | Ensure producers create items matching the filter criteria |
| Statistics are empty | Collecting before env.run() |
Call stats.report() after env.run() completes |
Bundled Resources
references/process_events_guide.md— Detailed event lifecycle (triggered→processed), composite events (AllOf/AnyOf), process interaction patterns (signaling, barriers, interruption, handshake), and advanced synchronization. Consolidated from original events.md (375 lines) + process-interaction.md (425 lines)references/resources_monitoring_guide.md— Complete resource type reference (Resource, Priority, Preemptive, Container, Store, FilterStore, PriorityStore), monitoring via monkey-patching (ResourceMonitor, ContainerMonitor classes), statistical collection patterns, CSV/matplotlib export, and real-time simulation (RealtimeEnvironment, time scaling, strict mode, HIL patterns). Consolidated from original resources.md (276 lines) + monitoring.md (476 lines) + real-time.md (396 lines). Scripts functionality (basic_simulation_template.py, resource_monitor.py) incorporated into Core API monitoring examples and Common Recipes
Related Skills
- matplotlib-scientific-plotting — Visualize simulation results (queue lengths, utilization over time)
- polars-dataframes — Analyze large simulation output datasets
References
- SimPy Documentation: https://simpy.readthedocs.io/
- SimPy GitHub: https://github.com/teamhide/simpy
- Banks et al., "Discrete-Event System Simulation" (textbook reference)
skills/scientific-computing/snakemake-workflow-engine/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill snakemake-workflow-engine -g -y
SKILL.md
Frontmatter
{
"name": "snakemake-workflow-engine",
"license": "MIT",
"description": "Python-based workflow manager for reproducible, scalable pipelines. Define rules with file-based dependencies; Snakemake resolves execution order and parallelism. Runs local, SLURM, LSF, AWS, GCP via profiles; per-rule conda\/Singularity envs. For NGS pipelines, ML training, and multi-step file processing. Use Nextflow for Groovy dataflow or nf-core integration."
}
Snakemake — Python Workflow Engine
Overview
Snakemake is a Python-based workflow management system that scales analyses from laptop to HPC and cloud. Workflows are defined as rules with explicit input/output file dependencies; Snakemake resolves the execution order automatically and runs independent steps in parallel. Rules can call shell commands, Python/R/Julia scripts, or inline Python. Per-rule conda or Singularity environments make workflows fully reproducible. Widely used in bioinformatics for NGS, genome assembly, and variant-calling pipelines.
When to Use
- Building reproducible multi-step bioinformatics pipelines (align → sort → call variants → annotate)
- Scaling the same workflow from local development to SLURM cluster without code changes
- Processing multiple samples identically using wildcard-based rules
- Managing dependencies automatically — only rerun steps whose inputs changed
- Deploying per-rule conda or Singularity environments for tool isolation
- Generating visual DAGs and dry-run previews before committing computational resources
- Use
Nextflowinstead when you need Groovy DSL + dataflow channels, or when leveraging the nf-core community pipeline library - For simple shell loops, use bash scripts; Snakemake is worth the overhead only for 3+ sequential steps with branching
- Use
PrefectorAirflowinstead for data engineering workflows with dynamic task graphs or time-based scheduling
Prerequisites
- Python packages:
snakemake,graphviz(for DAG visualization) - Environment: Python 3.11+; conda/mamba recommended for per-rule environments
- Data requirements: Input files, reference files; output paths defined as rules
Check before installing: The tool may already be available in the current environment (e.g., inside a
pixi/condaenv). Runcommand -v snakemakefirst and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool viapixi run snakemakerather than baresnakemake.
# Install via conda (includes optional dependencies)
conda install -c conda-forge -c bioconda snakemake
# Minimal pip install
pip install snakemake
# Verify
snakemake --version
# 8.x.x
Quick Start
# Snakefile — minimal 2-rule pipeline
SAMPLES = ["sampleA", "sampleB"]
rule all: # Target rule: request final outputs
input:
expand("results/{sample}.sorted.bam", sample=SAMPLES)
rule align:
input:
fastq="data/{sample}.fastq",
ref="refs/genome.fa"
output:
bam="results/{sample}.sorted.bam"
threads: 4
shell:
"bwa mem -t {threads} {input.ref} {input.fastq} "
"| samtools sort -@ {threads} -o {output.bam}"
# Run: dry-run first, then execute
snakemake -n # dry-run: show what would run
snakemake --cores 8 # execute with 8 cores
Core API
Module 1: Rule Definition
Each rule defines one analysis step with inputs, outputs, and an execution method.
# Shell rule: run a command with {input} and {output} placeholders
rule fastqc:
input:
fastq="data/{sample}.fastq"
output:
html="qc/{sample}_fastqc.html",
zip="qc/{sample}_fastqc.zip"
log:
"logs/fastqc/{sample}.log"
shell:
"fastqc {input.fastq} -o qc/ 2> {log}"
# Run rule: inline Python for logic-heavy steps
rule parse_stats:
input:
txt="results/{sample}.flagstat.txt"
output:
csv="results/{sample}.stats.csv"
run:
import re, csv
lines = open(input.txt).readlines()
mapped = re.search(r"(\d+) mapped", "".join(lines)).group(1)
with open(output.csv, "w") as f:
csv.writer(f).writerow([wildcards.sample, mapped])
# Script rule: delegate to external R/Python/Julia script
rule plot_coverage:
input:
depth="results/{sample}.depth.txt"
output:
pdf="results/{sample}.coverage.pdf"
script:
"scripts/plot_coverage.R"
# In the R script, access via snakemake object:
# depth_file <- snakemake@input[["depth"]]
# pdf_path <- snakemake@output[["pdf"]]
Module 2: Wildcards and Pattern Expansion
Wildcards let one rule process any number of samples; expand() generates all required file paths.
# Define sample list (from config or glob)
SAMPLES = ["ctrl_rep1", "ctrl_rep2", "treat_rep1", "treat_rep2"]
rule all:
input:
# expand() generates: qc/ctrl_rep1_fastqc.html, qc/ctrl_rep2_fastqc.html, ...
expand("qc/{sample}_fastqc.html", sample=SAMPLES),
expand("results/{sample}.bam", sample=SAMPLES)
# Access wildcard values inside shell/run
rule align:
input:
"data/{sample}.fastq"
output:
"results/{sample}.bam"
shell:
"echo Processing {wildcards.sample}; "
"bwa mem refs/genome.fa {input} | samtools view -b > {output}"
# Wildcard constraints prevent ambiguous matches
rule process:
input:
"data/{sample}_{rep}.fastq"
output:
"results/{sample}_{rep}.txt"
wildcard_constraints:
sample="[A-Za-z]+", # letters only
rep="\d+" # digits only
# multiext: multiple outputs sharing a common path base
rule bwa_index:
input:
"refs/genome.fa"
output:
multiext("refs/genome.fa", ".amb", ".ann", ".bwt", ".pac", ".sa")
shell:
"bwa index {input}"
Module 3: Configuration and Parameters
Config files externalize settings; params passes rule-level values without file dependencies.
# Snakefile: declare config file
configfile: "config/config.yaml"
# config/config.yaml:
# samples: [ctrl, treat]
# threads:
# align: 8
# sort: 4
# min_mapq: 20
SAMPLES = config["samples"]
rule filter_reads:
input:
"results/{sample}.bam"
output:
"results/{sample}.filtered.bam"
params:
mapq=config["min_mapq"] # from config, not a file
threads:
config["threads"]["sort"]
shell:
"samtools view -q {params.mapq} -b {input} > {output}"
# Dynamic params via lambda functions
rule trim:
input:
fastq="data/{sample}.fastq"
output:
trimmed="trimmed/{sample}.fastq"
params:
# Adapt quality threshold based on sample name
quality=lambda wildcards: 25 if "ctrl" in wildcards.sample else 20
shell:
"fastp -q {params.quality} -i {input.fastq} -o {output.trimmed}"
Module 4: Resources and Environments
Declare computational resources for scheduler integration; use conda/Singularity for tool isolation.
# Resource declaration (used by SLURM/LSF profiles)
rule variant_calling:
input:
bam="results/{sample}.deduped.bam",
ref="refs/genome.fa"
output:
vcf="variants/{sample}.vcf.gz"
resources:
mem_mb=16000, # memory in MB
runtime=240, # max walltime in minutes
disk_mb=20000 # scratch disk space
threads: 8
shell:
"bcftools mpileup -f {input.ref} {input.bam} "
"| bcftools call -m -Oz -o {output.vcf}"
# Conda environment per rule (for reproducibility)
rule star_align:
input:
reads="data/{sample}.fastq",
genome_dir="refs/star_index/"
output:
bam="star_out/{sample}/Aligned.sortedByCoord.out.bam"
conda:
"envs/star.yaml"
# envs/star.yaml:
# channels:
# - bioconda
# dependencies:
# - star=2.7.10b
# - samtools=1.17
threads: 8
shell:
"STAR --runThreadN {threads} --genomeDir {input.genome_dir} "
"--readFilesIn {input.reads} --outSAMtype BAM SortedByCoordinate"
# Singularity/Apptainer container
rule gatk_haplotypecaller:
input:
bam="results/{sample}.bam",
ref="refs/genome.fa"
output:
gvcf="gvcfs/{sample}.g.vcf.gz"
container:
"docker://broadinstitute/gatk:4.4.0.0"
shell:
"gatk HaplotypeCaller -I {input.bam} -R {input.ref} "
"-O {output.gvcf} -ERC GVCF"
Module 5: Execution and Cluster Profiles
Execute locally, on clusters, or in cloud; profiles configure executors without changing the Snakefile.
# Local execution
snakemake --cores 8 # use 8 CPU cores
snakemake --cores all # use all available cores
# Dry run: show tasks without executing
snakemake -n --cores 8
# Output: 12 of 24 steps are complete. 12 jobs to run.
# Force rerun (ignore existing outputs)
snakemake --forceall --cores 8
# Visualize DAG as PDF
snakemake --dag | dot -Tpdf > workflow_dag.pdf
# SLURM cluster profile (profiles/slurm/config.yaml)
# executor: slurm
# jobs: 50
# default-resources:
# mem_mb: 2000
# runtime: 60
# use-conda: true
# Run with profile (cluster submit + monitor)
snakemake --profile profiles/slurm --cores 128
# Override resources at runtime
snakemake --profile profiles/slurm \
--set-resources variant_calling:mem_mb=32000 --cores 128
# Override threads
snakemake --set-threads align=16 --cores 64
Module 6: Special Output Types and Utilities
Handle temporary files, protected outputs, checkpoints, and output validation.
# temp: auto-delete after downstream rules consume it
rule sort_bam:
input:
"results/{sample}.raw.bam"
output:
temp("results/{sample}.sorted_temp.bam") # deleted after indexing
shell:
"samtools sort {input} -o {output}"
# protected: write-protect final outputs (prevent overwrite)
rule final_report:
input:
"results/{sample}.vcf.gz"
output:
protected("reports/{sample}.final.vcf.gz")
shell:
"cp {input} {output}"
# directory: rule that outputs a directory
rule denovo_assembly:
input:
fastq="data/{sample}.fastq"
output:
directory("assemblies/{sample}/")
shell:
"spades.py -s {input.fastq} -o {output}"
# touch: create empty flag file (for ordering-only dependencies)
rule validate_bam:
input:
"results/{sample}.bam"
output:
touch("checkpoints/{sample}.validated")
shell:
"samtools quickcheck {input} && echo OK"
# ensure: validate output properties before considering rule complete
rule download_reference:
output:
ensure("refs/genome.fa", min_size=1_000_000)
shell:
"wget -O {output} https://example.com/genome.fa"
Key Concepts
Rule Resolution and DAG
Snakemake works backward from targets: given a list of desired output files, it builds a DAG of rules needed to produce them. Rules not needed for the current targets are ignored.
# rule all: declare all final outputs here
# Without this, snakemake runs only the first rule
rule all:
input:
expand("results/{sample}.vcf.gz", sample=SAMPLES),
expand("qc/{sample}_fastqc.html", sample=SAMPLES)
Wildcards vs Expand
{sample}in rule input/output = wildcard: filled by Snakemake at execution timeexpand("results/{sample}.bam", sample=SAMPLES)= Python: generates a list of strings NOW (used inrule all)
Common Workflows
Workflow 1: Standard NGS QC Pipeline
Goal: FastQC → trim → align → sort → dedup → flagstat for multiple samples.
configfile: "config/config.yaml"
SAMPLES = config["samples"]
rule all:
input:
expand("qc/{sample}_fastqc.html", sample=SAMPLES),
expand("results/{sample}.flagstat.txt", sample=SAMPLES)
rule fastqc:
input: "data/{sample}.fastq"
output: "qc/{sample}_fastqc.html", "qc/{sample}_fastqc.zip"
shell: "fastqc {input} -o qc/"
rule trim:
input: "data/{sample}.fastq"
output: "trimmed/{sample}.fastq"
shell: "fastp -q 20 -i {input} -o {output}"
rule align:
input:
fastq="trimmed/{sample}.fastq",
ref="refs/genome.fa"
output: temp("results/{sample}.raw.bam")
threads: 8
shell:
"bwa mem -t {threads} {input.ref} {input.fastq} | samtools view -b > {output}"
rule sort_dedup:
input: "results/{sample}.raw.bam"
output:
bam="results/{sample}.bam",
bai="results/{sample}.bam.bai"
threads: 4
shell:
"samtools sort -@ {threads} {input} | samtools markdup -r - {output.bam} "
"&& samtools index {output.bam}"
rule flagstat:
input: "results/{sample}.bam"
output: "results/{sample}.flagstat.txt"
shell: "samtools flagstat {input} > {output}"
Workflow 2: Running on a SLURM Cluster
Goal: Deploy the same Snakefile to HPC with per-job resource allocation.
# 1. Create profiles/slurm/config.yaml
mkdir -p profiles/slurm
cat > profiles/slurm/config.yaml << 'EOF'
executor: slurm
jobs: 100
default-resources:
mem_mb: 4000
runtime: 60
use-conda: true
latency-wait: 30
rerun-incomplete: true
EOF
# 2. Add resources to compute-heavy rules in Snakefile
# resources:
# mem_mb=16000, runtime=120
# 3. Submit
snakemake --profile profiles/slurm --cores 256 -n # dry-run
snakemake --profile profiles/slurm --cores 256 # submit
# 4. Monitor
snakemake --profile profiles/slurm --report report.html # after completion
Key Parameters
| Parameter | Context | Default | Range/Options | Effect |
|---|---|---|---|---|
--cores |
CLI | 1 | 1–N or all |
Max concurrent jobs/threads |
threads: |
Rule | 1 | 1–N | Threads per rule (scales to --cores) |
mem_mb: |
resources: |
None | integer | Memory in MB (used by SLURM profile) |
runtime: |
resources: |
None | integer (min) | Max walltime per job |
--profile |
CLI | None | path | YAML profile for executor config |
--use-conda |
CLI | False | flag | Activate per-rule conda environments |
--use-apptainer |
CLI | False | flag | Enable Singularity/Apptainer containers |
-n |
CLI | False | flag | Dry-run (show tasks, don't execute) |
--forceall |
CLI | False | flag | Rerun all rules regardless of status |
--rerun-incomplete |
CLI | False | flag | Rerun rules with partial outputs |
configfile: |
Snakefile | None | YAML path | Load config dictionary from YAML |
Best Practices
-
Always define
rule all: Without it, only the first rule in the Snakefile runs.rule allcollects all final outputs; Snakemake runs everything needed to produce them. -
Use
temp()for large intermediates: BAM files before deduplication, unsorted BAMs, and intermediate assemblies can be markedtemp()to auto-delete after consumption — saves significant disk. -
Separate config from code: Put sample lists, thread counts, file paths, and thresholds in
config.yaml. Hard-coded values in Snakefiles make pipelines brittle and non-reusable. -
Test with
snakemake -nfirst: The dry-run shows exactly which rules will run and in what order. Run it before every production execution to confirm the DAG is correct. -
Use
log:for every shell rule: Redirect tool stderr/stdout to per-rule log files (2> {log}). Without logs, debugging cluster job failures is nearly impossible. -
Benchmark rules in production: Add
benchmark: "benchmarks/{rule}/{sample}.txt"to measure actual runtime and memory — essential data for tuning SLURM resource requests.
Common Recipes
Recipe: Generate Sample List from Files
# Auto-discover samples from input directory (no hardcoded list)
from pathlib import Path
SAMPLES = [p.stem.replace(".fastq", "") for p in Path("data/").glob("*.fastq")]
print(f"Found {len(SAMPLES)} samples: {SAMPLES[:3]}...")
rule all:
input:
expand("results/{sample}.bam", sample=SAMPLES)
Recipe: Conditional Execution Based on Config
configfile: "config/config.yaml"
# Only run deduplication for WGS (not amplicon) data
rule dedup:
input:
"results/{sample}.sorted.bam"
output:
"results/{sample}.deduped.bam"
run:
if config.get("assay_type") == "WGS":
shell("samtools markdup -r {input} {output}")
else:
shell("cp {input} {output}")
Recipe: Aggregate Multiple Samples
# Collect all per-sample stats into one summary table
rule multiqc:
input:
expand("qc/{sample}_fastqc.zip", sample=SAMPLES),
expand("results/{sample}.flagstat.txt", sample=SAMPLES)
output:
"multiqc/multiqc_report.html"
shell:
"multiqc qc/ results/ -o multiqc/"
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
AmbiguousRuleException |
Multiple rules match same output | Add wildcard_constraints:, use ruleorder rule_a > rule_b, or rename outputs |
MissingOutputException |
Rule completed but output file absent | Check working directory in shell; verify output path; check disk space |
TargetFileException |
rule all requests a file no rule can produce |
Verify expand() args match wildcard names; use -n to trace resolution |
| Cluster jobs all fail | Resources too low for tool | Increase mem_mb or runtime; check cluster queue with squeue |
| Conda env build fails | Package conflict or wrong channel | Add conda-forge before bioconda; pin package versions |
| Rule reruns unexpectedly | Output file timestamp older than input | Touch output files with snakemake --touch; or delete and rerun |
PermissionError on protected output |
protected() wrapper applied |
Remove protection with --force; or delete and regenerate without protected() |
Related Skills
- samtools-bam-processing — BAM sorting and indexing rules commonly used in Snakemake pipelines
- bedtools-genomic-intervals — interval operations in downstream annotation rules
- neuropixels-analysis — example of a complex multi-step pipeline that benefits from Snakemake
References
- Snakemake documentation — rules, wildcards, profiles, API reference
- Snakemake GitHub — source, releases, issue tracker
- Mölder et al. (2021) "Sustainable data analysis with Snakemake" — F1000Research 10:33
- Snakemake workflow catalog — community-maintained reference pipelines
skills/scientific-computing/spikeinterface-electrophysiology/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill spikeinterface-electrophysiology -g -y
SKILL.md
Frontmatter
{
"name": "spikeinterface-electrophysiology",
"license": "MIT",
"description": "Unified Python framework for extracellular electrophysiology. Load 20+ formats (SpikeGLX, OpenEphys, NWB, Intan, Maxwell, Blackrock), preprocess, run 10+ sorters (Kilosort4, SpykingCircus2, Tridesclous, MountainSort5) via one API, compute quality metrics (SNR, ISI, firing rate), compare sorters, export NWB\/Phy. For format-agnostic multi-sorter workflows. For Neuropixels-specific PSTH\/decoding use neuropixels."
}
SpikeInterface — Unified Extracellular Electrophysiology Framework
Overview
SpikeInterface provides a common Python API to read extracellular recordings from 20+ file formats, preprocess raw voltage traces, run 10+ spike sorters, postprocess and quality-control sorted units, and export results — all without format-specific code. Its modular design lets users swap sorters, formats, and preprocessing steps without rewriting pipelines. SpikeInterface is built around lazy, chainable objects: a Recording holds raw data, a Sorting holds spike times, and a SortingAnalyzer ties them together for waveform and metric computation.
When to Use
- Loading recordings from multiple acquisition systems (SpikeGLX, OpenEphys, Intan, NWB, Maxwell MEA, Blackrock) with a unified API rather than format-specific parsers
- Running the same preprocessing and sorting pipeline across experiments recorded on different hardware
- Comparing two or more spike sorters on the same recording to assess agreement and choose the best output
- Running containerized sorters (Kilosort, IronClust, MountainSort5) via Docker or Singularity without local installation
- Computing standard quality metrics (SNR, ISI violations, firing rate, presence ratio, amplitude cutoff) and applying threshold-based curation
- Validating spike-sorting accuracy against synthetic or hybrid ground-truth recordings
- Exporting sorted results to NWB for data sharing or to Phy for manual curation
- Use
neuropixels-analysisinstead for a complete Neuropixels-specific Kilosort4 workflow including PSTH computation, tuning curves, and population decoding - For EEG, ECG, or other biosignal processing (not spike sorting), use
neurokit2instead
Prerequisites
- Python packages:
spikeinterface[full]>=0.101,probeinterface,numpy,matplotlib - Optional sorter deps:
kilosort(pip), or Docker/Singularity for containerized sorters - Data requirements: raw binary recording files plus probe geometry (
.prb,.json, or auto-detected from format) - Hardware: GPU required for Kilosort4; all other sorters run on CPU
pip install "spikeinterface[full]>=0.101" probeinterface
# Optional: Kilosort4 Python package
pip install kilosort
# Optional: Phy for manual curation
pip install phy
Quick Start
import spikeinterface.full as si
import spikeinterface.preprocessing as spre
import spikeinterface.sorters as ss
import spikeinterface.qualitymetrics as sqm
# Load, preprocess, sort, and inspect quality metrics in 10 lines
recording = si.read_openephys("/data/session_001", stream_name="Signals CH")
recording_pp = spre.bandpass_filter(
spre.common_reference(recording, reference="global", operator="median"),
freq_min=300, freq_max=6000,
)
sorting = ss.run_sorter("spykingcircus2", recording_pp, output_folder="./sc2_out")
analyzer = si.create_sorting_analyzer(sorting, recording_pp, folder="./analyzer")
analyzer.compute(["random_spikes", "waveforms", "templates", "noise_levels"])
metrics = sqm.compute_quality_metrics(analyzer, metric_names=["snr", "firing_rate", "isi_violation"])
print(metrics.describe())
Core API
Module 1: Recording I/O
SpikeInterface wraps every acquisition format behind a common BaseRecording interface. Once loaded, all objects expose the same methods regardless of origin format.
import spikeinterface.full as si
# SpikeGLX (.bin + .meta)
recording_sglx = si.read_spikeglx("/data/session_001", stream_name="imec0.ap")
# OpenEphys (binary or classic)
recording_oe = si.read_openephys("/data/oe_session", stream_name="Signals CH")
# NWB file
recording_nwb = si.read_nwb_recording("/data/recording.nwb",
electrical_series_name="ElectricalSeries")
# Intan RHD/RHS
recording_intan = si.read_intan("/data/session.rhd", stream_name="RHn")
# Inspect any recording with the same API
print(f"Format: {type(recording_sglx).__name__}")
print(f"Channels: {recording_sglx.get_num_channels()}")
print(f"Sampling rate:{recording_sglx.get_sampling_frequency()} Hz")
print(f"Duration: {recording_sglx.get_total_duration():.1f} s")
print(f"Probe: {recording_sglx.get_probe().name}")
# List available streams before loading (useful when a file has multiple streams)
streams = si.get_neo_streams("spikeglx", "/data/session_001")
print("Available streams:", streams)
# e.g. ['imec0.ap', 'imec0.lf', 'nidq']
# Select a time slice (lazy, no data loaded until get_traces() is called)
recording_slice = recording_sglx.frame_slice(
start_frame=0,
end_frame=int(60 * recording_sglx.get_sampling_frequency()), # first 60 s
)
print(f"Sliced duration: {recording_slice.get_total_duration():.1f} s")
Module 2: Preprocessing
Preprocessing functions return new Recording objects wrapping the original; the chain is applied lazily when data is read. This keeps memory usage low even for multi-hour recordings.
import spikeinterface.preprocessing as spre
# 1. Common median reference — removes shared noise across all channels
recording_cmr = spre.common_reference(recording_sglx,
reference="global",
operator="median")
# 2. Bandpass filter for action potentials (300–6000 Hz typical)
recording_filt = spre.bandpass_filter(recording_cmr,
freq_min=300,
freq_max=6000)
# 3. Remove bad channels automatically (coherence-based detection)
recording_clean, removed_ids = spre.remove_bad_channels(recording_filt,
method="coherence+psd")
print(f"Removed {len(removed_ids)} bad channels: {removed_ids}")
print(f"Clean channels: {recording_clean.get_num_channels()}")
# Whitening — decorrelates channels; recommended before template-matching sorters
recording_white = spre.whiten(recording_clean, mode="local")
# Phase shift correction for Neuropixels (samples acquired with small time offsets)
recording_shifted = spre.phase_shift(recording_clean)
# Inspect a short snippet of preprocessed data
traces = recording_white.get_traces(start_frame=0, end_frame=3000, segment_index=0)
print(f"Trace snippet shape: {traces.shape}") # (3000, n_channels)
print(f"Trace range: [{traces.min():.2f}, {traces.max():.2f}] µV")
Module 3: Spike Sorting
ss.run_sorter() wraps every supported sorter behind a uniform call signature. Sorter-specific parameters are passed as keyword arguments; all other pipeline steps are identical.
import spikeinterface.sorters as ss
from pathlib import Path
# List all sorters available in the current environment
available = ss.available_sorters()
print("Available sorters:", available)
# List sorters that can run without local installation (via container)
installed = ss.installed_sorters()
print("Installed locally:", installed)
# Run SpykingCircus2 (CPU, no external deps)
sorting_sc2 = ss.run_sorter(
"spykingcircus2",
recording_clean,
output_folder=Path("./sorter_output/sc2"),
remove_existing_folder=True,
verbose=True,
)
print(f"SpykingCircus2 units: {len(sorting_sc2.get_unit_ids())}")
# Run Kilosort4 via Docker container (no local GPU/MATLAB required)
sorting_ks4 = ss.run_sorter(
"kilosort4",
recording_clean,
output_folder=Path("./sorter_output/ks4"),
singularity_image=False, # use Docker; set True for Singularity
docker_image=True,
remove_existing_folder=True,
# Kilosort4-specific parameters
nblocks=5,
Th_learned=8,
do_correction=True,
)
print(f"Kilosort4 units: {len(sorting_ks4.get_unit_ids())}")
# Run MountainSort5 (CPU, fast, good for tetrode/low-channel-count probes)
sorting_ms5 = ss.run_sorter(
"mountainsort5",
recording_clean,
output_folder=Path("./sorter_output/ms5"),
scheme="2", # scheme 2 is recommended for high-density probes
detect_threshold=5.5,
)
print(f"MountainSort5 units: {len(sorting_ms5.get_unit_ids())}")
Module 4: Postprocessing (SortingAnalyzer)
SortingAnalyzer is the central postprocessing object in SpikeInterface >= 0.101. It replaces the older WaveformExtractor and provides a unified interface for waveforms, templates, PCAs, and downstream metrics.
import spikeinterface.full as si
import spikeinterface.postprocessing as spost
# Create analyzer (saves to disk; use format="memory" for in-RAM only)
analyzer = si.create_sorting_analyzer(
sorting_sc2,
recording_clean,
folder="./analyzer_sc2",
format="binary_folder",
overwrite=True,
sparse=True, # sparse=True: only nearby channels per unit
ms_before=1.0,
ms_after=2.0,
)
# Compute extensions in dependency order
analyzer.compute([
"random_spikes", # subsample spike indices for waveform extraction
"waveforms", # raw waveform snippets per unit
"templates", # mean/std template per unit
"noise_levels", # per-channel noise estimate
])
# Retrieve templates
templates = analyzer.get_extension("templates").get_data(outputs="Templates")
print(f"Templates object: {templates}")
print(f"Unit 0 template shape: {templates.get_one_template_dense(0).shape}")
# (n_samples, n_channels)
# Compute amplitude and PCA extensions (needed for quality metrics)
analyzer.compute([
"spike_amplitudes", # amplitude at peak channel per spike
"principal_components", # PCA scores (n_components x n_spikes)
"template_similarity", # pairwise template correlation matrix
"correlograms", # auto- and cross-correlograms
"unit_locations", # estimated unit position on probe (center of mass)
])
# Access spike amplitudes for first unit
ext_amp = analyzer.get_extension("spike_amplitudes")
unit_ids = analyzer.unit_ids
amps = ext_amp.get_data()[analyzer.sorting.ids_to_indices([unit_ids[0]])]
print(f"Unit {unit_ids[0]} — median amplitude: {abs(amps).median():.1f} µV")
Module 5: Quality Metrics
Quality metrics summarize unit isolation quality. Metrics requiring only spike times (ISI violations, firing rate) are fast; metrics requiring waveforms (SNR, amplitude cutoff) need the SortingAnalyzer to be populated first.
import spikeinterface.qualitymetrics as sqm
# Compute a standard panel of quality metrics
metrics = sqm.compute_quality_metrics(
analyzer,
metric_names=[
"snr", # signal-to-noise ratio of template peak
"isi_violation", # fraction of ISIs < refractory period
"firing_rate", # mean firing rate (Hz) over recording
"presence_ratio", # fraction of time windows with ≥1 spike
"amplitude_cutoff", # estimated fraction of spikes below threshold
"nearest_neighbor", # isolation distance in PCA space
"silhouette_score", # cluster separation in PCA space
],
)
print(metrics.head())
print(f"\nShape: {metrics.shape}") # (n_units, n_metrics)
import pandas as pd
# Apply threshold-based curation (Allen Brain Institute defaults)
thresholds = {
"snr": (">=", 5.0),
"isi_violations_ratio": ("<=", 0.1),
"firing_rate": (">=", 0.1),
"presence_ratio": (">=", 0.9),
"amplitude_cutoff": ("<=", 0.1),
}
keep = pd.Series(True, index=metrics.index)
for col, (op, val) in thresholds.items():
if col not in metrics.columns:
continue
if op == ">=":
keep &= metrics[col] >= val
else:
keep &= metrics[col] <= val
good_unit_ids = metrics[keep].index.tolist()
print(f"Total units: {len(metrics)}")
print(f"Curated units: {len(good_unit_ids)} ({100*len(good_unit_ids)/len(metrics):.0f}%)")
# Filter analyzer to good units
sorting_curated = sorting_sc2.select_units(good_unit_ids)
Module 6: Comparison and Export
Compare sorters against each other or against ground truth, then export results in shareable formats.
import spikeinterface.comparison as sc
# Compare two sorters — matches units by spike train overlap
comparison = sc.compare_two_sorters(
sorting_sc2,
sorting_ks4,
sorting1_name="SpykingCircus2",
sorting2_name="Kilosort4",
match_score=0.5, # minimum overlap to count as a match
delta_time=0.4, # coincidence window (ms)
)
# Performance summary per matched unit pair
perf = comparison.get_performance(method="by_unit")
print(perf.head(10))
# Columns: accuracy, recall, precision, false_discovery_rate, miss_rate
# Agreement score matrix (fraction overlap between all unit pairs)
agreement_matrix = comparison.get_agreement_fraction_table()
print(f"Agreement matrix shape: {agreement_matrix.shape}")
import spikeinterface.exporters as sexp
# Export curated sorting to NWB (Neurodata Without Borders)
sexp.export_to_nwb(
sorting_curated,
nwb_file_path="./session_sorted.nwb",
overwrite=True,
)
print("Exported to NWB: session_sorted.nwb")
# Export to Phy for manual curation
sexp.export_to_phy(
analyzer,
output_folder="./phy_export",
compute_pc_features=True,
copy_binary=True,
remove_if_exists=True,
)
print("Phy export ready at: ./phy_export")
print("Launch Phy with: phy template-gui phy_export/params.py")
Common Workflows
Workflow 1: Multi-Sorter Comparison on OpenEphys Data
Goal: Load an OpenEphys recording, preprocess, run two sorters, compare their agreement, curate the higher-yield output, and export to NWB.
import spikeinterface.full as si
import spikeinterface.preprocessing as spre
import spikeinterface.sorters as ss
import spikeinterface.comparison as sc
import spikeinterface.qualitymetrics as sqm
import spikeinterface.exporters as sexp
from pathlib import Path
# --- Step 1: Load ---
data_dir = Path("/data/oe_recording")
streams = si.get_neo_streams("openephys", data_dir)
print("Streams:", streams)
recording = si.read_openephys(data_dir, stream_name="Signals CH")
print(f"Loaded: {recording.get_num_channels()} ch, "
f"{recording.get_sampling_frequency()} Hz, "
f"{recording.get_total_duration():.1f} s")
# --- Step 2: Preprocess ---
rec = spre.bandpass_filter(recording, freq_min=300, freq_max=6000)
rec = spre.common_reference(rec, reference="global", operator="median")
rec, bad_ids = spre.remove_bad_channels(rec, method="coherence+psd")
print(f"Preprocessing complete. Removed channels: {bad_ids}")
# --- Step 3: Run two sorters ---
out = Path("./sorting_outputs")
sorting_sc2 = ss.run_sorter("spykingcircus2", rec,
output_folder=out / "sc2",
remove_existing_folder=True)
sorting_tdc = ss.run_sorter("tridesclous2", rec,
output_folder=out / "tdc",
remove_existing_folder=True)
print(f"SC2 units: {len(sorting_sc2.unit_ids)}, "
f"TDC units: {len(sorting_tdc.unit_ids)}")
# --- Step 4: Compare ---
cmp = sc.compare_two_sorters(sorting_sc2, sorting_tdc,
sorting1_name="SC2",
sorting2_name="Tridesclous2",
match_score=0.5)
perf = cmp.get_performance(method="pooled_with_average")
print(f"\nAgreement performance:\n{perf}")
# --- Step 5: Quality metrics on SC2 (higher yield) ---
analyzer = si.create_sorting_analyzer(sorting_sc2, rec,
folder="./analyzer_sc2",
overwrite=True, sparse=True)
analyzer.compute(["random_spikes", "waveforms", "templates",
"noise_levels", "spike_amplitudes"])
metrics = sqm.compute_quality_metrics(
analyzer,
metric_names=["snr", "firing_rate", "isi_violation",
"presence_ratio", "amplitude_cutoff"],
)
keep = (metrics["snr"] >= 5) & (metrics["isi_violations_ratio"] <= 0.1) \
& (metrics["firing_rate"] >= 0.1) & (metrics["presence_ratio"] >= 0.9)
sorting_curated = sorting_sc2.select_units(metrics[keep].index.tolist())
print(f"\nCurated: {len(sorting_curated.unit_ids)} / {len(sorting_sc2.unit_ids)} units")
# --- Step 6: Export winner to NWB ---
sexp.export_to_nwb(sorting_curated,
nwb_file_path="./session_sorted.nwb",
overwrite=True)
print("Saved: session_sorted.nwb")
Workflow 2: Ground Truth Validation with Synthetic Recordings
Goal: Generate a synthetic recording with known spike trains, run a sorter, and measure true accuracy (recall, precision) against ground truth — for benchmarking sorters or testing preprocessing pipelines.
import spikeinterface.full as si
import spikeinterface.preprocessing as spre
import spikeinterface.sorters as ss
import spikeinterface.comparison as sc
import numpy as np
# --- Step 1: Generate ground-truth synthetic recording ---
# Uses a Marsaglia noise model with realistic waveform templates
recording_gt, sorting_gt = si.generate_ground_truth_recording(
durations=[120.0], # 120 s recording
sampling_frequency=30000.0,
num_channels=32,
num_units=10,
noise_kwargs={"noise_level": 10.0, "dtype": "float32"},
seed=42,
)
print(f"GT recording: {recording_gt.get_num_channels()} ch, "
f"{recording_gt.get_total_duration():.0f} s")
print(f"GT units: {len(sorting_gt.unit_ids)}")
print(f"GT firing rates: "
f"{[round(len(sorting_gt.get_unit_spike_train(u, 0))/120, 1) for u in sorting_gt.unit_ids]} Hz")
# --- Step 2: Preprocess ---
rec_pp = spre.bandpass_filter(recording_gt, freq_min=300, freq_max=6000)
rec_pp = spre.common_reference(rec_pp, reference="global", operator="median")
# --- Step 3: Sort with two sorters ---
sorting_sc2 = ss.run_sorter("spykingcircus2", rec_pp,
output_folder="./gt_sc2",
remove_existing_folder=True)
sorting_ms5 = ss.run_sorter("mountainsort5", rec_pp,
output_folder="./gt_ms5",
remove_existing_folder=True,
scheme="2")
# --- Step 4: Compare each sorter against ground truth ---
for name, sorting_test in [("SC2", sorting_sc2), ("MS5", sorting_ms5)]:
cmp = sc.compare_sorter_to_ground_truth(sorting_gt, sorting_test,
exhaustive_gt=True)
perf = cmp.get_performance(method="pooled_with_average")
print(f"\n{name} vs Ground Truth:")
print(f" Accuracy: {perf['accuracy']:.3f}")
print(f" Recall: {perf['recall']:.3f}")
print(f" Precision: {perf['precision']:.3f}")
print(f" Well-detected units: {cmp.get_well_detected_units(well_detected_score=0.8)}")
Workflow 3: Batch Processing Multiple Sessions
Goal: Apply the same preprocessing + sorting pipeline to multiple recording sessions and collect quality metrics across all sessions.
import spikeinterface.full as si
import spikeinterface.preprocessing as spre
import spikeinterface.sorters as ss
import spikeinterface.qualitymetrics as sqm
import pandas as pd
from pathlib import Path
sessions = list(Path("/data/experiment").glob("session_*/"))
all_metrics = []
for session_dir in sessions:
print(f"Processing {session_dir.name} ...")
try:
streams = si.get_neo_streams("spikeglx", session_dir)
ap_stream = [s for s in streams if "ap" in s][0]
rec = si.read_spikeglx(session_dir, stream_name=ap_stream)
# Preprocess
rec = spre.bandpass_filter(
spre.common_reference(rec, reference="global", operator="median"),
freq_min=300, freq_max=6000,
)
rec, _ = spre.remove_bad_channels(rec)
# Sort
out_dir = session_dir / "sorting"
sorting = ss.run_sorter("spykingcircus2", rec,
output_folder=out_dir,
remove_existing_folder=True)
# Compute metrics
analyzer = si.create_sorting_analyzer(
sorting, rec, folder=session_dir / "analyzer", overwrite=True, sparse=True
)
analyzer.compute(["random_spikes", "waveforms", "templates",
"noise_levels", "spike_amplitudes"])
m = sqm.compute_quality_metrics(
analyzer, metric_names=["snr", "firing_rate", "isi_violation"]
)
m["session"] = session_dir.name
all_metrics.append(m)
except Exception as e:
print(f" FAILED: {e}")
continue
# Combine across sessions
combined = pd.concat(all_metrics)
combined.to_csv("all_sessions_metrics.csv")
print(f"\nSaved metrics: {combined.shape[0]} units across {len(all_metrics)} sessions")
print(combined.groupby("session")[["snr", "firing_rate"]].median())
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
freq_min / freq_max |
spre.bandpass_filter |
300 / 6000 Hz | 150–500 / 3000–10000 Hz | Spike band; use 300–6000 Hz for AP activity |
reference |
spre.common_reference |
"global" |
"global", "local", "single" |
Channel subset used for median reference subtraction |
method |
spre.remove_bad_channels |
"coherence+psd" |
"coherence+psd", "std", "mad" |
Algorithm for bad channel detection |
scheme |
ss.run_sorter("mountainsort5") |
"2" |
"1", "2", "3" |
Sorting scheme; scheme 2 recommended for high-density probes |
nblocks |
ss.run_sorter("kilosort4") |
5 |
0–10 |
Number of drift correction blocks; 0 disables drift correction |
Th_learned |
ss.run_sorter("kilosort4") |
8 |
6–12 |
Detection threshold (× noise); lower = more units, more noise |
match_score |
sc.compare_two_sorters |
0.5 |
0.1–0.9 |
Minimum spike-train overlap to declare a unit match |
sparse |
si.create_sorting_analyzer |
True |
True, False |
Limit waveform extraction to channels near each unit; reduces memory |
ms_before / ms_after |
si.create_sorting_analyzer |
1.0 / 2.0 ms |
0.5–2.0 / 1.0–3.0 ms | Waveform snippet window relative to detected spike peak |
snr threshold |
sqm.compute_quality_metrics |
— | 5–10 recommended | Amplitude / noise ratio; > 5 indicates well-isolated unit |
isi_violations_ratio |
sqm.compute_quality_metrics |
— | ≤ 0.1 recommended | Fraction of ISIs < refractory period (1.5 ms); < 0.1 = single unit |
presence_ratio |
sqm.compute_quality_metrics |
— | ≥ 0.9 recommended | Fraction of recording epochs where unit fires; < 0.9 = drifting unit |
Best Practices
-
Always inspect available streams before loading: Different acquisition systems save AP data, LFP data, and auxiliary channels as separate streams. Loading the wrong stream silently yields valid-looking but incorrect data.
streams = si.get_neo_streams("spikeglx", data_dir) print(streams) # e.g. ['imec0.ap', 'imec0.lf', 'nidq'] recording = si.read_spikeglx(data_dir, stream_name="imec0.ap") -
Chain preprocessing lazily; do not load to memory early: Preprocessing objects are lazy and apply transformations at read time. Calling
get_traces()on the raw recording before preprocessing will load unfiltered data into RAM unnecessarily. Build the full chain before any data access. -
Use
sparse=Truewhen creating a SortingAnalyzer: For high-channel-count probes (64–384 channels), dense waveform extraction is 10–50× more expensive in RAM and disk than sparse. Sparse mode extracts waveforms only on the channels nearest each unit. -
Run containerized sorters to avoid dependency conflicts: Kilosort2/3 (MATLAB), IronClust, and other sorters have complex dependencies. Use
docker_image=Trueinrun_sorter()to pull the official container and run the sorter in isolation:sorting = ss.run_sorter("kilosort2_5", recording_clean, output_folder="./ks25_out", docker_image=True) -
Compute metrics extensions in dependency order: Extensions depend on each other. The canonical order is:
random_spikes→waveforms→templates→noise_levels→spike_amplitudes→principal_components. Skipping an earlier step causes aMissingExtensionErrorwhen a downstream step is requested. -
Save the SortingAnalyzer to disk for large recordings: In-memory analyzers (
format="memory") are lost when the process exits. For recordings longer than 30 minutes or with many units, always specify afolderpath so the analyzer can be reloaded:analyzer = si.load_sorting_analyzer("./analyzer_sc2") -
Do not compare sorters with mismatched preprocessing: When benchmarking sorters, run all of them on the same preprocessed
recording_cleanobject. Running sorters on different preprocessing chains invalidates the comparison.
Common Recipes
Recipe: Load and Inspect a Multi-Stream Recording
When to use: Quickly check what streams are available in an unfamiliar recording and confirm channel counts and duration before committing to a full sort.
import spikeinterface.full as si
data_dir = "/data/recording_session"
# Try SpikeGLX first; if it fails, try OpenEphys
try:
streams = si.get_neo_streams("spikeglx", data_dir)
fmt = "spikeglx"
except Exception:
streams = si.get_neo_streams("openephys", data_dir)
fmt = "openephys"
print(f"Format: {fmt}")
print(f"Streams: {streams}")
for stream in streams:
try:
rec = si.read_spikeglx(data_dir, stream_name=stream) if fmt == "spikeglx" \
else si.read_openephys(data_dir, stream_name=stream)
print(f" {stream}: {rec.get_num_channels()} ch, "
f"{rec.get_sampling_frequency()} Hz, "
f"{rec.get_total_duration():.1f} s")
except Exception as e:
print(f" {stream}: could not load ({e})")
Recipe: Export Quality Metrics Report to CSV
When to use: After running quality metrics, save a tidy CSV summarizing all units with their metrics and a pass/fail column for downstream analysis or sharing with collaborators.
import spikeinterface.qualitymetrics as sqm
import pandas as pd
metrics = sqm.compute_quality_metrics(
analyzer,
metric_names=["snr", "firing_rate", "isi_violation",
"presence_ratio", "amplitude_cutoff"],
)
# Add pass/fail column based on standard thresholds
metrics["pass_qc"] = (
(metrics["snr"] >= 5) &
(metrics["isi_violations_ratio"] <= 0.1) &
(metrics["firing_rate"] >= 0.1) &
(metrics["presence_ratio"] >= 0.9) &
(metrics["amplitude_cutoff"] <= 0.1)
)
metrics.to_csv("unit_quality_metrics.csv")
n_pass = metrics["pass_qc"].sum()
print(f"QC report saved: {len(metrics)} total units, {n_pass} pass ({100*n_pass/len(metrics):.0f}%)")
print(metrics[metrics["pass_qc"]].describe())
Recipe: Probe Geometry Visualization
When to use: Verify that the probe channel map loaded correctly before sorting. Incorrect channel maps silently degrade sorting quality on high-density probes.
import spikeinterface.full as si
import matplotlib.pyplot as plt
import probeinterface.plotting as pp
recording = si.read_spikeglx("/data/session_001", stream_name="imec0.ap")
probe = recording.get_probe()
print(f"Probe name: {probe.name}")
print(f"N contacts: {probe.get_contact_count()}")
print(f"Contact positions (first 5):\n{probe.contact_positions[:5]}")
fig, ax = plt.subplots(figsize=(3, 10))
pp.plot_probe(probe, ax=ax, with_channel_index=True)
ax.set_title(f"{probe.name} — channel map")
plt.tight_layout()
plt.savefig("probe_geometry.png", dpi=150)
print("Saved probe_geometry.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: stream_name not found |
Recording has multiple streams; none is specified | Run si.get_neo_streams(format, path) to list available streams; pass the correct one to the reader |
| Sorter output has zero units | Detection threshold too high, or preprocessing removed all signal | Verify recording_clean.get_traces() returns non-zero data; lower detection threshold (e.g. Th_learned=6 for Kilosort4) |
MissingExtensionError |
Analyzer extension depends on an uncomputed prerequisite | Follow the canonical compute order: random_spikes → waveforms → templates → noise_levels → spike_amplitudes |
| Docker sorter hangs at startup | Docker daemon not running or image not pulled | Run docker ps to confirm Docker is running; pull image manually with docker pull spikeinterface/kilosort4-compiled-base |
MemoryError during waveform extraction |
Dense extraction on high-channel-count probe | Use sparse=True in create_sorting_analyzer; reduce max_spikes_per_unit (default 500) |
| Bad channel detection removes too many channels | Threshold too aggressive or short recording | Set method="std" for a simpler threshold; increase bad_threshold parameter |
| Unit comparison shows 0% agreement between sorters | Delta time window too narrow or match score too strict | Increase delta_time (default 0.4 ms) and lower match_score (try 0.3) |
NWB export raises TypeError on unit properties |
Sorting contains non-serializable properties from sorter | Remove problematic properties: sorting.remove_unit_property("property_name") before export |
read_spikeglx fails on LF stream |
LFP stream uses different file suffix (.lf.bin) |
Specify stream_name="imec0.lf" explicitly; confirm file exists with ls data_dir/*.lf.bin |
Related Skills
- neuropixels-analysis — Neuropixels-specific pipeline using SpikeInterface + Kilosort4 with PSTH, tuning curves, and population decoding for rodent and primate experiments
- neurokit2 — For biosignal processing (ECG, EEG, EDA, EMG, PPG) rather than spike sorting; use when data is not extracellular electrophysiology
References
- SpikeInterface documentation — full API reference, tutorials, and sorter-specific guides
- SpikeInterface GitHub — source code, changelogs, and issue tracker
- Buccino et al. (2020), eLife — SpikeInterface paper — unified framework design and benchmarks across sorters
- ProbeInterface documentation — probe geometry handling and channel map formats
- SpikeInterface sorter list — supported sorters, requirements, and container images
- NWB documentation — Neurodata Without Borders format for neurophysiology data sharing
skills/scientific-computing/sympy-symbolic-math/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill sympy-symbolic-math -g -y
SKILL.md
Frontmatter
{
"name": "sympy-symbolic-math",
"license": "BSD-3-Clause",
"description": "Symbolic math in Python: exact algebra, calculus (derivatives, integrals, limits), equation solving, symbolic matrices, ODEs, code gen (lambdify, C\/Fortran). Use for exact symbolic results. For numerical use numpy\/scipy; for stats use statsmodels."
}
SymPy — Symbolic Mathematics
Overview
SymPy is a Python library for symbolic mathematics that performs exact computation using mathematical symbols rather than numerical approximations. It covers algebra, calculus, equation solving, linear algebra, physics, and code generation — all within pure Python with no external dependencies.
When to Use
- Solving equations symbolically (algebraic, systems, differential equations)
- Performing calculus operations (derivatives, integrals, limits, series expansions)
- Simplifying and manipulating algebraic expressions
- Working with matrices symbolically (eigenvalues, determinants, decompositions)
- Converting symbolic expressions to fast numerical functions (lambdify → NumPy)
- Generating code from math expressions (C, Fortran, LaTeX)
- Needing exact results (e.g.,
sqrt(2)not1.414...) - For numerical computing (array operations, linear algebra on data), use numpy/scipy
- For statistical modeling (regression, hypothesis testing), use statsmodels
Prerequisites
pip install sympy
# Optional for numerical evaluation:
pip install numpy matplotlib
SymPy is pure Python — no compiled dependencies, installs everywhere.
Quick Start
from sympy import symbols, solve, diff, integrate, sqrt, pi
x = symbols('x')
# Solve equation
print(solve(x**2 - 5*x + 6, x)) # [2, 3]
# Derivative
print(diff(x**3 + 2*x, x)) # 3*x**2 + 2
# Integral
print(integrate(x**2, (x, 0, 1))) # 1/3
# Exact arithmetic
print(sqrt(8)) # 2*sqrt(2)
print(pi.evalf(30)) # 3.14159265358979323846264338328
Core API
1. Symbols and Expressions
Create symbolic variables and manipulate expressions.
from sympy import symbols, Symbol, Rational, S, oo, pi, E, I
from sympy import simplify, expand, factor, collect, cancel, trigsimp
# Define symbols
x, y, z = symbols('x y z')
# With assumptions (improve simplification)
n = symbols('n', integer=True)
t = symbols('t', positive=True, real=True)
from sympy import sqrt
print(sqrt(t**2)) # t (not Abs(t), because t is positive)
# Exact fractions (avoid floats!)
expr = Rational(1, 3) * x + S(1)/7
print(expr) # x/3 + 1/7
# Simplification
print(simplify(x**2 + 2*x + 1)) # (x + 1)**2
print(expand((x + 1)**3)) # x**3 + 3*x**2 + 3*x + 1
print(factor(x**3 - x)) # x*(x - 1)*(x + 1)
print(collect(x*y + x - 3 + 2*x**2 - z*x**2, x)) # x**2*(2 - z) + x*(y + 1) - 3
2. Calculus
Derivatives, integrals, limits, and series.
from sympy import symbols, diff, integrate, limit, series, oo, sin, cos, exp, log
x = symbols('x')
# Derivatives
print(diff(sin(x**2), x)) # 2*x*cos(x**2)
print(diff(x**4, x, 3)) # 24*x (third derivative)
# Partial derivatives
x, y = symbols('x y')
f = x**2 * y**3
print(diff(f, x, y)) # 6*x*y**2
# Integrals
x = symbols('x')
print(integrate(x**2, x)) # x**3/3 (indefinite)
print(integrate(exp(-x**2), (x, -oo, oo))) # sqrt(pi) (Gaussian)
print(integrate(x * exp(-x), (x, 0, oo))) # 1
# Limits
print(limit(sin(x)/x, x, 0)) # 1
print(limit((1 + 1/x)**x, x, oo)) # E
# Taylor series
print(series(exp(x), x, 0, 5)) # 1 + x + x**2/2 + x**3/6 + x**4/24 + O(x**5)
3. Equation Solving
Algebraic, transcendental, and differential equations.
from sympy import symbols, solve, solveset, Eq, S, linsolve, nonlinsolve, Function, dsolve
x, y = symbols('x y')
# Single equation
print(solve(x**2 - 4, x)) # [-2, 2]
print(solveset(x**2 - 4, x, S.Reals)) # {-2, 2}
# System of linear equations
print(linsolve([x + y - 5, 2*x - y - 1], x, y)) # {(2, 3)}
# System of nonlinear equations
print(nonlinsolve([x**2 + y - 4, x + y**2 - 4], x, y))
# Differential equation: y'' + y = 0
f = Function('f')
ode = f(x).diff(x, 2) + f(x)
print(dsolve(ode, f(x))) # Eq(f(x), C1*sin(x) + C2*cos(x))
# With initial conditions
from sympy import Derivative
ics = {f(0): 1, f(x).diff(x).subs(x, 0): 0}
print(dsolve(ode, f(x), ics=ics)) # Eq(f(x), cos(x))
4. Matrices and Linear Algebra
Symbolic matrix operations.
from sympy import Matrix, eye, zeros, ones, diag, symbols
# Create matrices
M = Matrix([[1, 2], [3, 4]])
print(f"Det: {M.det()}") # -2
print(f"Inverse:\n{M**-1}")
# Symbolic matrices
a, b = symbols('a b')
M = Matrix([[a, b], [b, a]])
print(f"Eigenvalues: {M.eigenvals()}") # {a - b: 1, a + b: 1}
# Eigenvectors and diagonalization
eigendata = M.eigenvects()
# [(eigenval, multiplicity, [eigenvectors]), ...]
P, D = M.diagonalize()
print(f"M = P*D*P^-1")
# Solve linear system Ax = b
A = Matrix([[1, 2], [3, 4]])
b = Matrix([5, 6])
x = A.solve(b)
print(f"Solution: {x.T}")
# Matrix calculus
t = symbols('t')
M_t = Matrix([[t, t**2], [1, t]])
print(f"dM/dt:\n{M_t.diff(t)}")
5. Code Generation
Convert symbolic expressions to fast numerical functions or compiled code.
import numpy as np
from sympy import symbols, lambdify, sin, exp, ccode, fcode, latex
x, y = symbols('x y')
expr = sin(x) * exp(-x**2 / 2)
# lambdify: symbolic → fast NumPy function
f = lambdify(x, expr, 'numpy')
x_vals = np.linspace(-5, 5, 1000)
y_vals = f(x_vals)
print(f"Shape: {y_vals.shape}, Max: {y_vals.max():.4f}")
# Multi-variable lambdify
expr2 = x**2 + y**2
f2 = lambdify((x, y), expr2, 'numpy')
print(f"f(3, 4) = {f2(3, 4)}") # 25
# C code generation
print(ccode(expr)) # sin(x)*exp(-1.0/2.0*pow(x, 2))
# Fortran code generation
print(fcode(expr))
# LaTeX output
print(latex(expr)) # \sin{\left(x \right)} e^{- \frac{x^{2}}{2}}
6. Physics Module
Classical mechanics, vector analysis, and units.
from sympy import symbols, cos, sin, Function
from sympy.physics.mechanics import dynamicsymbols, LagrangesMethod, Particle, Point, ReferenceFrame
from sympy.physics.vector import dot, cross
# Vector analysis
N = ReferenceFrame('N')
v1 = 3*N.x + 4*N.y + 0*N.z
v2 = 1*N.x + 0*N.y + 2*N.z
print(f"Dot: {dot(v1, v2)}") # 3
print(f"Cross: {cross(v1, v2)}") # 8*N.x - 6*N.y - 4*N.z
# Simple pendulum via Lagrangian mechanics
q = dynamicsymbols('q') # Generalized coordinate (angle)
m, g, l = symbols('m g l', positive=True)
T = Rational(1, 2) * m * (l * q.diff())**2 # Kinetic energy
V = m * g * l * (1 - cos(q)) # Potential energy
L = T - V # Lagrangian
print(f"Lagrangian: {L}")
Key Concepts
Exact vs Numerical Arithmetic
from sympy import Rational, S, sqrt, pi
# WRONG: introduces floating-point error
expr_bad = 0.5 * x # Float 0.5, loses exactness
# CORRECT: exact symbolic arithmetic
expr_good = Rational(1, 2) * x # Exact 1/2
expr_good = S(1)/2 * x # Alternative exact syntax
expr_good = x / 2 # Also exact
# Numerical evaluation when needed
print(sqrt(2).evalf()) # 1.41421356237310
print(pi.evalf(50)) # 50 digits of precision
Solver Selection Guide
| Solver | Use When | Returns |
|---|---|---|
solve(eq, x) |
General purpose, legacy | List of solutions |
solveset(eq, x, domain) |
Algebraic equations (preferred) | Set (may be infinite) |
linsolve(system, vars) |
Linear systems | FiniteSet of tuples |
nonlinsolve(system, vars) |
Nonlinear systems | FiniteSet of tuples |
dsolve(ode, f(x)) |
Ordinary differential equations | Equality (Eq) |
nsolve(eq, x0) |
Numerical root finding | Float approximation |
Common Simplification Functions
| Function | Does | Example |
|---|---|---|
simplify() |
General simplification (slow, tries everything) | sin(x)**2 + cos(x)**2 → 1 |
expand() |
Distribute multiplication | (x+1)**2 → x**2+2*x+1 |
factor() |
Factor into irreducibles | x**2-1 → (x-1)*(x+1) |
collect() |
Group by variable | Collect terms in x |
cancel() |
Cancel common factors in fractions | (x**2-1)/(x-1) → x+1 |
trigsimp() |
Simplify trig expressions | Faster than simplify for trig |
powsimp() |
Simplify powers/exponentials | Combine x**a * x**b |
Common Workflows
Workflow: Symbolic-to-Numeric Pipeline
from sympy import symbols, diff, integrate, lambdify, sin, cos
import numpy as np
import matplotlib.pyplot as plt
x = symbols('x')
# 1. Define expression symbolically
f_expr = sin(x) * cos(x)**2
# 2. Symbolic operations
f_prime = diff(f_expr, x)
F_expr = integrate(f_expr, x)
print(f"f(x) = {f_expr}")
print(f"f'(x) = {f_prime}")
print(f"F(x) = {F_expr}")
# 3. Convert to fast numerical functions
f_num = lambdify(x, f_expr, 'numpy')
f_prime_num = lambdify(x, f_prime, 'numpy')
F_num = lambdify(x, F_expr, 'numpy')
# 4. Evaluate and plot
x_vals = np.linspace(0, 2*np.pi, 500)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].plot(x_vals, f_num(x_vals)); axes[0].set_title('f(x)')
axes[1].plot(x_vals, f_prime_num(x_vals)); axes[1].set_title("f'(x)")
axes[2].plot(x_vals, F_num(x_vals)); axes[2].set_title('F(x)')
plt.tight_layout()
plt.savefig('symbolic_pipeline.png', dpi=150)
print("Saved symbolic_pipeline.png")
Workflow: Solve and Verify
from sympy import symbols, solve, simplify, Eq, sqrt
x = symbols('x')
# 1. Define equation
equation = x**3 - 6*x**2 + 11*x - 6
# 2. Solve symbolically
solutions = solve(equation, x)
print(f"Solutions: {solutions}") # [1, 2, 3]
# 3. Verify each solution
for sol in solutions:
result = simplify(equation.subs(x, sol))
assert result == 0, f"Solution {sol} failed!"
print(f" x={sol}: f(x) = {result} ✓")
# 4. Factor the polynomial
from sympy import factor
print(f"Factored: {factor(equation)}") # (x - 1)*(x - 2)*(x - 3)
Workflow: ODE System Analysis
- Define the ODE using
Functionanddsolve() - Solve symbolically; apply initial conditions with
ics={}parameter - Convert solution to numerical function with
lambdify() - Plot the solution trajectory with matplotlib
Key Parameters
| Parameter | Function | Default | Options | Effect |
|---|---|---|---|---|
domain |
solveset() |
S.Complexes |
S.Reals, S.Integers |
Restrict solution domain |
force |
simplify() |
False | True/False | Aggressive simplification |
n |
diff(expr, x, n) |
1 | 1–∞ | Order of derivative |
| Precision | evalf(n) |
15 | 1–1000+ | Digits of numerical precision |
| Backend | lambdify() |
"math" | "numpy", "scipy", "mpmath" | Numerical backend for evaluation |
rational |
nsimplify() |
True | True/False | Find exact rational approximation |
Best Practices
-
Always use
Rational()orS()for fractions:0.5 * xintroduces floats that break exact computation. UseRational(1, 2) * xorS(1)/2 * x. -
Add assumptions to symbols:
symbols('x', positive=True)enables simplifications likesqrt(x**2) → x. Without assumptions, SymPy must handle the general complex case. -
Use
lambdifyfor numerical evaluation, notsubs().evalf():subs/evalfin a loop is 100-1000x slower than a singlelambdifycall.# Slow: [expr.subs(x, v).evalf() for v in values] # Fast: f = lambdify(x, expr, 'numpy'); f(np.array(values)) -
Anti-pattern — using
simplify()as default:simplify()is slow because it tries many strategies. Use specific functions (factor,expand,trigsimp) when you know the desired form. -
Prefer
solvesetoversolvefor algebraic equations:solvesetreturns proper mathematical sets and handles edge cases better.solveis legacy but still useful for general cases. -
Anti-pattern — solving symbolically when numerical is sufficient: For equations with no closed-form solution, use
nsolve(eq, x0)for numerical root finding instead of waiting forsolveto fail. -
Use
init_printing()in Jupyter for readable output:from sympy import init_printing; init_printing()enables LaTeX rendering in notebooks.
Common Recipes
Recipe: Generate LaTeX Documentation
from sympy import symbols, Integral, Eq, latex, sqrt, pi
x = symbols('x')
integral = Integral(x**2 * sqrt(1 - x**2), (x, 0, 1))
result = integral.doit()
print(f"$$ {latex(integral)} = {latex(result)} $$")
# $$ \int\limits_{0}^{1} x^{2} \sqrt{1 - x^{2}}\, dx = \frac{\pi}{16} $$
Recipe: Parametric ODE Solution
from sympy import symbols, Function, dsolve, Eq, exp, lambdify
import numpy as np
x = symbols('x')
k, A = symbols('k A', positive=True)
f = Function('f')
# Solve y' = -ky with y(0) = A
ode = Eq(f(x).diff(x), -k * f(x))
solution = dsolve(ode, f(x), ics={f(0): A})
print(f"Solution: {solution}") # f(x) = A*exp(-k*x)
# Evaluate for specific parameters
f_num = lambdify((x, k, A), solution.rhs, 'numpy')
x_vals = np.linspace(0, 5, 100)
y_vals = f_num(x_vals, k=0.5, A=10)
print(f"y(5) = {y_vals[-1]:.4f}")
Recipe: Symbolic Matrix Decomposition
from sympy import Matrix, symbols, pprint
a, b, c, d = symbols('a b c d')
M = Matrix([[a, b], [c, d]])
# Characteristic polynomial
lam = symbols('lambda')
char_poly = M.charpoly(lam)
print(f"Characteristic polynomial: {char_poly.as_expr()}")
# Eigenvalues (symbolic)
eigenvals = M.eigenvals()
print(f"Eigenvalues: {eigenvals}")
# Determinant and trace
print(f"det(M) = {M.det()}") # a*d - b*c
print(f"tr(M) = {M.trace()}") # a + d
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
NameError: name 'x' is not defined |
Symbol not created | Define with x = symbols('x') before use |
| Unexpected float results | Using 0.5 instead of Rational(1,2) |
Use Rational() or S() for exact fractions |
simplify() very slow |
Trying all strategies on complex expr | Use specific function: factor(), expand(), trigsimp() |
solve() returns empty list |
No closed-form solution exists | Use nsolve(eq, x0) for numerical approximation |
sqrt(x**2) returns sqrt(x**2) not x |
No assumption on x |
Define x = symbols('x', positive=True) |
lambdify wrong results |
Expression has SymPy-specific functions | Specify backend: lambdify(x, expr, 'numpy') or 'scipy' |
NotImplementedError in dsolve |
ODE type not supported | Try numerical ODE solver (scipy odeint) instead |
Related Skills
- matplotlib-scientific-plotting — plot symbolic results after lambdify conversion
- statsmodels-statistical-modeling — statistical inference; use when you need p-values, not exact algebra
- matlab-scientific-computing — MATLAB alternative for numerical (not symbolic) computing
References
- SymPy documentation — official API reference and tutorials
- SymPy tutorial — introduction for new users
- SymPy GitHub — source code, examples, and issues
- Meurer et al. (2017) "SymPy: symbolic computing in Python" — PeerJ Computer Science
skills/scientific-computing/torch-geometric-graph-neural-networks/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill torch-geometric-graph-neural-networks -g -y
SKILL.md
Frontmatter
{
"name": "torch-geometric-graph-neural-networks",
"license": "MIT",
"description": "PyTorch Geometric (PyG) for graph neural networks: node\/graph classification, link prediction with GCN, GAT, GraphSAGE, GIN. Message passing, mini-batches, heterogeneous graphs, neighbor sampling, explainability. Supports molecules (QM9, MoleculeNet), social\/knowledge graphs, 3D point clouds. For non-graph DL use PyTorch; for classical graph algorithms use NetworkX."
}
PyTorch Geometric (PyG) — Graph Neural Networks
Overview
PyTorch Geometric is a library built on PyTorch for developing and training Graph Neural Networks (GNNs). It provides 40+ convolutional layers, mini-batch processing via block-diagonal adjacency matrices, neighbor sampling for large-scale graphs, and heterogeneous graph support for multi-type node/edge networks.
When to Use
- Node classification on citation, social, or biological networks
- Graph-level classification (molecular activity, protein function)
- Link prediction (knowledge graphs, recommendation systems)
- Molecular property prediction (drug discovery, quantum chemistry)
- 3D point cloud processing and mesh analysis
- Large-scale graph learning with neighbor sampling (>100K nodes)
- Heterogeneous graphs with multiple node/edge types
- For non-graph deep learning → use PyTorch directly
- For traditional graph algorithms (shortest path, centrality) → use NetworkX
Prerequisites
pip install torch torch_geometric
# Optional sparse operations (recommended):
# pip install pyg_lib torch_scatter torch_sparse torch_cluster
import torch
import torch.nn.functional as F
from torch_geometric.data import Data
from torch_geometric.nn import GCNConv
Quick Start
from torch_geometric.datasets import Planetoid
from torch_geometric.nn import GCNConv
import torch, torch.nn.functional as F
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
class GCN(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = GCNConv(dataset.num_features, 16)
self.conv2 = GCNConv(16, dataset.num_classes)
def forward(self, data):
x = F.relu(self.conv1(data.x, data.edge_index))
return self.conv2(x, data.edge_index)
model = GCN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
for epoch in range(200):
model.train(); optimizer.zero_grad()
F.cross_entropy(model(data)[data.train_mask], data.y[data.train_mask]).backward()
optimizer.step()
model.eval()
pred = model(data).argmax(dim=1)
acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean()
print(f'Test Accuracy: {acc:.4f}') # ~0.81
Core API
1. Data Representation
import torch
from torch_geometric.data import Data
# Create a graph: 3 nodes, 4 edges (undirected)
edge_index = torch.tensor([[0, 1, 1, 2],
[1, 0, 2, 1]], dtype=torch.long)
x = torch.randn(3, 16) # Node features [num_nodes, features]
y = torch.tensor([0, 1, 0]) # Node labels
data = Data(x=x, edge_index=edge_index, y=y)
print(f'Nodes: {data.num_nodes}, Edges: {data.num_edges}')
print(f'Features: {data.num_node_features}')
print(f'Has self-loops: {data.has_self_loops()}')
print(f'Is undirected: {data.is_undirected()}')
# Optional attributes
data.edge_attr = torch.randn(4, 8) # Edge features [num_edges, features]
data.pos = torch.randn(3, 3) # Node positions (3D)
data.train_mask = torch.tensor([True, True, False]) # Custom masks
# Mini-batch processing — graphs concatenated as block-diagonal
from torch_geometric.loader import DataLoader
loader = DataLoader(dataset, batch_size=32, shuffle=True)
for batch in loader:
print(f'Graphs: {batch.num_graphs}, Nodes: {batch.num_nodes}')
# batch.batch maps each node → its source graph index
# No padding needed — computationally efficient
2. Convolutional Layers
from torch_geometric.nn import GCNConv, GATConv, SAGEConv, GINConv
import torch.nn as nn
# GCNConv — spectral graph convolution (baseline)
conv = GCNConv(in_channels=16, out_channels=32)
# Supports: edge_weight, SparseTensor, Bipartite, Lazy init
# GATConv — attention-based neighbor weighting
conv = GATConv(16, 32, heads=8, dropout=0.6)
# Output: [N, heads * out_channels] (concat) or [N, out_channels] (concat=False)
# SAGEConv — inductive learning via sampling
conv = SAGEConv(16, 32, aggr='mean') # 'mean', 'max', 'lstm'
# GINConv — maximally powerful for graph isomorphism
nn_module = nn.Sequential(nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 32))
conv = GINConv(nn_module)
# TransformerConv — graph transformer
from torch_geometric.nn import TransformerConv
conv = TransformerConv(16, 32, heads=8, beta=True)
# All layers: x_out = conv(x, edge_index)
x_out = conv(x, edge_index)
print(f'Output shape: {x_out.shape}') # [num_nodes, out_channels]
3. Custom Message Passing
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree
class CustomConv(MessagePassing):
def __init__(self, in_channels, out_channels):
super().__init__(aggr='add') # 'add', 'mean', 'max'
self.lin = torch.nn.Linear(in_channels, out_channels)
def forward(self, x, edge_index):
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
x = self.lin(x)
# Degree-based normalization
row, col = edge_index
deg = degree(col, x.size(0), dtype=x.dtype)
norm = deg.pow(-0.5)
norm = norm[row] * norm[col]
return self.propagate(edge_index, x=x, norm=norm)
def message(self, x_j, norm):
# x_j: source node features (automatic via _j suffix)
return norm.view(-1, 1) * x_j
# Key methods: forward(), message(), aggregate(), update()
# _i suffix → target node, _j suffix → source node
4. Pooling & Graph-Level Readout
from torch_geometric.nn import (
global_mean_pool, global_max_pool, global_add_pool,
TopKPooling, SAGPooling
)
# Global pooling: node features → graph-level representation
x_graph = global_mean_pool(x, batch) # [num_graphs, features]
# Hierarchical pooling: coarsen graph
pool = TopKPooling(64, ratio=0.8) # Keep top 80% nodes
x, edge_index, _, batch, _, _ = pool(x, edge_index, None, batch)
# Graph classification model
class GraphClassifier(torch.nn.Module):
def __init__(self, num_features, num_classes):
super().__init__()
self.conv1 = GCNConv(num_features, 64)
self.conv2 = GCNConv(64, 64)
self.pool = TopKPooling(64, ratio=0.8)
self.lin = torch.nn.Linear(64, num_classes)
def forward(self, data):
x, edge_index, batch = data.x, data.edge_index, data.batch
x = F.relu(self.conv1(x, edge_index))
x, edge_index, _, batch, _, _ = self.pool(x, edge_index, None, batch)
x = F.relu(self.conv2(x, edge_index))
x = global_mean_pool(x, batch)
return self.lin(x)
5. Heterogeneous Graphs
from torch_geometric.data import HeteroData
from torch_geometric.nn import HeteroConv, GCNConv, SAGEConv, to_hetero
# Create heterogeneous graph
data = HeteroData()
data['paper'].x = torch.randn(100, 128)
data['author'].x = torch.randn(200, 64)
data['author', 'writes', 'paper'].edge_index = torch.randint(0, 200, (2, 500))
data['paper', 'cites', 'paper'].edge_index = torch.randint(0, 100, (2, 300))
print(data) # Shows all node/edge types
# Method 1: Auto-convert homogeneous model
model = GCN(...)
model = to_hetero(model, data.metadata(), aggr='sum')
out = model(data.x_dict, data.edge_index_dict)
# Method 2: Custom per-edge-type convolutions
class HeteroGNN(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = HeteroConv({
('paper', 'cites', 'paper'): GCNConv(-1, 64),
('author', 'writes', 'paper'): SAGEConv((-1, -1), 64),
}, aggr='sum')
def forward(self, x_dict, edge_index_dict):
x_dict = self.conv1(x_dict, edge_index_dict)
return {k: F.relu(v) for k, v in x_dict.items()}
6. Transforms & Preprocessing
from torch_geometric.transforms import (
NormalizeFeatures, AddSelfLoops, ToUndirected,
RandomNodeSplit, RandomLinkSplit, Compose,
KNNGraph, RadiusGraph, AddLaplacianEigenvectorPE
)
# Single transform
dataset = Planetoid(root='/tmp/Cora', name='Cora', transform=NormalizeFeatures())
# Compose multiple transforms
transform = Compose([
ToUndirected(),
AddSelfLoops(),
NormalizeFeatures(),
])
# Data splitting
node_split = RandomNodeSplit(num_val=0.1, num_test=0.2)
link_split = RandomLinkSplit(num_val=0.1, num_test=0.2, is_undirected=True)
# Point cloud → graph
pc_transform = Compose([KNNGraph(k=6), NormalizeFeatures()])
# Positional encodings (for Graph Transformers)
pe_transform = AddLaplacianEigenvectorPE(k=10)
Key Concepts
Layer Selection Guide
| Task | Layer | Key Feature |
|---|---|---|
| Baseline / general | GCNConv |
Spectral, cached, edge_weight |
| Variable neighbor importance | GATConv / GATv2Conv |
Multi-head attention |
| Large-scale inductive | SAGEConv |
Sampling-friendly, mean/max/lstm aggr |
| Graph classification | GINConv |
Maximally powerful WL-test |
| Long-range dependencies | TransformerConv |
Graph transformer |
| Spectral filtering | ChebConv |
Chebyshev polynomials, K hops |
| Rich edge features | NNConv |
Edge NN processes edge_attr |
| Molecular / 3D structures | SchNet, DimeNet |
Continuous filters, angles |
| Heterogeneous / multi-relation | RGCNConv, HGTConv |
Multiple edge types |
| Point clouds | EdgeConv, PointNetConv |
Dynamic graphs, local features |
| Deep GNNs (avoid oversmoothing) | APPNP + PairNorm |
Separated propagation |
Data Flow Architecture
- edge_index:
[2, num_edges]COO format. Row 0 = source, Row 1 = target - Mini-batch: Block-diagonal adjacency +
batchvector mapping nodes → graphs. No padding - Neighbor sampling:
NeighborLoadersamples K-hop subgraphs per seed node. Output is directed, relabeled - Heterogeneous:
x_dict(per-type features),edge_index_dict(per-relation edges),metadata()for schema
Aggregation Options
| Aggregation | Class | Use Case |
|---|---|---|
| Sum | SumAggregation |
Counting-sensitive tasks |
| Mean | MeanAggregation |
Degree-invariant |
| Max | MaxAggregation |
Salient feature detection |
| Softmax | SoftmaxAggregation(learn=True) |
Learnable attention |
| Multi | MultiAggregation(['mean','max','std']) |
Combined signals |
Common Workflows
1. Node Classification (Full Graph)
import torch
import torch.nn.functional as F
from torch_geometric.datasets import Planetoid
from torch_geometric.nn import GCNConv
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
class GCN(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = GCNConv(dataset.num_features, 16)
self.conv2 = GCNConv(16, dataset.num_classes)
def forward(self, data):
x = F.dropout(F.relu(self.conv1(data.x, data.edge_index)), p=0.5, training=self.training)
return self.conv2(x, data.edge_index)
model = GCN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
# Training
for epoch in range(200):
model.train(); optimizer.zero_grad()
out = model(data)
F.cross_entropy(out[data.train_mask], data.y[data.train_mask]).backward()
optimizer.step()
# Evaluation
model.eval()
pred = model(data).argmax(dim=1)
acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean()
print(f'Test Accuracy: {acc:.4f}')
2. Graph Classification (Mini-Batch)
from torch_geometric.datasets import TUDataset
from torch_geometric.loader import DataLoader
from torch_geometric.nn import GCNConv, global_mean_pool
dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES')
train_dataset = dataset[:int(0.8 * len(dataset))]
test_dataset = dataset[int(0.8 * len(dataset)):]
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
class GraphNet(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = GCNConv(dataset.num_features, 64)
self.conv2 = GCNConv(64, 64)
self.lin = torch.nn.Linear(64, dataset.num_classes)
def forward(self, data):
x = F.relu(self.conv1(data.x, data.edge_index))
x = F.relu(self.conv2(x, data.edge_index))
x = global_mean_pool(x, data.batch)
return self.lin(x)
model = GraphNet()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(100):
model.train()
for batch in train_loader:
optimizer.zero_grad()
F.cross_entropy(model(batch), batch.y).backward()
optimizer.step()
3. Large-Scale with Neighbor Sampling
from torch_geometric.loader import NeighborLoader
# Sample 25 1-hop and 10 2-hop neighbors per seed node
train_loader = NeighborLoader(
data,
num_neighbors=[25, 10],
batch_size=128,
input_nodes=data.train_mask,
)
model.train()
for batch in train_loader:
optimizer.zero_grad()
out = model(batch)
# Only compute loss on seed nodes (first batch_size nodes)
loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size])
loss.backward()
optimizer.step()
# Note: output subgraphs are directed, indices relabeled 0..N-1
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
in_channels |
All Conv layers | — | int | Input feature dimension |
out_channels |
All Conv layers | — | int | Output feature dimension |
heads |
GATConv | 1 | 1-16 | Number of attention heads |
dropout |
GATConv | 0.0 | 0-0.8 | Attention weight dropout |
aggr |
MessagePassing | 'add' | add/mean/max | Neighbor aggregation |
K |
ChebConv | — | 2-5 | Chebyshev polynomial order |
num_neighbors |
NeighborLoader | — | list[int] | Neighbors per hop (e.g., [25,10]) |
batch_size |
DataLoader | — | 16-512 | Graphs or seed nodes per batch |
ratio |
TopKPooling | 0.5 | 0.1-0.9 | Fraction of nodes to keep |
lr |
Adam | — | 1e-4 to 0.01 | Learning rate |
weight_decay |
Adam | 0 | 0 to 5e-3 | L2 regularization |
Best Practices
- Start with GCNConv: Use 2-layer GCN as baseline before trying complex architectures
- Use lazy initialization: Pass
-1asin_channelsto infer dimensions automatically:GCNConv(-1, 64) - Normalize features: Apply
NormalizeFeatures()transform for citation/social networks - Anti-pattern — too many layers: GNNs typically need only 2-3 layers. Deeper causes oversmoothing. Use
JumpingKnowledgeorPairNormif you need depth - GPU transfer: Move both model AND data to GPU:
model.to(device),data.to(device) - Anti-pattern — ignoring batch vector: In graph classification, always use
global_mean_pool(x, batch)— forgettingbatchpools across all graphs
Common Recipes
Recipe: Model Explainability (GNNExplainer)
from torch_geometric.explain import Explainer, GNNExplainer
explainer = Explainer(
model=model,
algorithm=GNNExplainer(epochs=200),
explanation_type='model',
node_mask_type='attributes',
edge_mask_type='object',
model_config=dict(mode='multiclass_classification', task_level='node', return_type='log_probs'),
)
explanation = explainer(data.x, data.edge_index, index=10)
print(f'Important edges: {explanation.edge_mask.topk(5).indices}')
print(f'Important features: {explanation.node_mask[10].topk(5).indices}')
Recipe: Custom InMemoryDataset
from torch_geometric.data import InMemoryDataset, Data
class MyDataset(InMemoryDataset):
def __init__(self, root, transform=None, pre_transform=None):
super().__init__(root, transform, pre_transform)
self.load(self.processed_paths[0])
@property
def raw_file_names(self):
return ['data.csv']
@property
def processed_file_names(self):
return ['data.pt']
def process(self):
data_list = []
# Build Data objects from raw files
edge_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
x = torch.randn(2, 16)
data_list.append(Data(x=x, edge_index=edge_index, y=torch.tensor([0])))
if self.pre_filter is not None:
data_list = [d for d in data_list if self.pre_filter(d)]
if self.pre_transform is not None:
data_list = [self.pre_transform(d) for d in data_list]
self.save(data_list, self.processed_paths[0])
Recipe: Deep GNN with JumpingKnowledge
from torch_geometric.nn import GCNConv, JumpingKnowledge, LayerNorm
class DeepGNN(torch.nn.Module):
def __init__(self, in_ch, hidden, num_layers, out_ch):
super().__init__()
self.convs = torch.nn.ModuleList()
self.norms = torch.nn.ModuleList()
self.convs.append(GCNConv(in_ch, hidden))
self.norms.append(LayerNorm(hidden))
for _ in range(num_layers - 2):
self.convs.append(GCNConv(hidden, hidden))
self.norms.append(LayerNorm(hidden))
self.convs.append(GCNConv(hidden, hidden))
self.jk = JumpingKnowledge(mode='cat')
self.lin = torch.nn.Linear(hidden * num_layers, out_ch)
def forward(self, x, edge_index, batch):
xs = []
for conv, norm in zip(self.convs[:-1], self.norms):
x = F.relu(norm(conv(x, edge_index)))
xs.append(x)
xs.append(self.convs[-1](x, edge_index))
return self.lin(global_mean_pool(self.jk(xs), batch))
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
edge_index shape error |
Wrong format (should be [2, E]) | Ensure COO format: torch.tensor([[src...],[dst...]], dtype=torch.long) |
| OOM on large graph | Full-graph forward pass | Use NeighborLoader for mini-batch training |
| Low accuracy | Oversmoothing (too many layers) | Reduce to 2-3 layers, add JumpingKnowledge or PairNorm |
| NaN in training | Exploding gradients | Add gradient clipping, reduce learning rate, check feature scale |
| Wrong graph-level output | Missing batch in pooling |
Pass batch tensor to global_mean_pool(x, batch) |
| Heterogeneous type error | Mismatched node/edge types | Check data.metadata() matches model definition |
| Slow DataLoader | Large graph, no sampling | Use NeighborLoader with reasonable num_neighbors (e.g., [25,10]) |
x dimension mismatch |
Multi-head attention output | For GATConv: output is heads*out_channels unless concat=False |
| Import error for sparse ops | Missing optional dependencies | Install torch_scatter, torch_sparse from PyG wheels |
| Pre-transform not applied | Dataset already processed | Delete processed/ directory and reload |
Bundled Resources
references/layers_transforms_reference.md— Complete catalog of 40+ convolutional layers (GCN, GAT, SAGE, GIN, molecular layers, hypergraph), aggregation operators, pooling (global + hierarchical), normalization layers, pre-built models, auto-encoders, knowledge graph embeddings, utility layers. Transform catalog: structure, feature, spatial, augmentation, mesh, specialized. Consolidated from original layers_reference.md (486 lines) + transforms_reference.md (680 lines). Script functionality (benchmark_model.py, create_gnn_template.py, visualize_graph.py) covered by Core API code blocks and Common Recipesreferences/datasets_catalog.md— Comprehensive dataset catalog organized by domain: citation networks (Planetoid, Coauthor, Amazon), graph classification (TUDataset 120+ benchmarks), molecular (QM9, ZINC, MoleculeNet), social (Reddit, Twitch), knowledge graphs (WordNet, FB15k), heterogeneous (OGB_MAG, MovieLens, DBLP), temporal (JODIE), 3D meshes (ShapeNet, ModelNet), OGB integration. Consolidated from original datasets_reference.md (575 lines)
Related Skills
- matplotlib-scientific-plotting — Visualize graph structures, training curves, attention weights
References
- PyTorch Geometric Documentation: https://pytorch-geometric.readthedocs.io/
- PyG GitHub: https://github.com/pyg-team/pytorch_geometric
- Fey & Lenssen (2019), "Fast Graph Representation Learning with PyTorch Geometric", ICLR Workshop
skills/scientific-computing/transformers-bio-nlp/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill transformers-bio-nlp -g -y
SKILL.md
Frontmatter
{
"name": "transformers-bio-nlp",
"license": "Apache-2.0",
"description": "HuggingFace Transformers with biomedical LMs (BioBERT, PubMedBERT, BioGPT, BioMedLM) for scientific NLP: NER (genes, diseases, chemicals), relation extraction, QA, text classification, abstract summarization. Covers loading, biomedical tokenization, inference pipelines, fine-tuning. Alternatives: spaCy en_core_sci_lg (rule-based NER), Stanza (biomedical models), NLTK."
}
Transformers for Biomedical NLP
Overview
HuggingFace Transformers provides a unified API to load, run, and fine-tune 500+ biomedical language models. The key biomedical models — BioBERT (trained on PubMed abstracts + PMC full text), PubMedBERT (trained from scratch on PubMed), BioGPT (generative, trained on PubMed), and BioMedLM — significantly outperform general-purpose BERT on biomedical NER, relation extraction, and question answering. The pipeline() abstraction handles tokenization, inference, and postprocessing in one call. Fine-tuning on task-specific labeled data (e.g., BC5CDR for chemical/disease NER) takes under an hour on a single GPU. The datasets library provides direct access to standard biomedical benchmarks.
When to Use
- Extracting gene names, disease mentions, drug names, or chemical entities from biomedical abstracts (NER)
- Classifying abstracts by topic, sentiment of clinical outcomes, or PICO elements for systematic reviews
- Answering specific questions from biomedical literature using extractive QA (BioASQ format)
- Generating hypotheses or summaries from biomedical text using BioGPT or BioMedLM
- Fine-tuning a pre-trained biomedical model on a custom labeled dataset (e.g., your lab's annotations)
- Embedding biomedical sentences for semantic similarity search across literature
- Use spaCy + en_core_sci_lg for fast rule-augmented NER; use Stanza for dependency parsing
Prerequisites
- Python packages:
transformers,torch,datasets,accelerate,sentencepiece - GPU: Strongly recommended for fine-tuning; inference on CPU is viable for single texts
- Data requirements: plain text biomedical strings; for fine-tuning, annotated data in BIO/IOB format
pip install transformers torch datasets accelerate sentencepiece
# For GPU (CUDA 11.8)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
Quick Start
from transformers import pipeline
# Named entity recognition with BioBERT
ner = pipeline("ner", model="allenai/scibert_scivocab_cased",
aggregation_strategy="simple")
text = "BRCA1 mutations are associated with increased risk of breast cancer and ovarian cancer."
entities = ner(text)
for ent in entities:
print(f" {ent['word']:20s} {ent['entity_group']:10s} score={ent['score']:.3f}")
Core API
Module 1: Named Entity Recognition (NER)
Extract biomedical entities using pre-trained NER models.
from transformers import pipeline, AutoTokenizer, AutoModelForTokenClassification
# BioBERT fine-tuned for NER (genes, diseases, chemicals)
# Common choices:
# "allenai/scibert_scivocab_cased" — scientific NER
# "d4data/biomedical-ner-all" — multi-entity biomedical NER
# "pruas/BENT-PubMedBERT-NER-Gene" — gene-specific NER
ner_pipe = pipeline(
"ner",
model="d4data/biomedical-ner-all",
aggregation_strategy="simple", # merge subword tokens into words
device=-1 # -1=CPU, 0=GPU
)
abstracts = [
"Imatinib inhibits the BCR-ABL1 tyrosine kinase and is first-line treatment for CML.",
"EGFR mutations in non-small cell lung cancer predict response to erlotinib.",
]
for text in abstracts:
entities = ner_pipe(text)
print(f"\nText: {text[:60]}...")
for e in entities:
print(f" [{e['entity_group']}] '{e['word']}' (score={e['score']:.2f})")
# Manual tokenization + inference for batch processing
from transformers import AutoTokenizer, AutoModelForTokenClassification
import torch
model_name = "allenai/scibert_scivocab_cased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForTokenClassification.from_pretrained(model_name)
model.eval()
text = "Metformin activates AMPK and reduces hepatic glucose production in type 2 diabetes."
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits # shape: (1, seq_len, n_labels)
predictions = logits.argmax(dim=-1)[0]
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
labels = [model.config.id2label[p.item()] for p in predictions]
for token, label in zip(tokens[1:-1], labels[1:-1]): # skip [CLS] and [SEP]
if label != "O":
print(f" {token:20s} {label}")
Module 2: Text Classification
Classify biomedical abstracts or sentences.
from transformers import pipeline
# Zero-shot classification — no fine-tuning needed
zs_clf = pipeline("zero-shot-classification",
model="facebook/bart-large-mnli",
device=-1)
abstract = """
This randomized controlled trial evaluated the efficacy of pembrolizumab versus
chemotherapy in patients with advanced non-small-cell lung cancer. Overall survival
was significantly improved in the pembrolizumab arm (HR=0.60, 95% CI 0.41-0.89).
"""
candidate_labels = ["clinical trial", "basic research", "meta-analysis", "review"]
result = zs_clf(abstract, candidate_labels)
print("Zero-shot classification:")
for label, score in zip(result["labels"], result["scores"]):
print(f" {label:20s}: {score:.3f}")
# Fine-tuned sentiment/outcome classification
from transformers import pipeline
# Example: classify clinical outcome sentiment
clf = pipeline("text-classification",
model="pruas/BENT-PubMedBERT-NER-Gene", # use appropriate task-specific model
device=-1)
sentences = [
"Treatment significantly improved overall survival (p<0.001).",
"No statistically significant difference was observed between groups.",
]
results = clf(sentences)
for sent, result in zip(sentences, results):
print(f" [{result['label']} | {result['score']:.2f}] {sent[:50]}...")
Module 3: Biomedical Question Answering
Extract answers from biomedical text passages.
from transformers import pipeline
# Extractive QA: find answer span within context
qa_pipe = pipeline(
"question-answering",
model="sultan/BioM-ELECTRA-Large-SQuAD2", # biomedical QA model
device=-1
)
context = """
BRCA1 is a tumor suppressor gene located on chromosome 17q21. Pathogenic variants
in BRCA1 confer a lifetime breast cancer risk of 50-72% and ovarian cancer risk
of 44-46%. BRCA1 protein functions in DNA double-strand break repair via
homologous recombination.
"""
questions = [
"What chromosome is BRCA1 located on?",
"What is the lifetime breast cancer risk from BRCA1 variants?",
"What DNA repair pathway does BRCA1 participate in?",
]
for q in questions:
result = qa_pipe(question=q, context=context)
print(f"Q: {q}")
print(f"A: {result['answer']} (score={result['score']:.3f})\n")
Module 4: Text Generation with BioGPT
Generate biomedical text, hypotheses, and summaries.
from transformers import AutoTokenizer, BioGptForCausalLM
import torch
model_name = "microsoft/biogpt"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = BioGptForCausalLM.from_pretrained(model_name)
model.eval()
prompt = "The role of VEGF in tumor angiogenesis"
inputs = tokenizer(prompt, return_tensors="pt")
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=100,
num_beams=5,
early_stopping=True,
no_repeat_ngram_size=3,
pad_token_id=tokenizer.eos_token_id,
)
generated = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Generated:\n{generated}")
Module 5: Sentence Embeddings for Semantic Search
Embed biomedical text for similarity search and clustering.
from transformers import AutoTokenizer, AutoModel
import torch
import numpy as np
def mean_pooling(model_output, attention_mask):
"""Mean pooling across token embeddings."""
token_embeddings = model_output.last_hidden_state
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
return (token_embeddings * input_mask_expanded).sum(1) / input_mask_expanded.sum(1)
# PubMedBERT for biomedical sentence embeddings
model_name = "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract-fulltext"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
model.eval()
sentences = [
"BRCA1 is involved in DNA double-strand break repair.",
"Homologous recombination requires BRCA1 and BRCA2.",
"Metformin inhibits hepatic gluconeogenesis via AMPK.",
]
inputs = tokenizer(sentences, padding=True, truncation=True,
max_length=512, return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
embeddings = mean_pooling(outputs, inputs["attention_mask"])
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1).numpy()
# Compute cosine similarity
from numpy.linalg import norm
sim_01 = np.dot(embeddings[0], embeddings[1])
sim_02 = np.dot(embeddings[0], embeddings[2])
print(f"Similarity (BRCA1 repair vs. HR): {sim_01:.3f}")
print(f"Similarity (BRCA1 repair vs. Metformin): {sim_02:.3f}")
Module 6: Fine-Tuning on Custom Data
Fine-tune a biomedical model on a labeled NER dataset.
from transformers import (AutoTokenizer, AutoModelForTokenClassification,
TrainingArguments, Trainer, DataCollatorForTokenClassification)
from datasets import Dataset
import numpy as np
# Example: minimal NER fine-tuning setup
model_name = "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract"
label_list = ["O", "B-GENE", "I-GENE", "B-DISEASE", "I-DISEASE"]
id2label = {i: l for i, l in enumerate(label_list)}
label2id = {l: i for i, l in enumerate(label_list)}
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForTokenClassification.from_pretrained(
model_name, num_labels=len(label_list), id2label=id2label, label2id=label2id
)
# Training arguments
training_args = TrainingArguments(
output_dir="./biomed_ner_finetuned",
num_train_epochs=3,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
warmup_steps=100,
weight_decay=0.01,
logging_dir="./logs",
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
)
print(f"Model ready for fine-tuning: {model_name}")
print(f"Labels: {label_list}")
# trainer = Trainer(model=model, args=training_args, ...)
# trainer.train()
Key Concepts
Tokenization of Biomedical Text
Biomedical text contains special tokens (gene symbols, drug names, chemical SMILES, numeric values) that WordPiece and BPE tokenizers split unexpectedly. For example, "BRCA1" → ["BR", "##CA", "##1"]. This subword splitting does not affect classification tasks but does affect NER — use aggregation_strategy="simple" or "first" in pipeline() to merge subword predictions back to word level.
BIO Labeling Scheme
NER uses BIO (Begin-Inside-Outside) tagging: B-GENE marks the first token of a gene name, I-GENE marks continuation tokens, O marks non-entity tokens. During fine-tuning, align labels to subword tokens by setting non-first subword labels to -100 (ignored by the loss function).
Common Workflows
Workflow 1: Batch Abstract NER and Entity Aggregation
from transformers import pipeline
import pandas as pd
ner_pipe = pipeline("ner", model="d4data/biomedical-ner-all",
aggregation_strategy="simple", device=-1)
abstracts = [
"Pembrolizumab combined with chemotherapy significantly improved progression-free survival in HER2-positive breast cancer.",
"Inhibition of EGFR by gefitinib is effective in patients with activating EGFR mutations in exons 19 and 21.",
"CRISPR-Cas9 editing of the PCSK9 gene in hepatocytes reduces LDL cholesterol in murine models.",
]
records = []
for i, text in enumerate(abstracts):
entities = ner_pipe(text)
for e in entities:
records.append({
"abstract_id": i,
"entity": e["word"],
"type": e["entity_group"],
"score": round(e["score"], 3),
})
df = pd.DataFrame(records)
print(df.groupby("type")["entity"].apply(list).to_string())
df.to_csv("extracted_entities.csv", index=False)
print(f"\nExtracted {len(df)} entity mentions across {len(abstracts)} abstracts")
Workflow 2: Semantic Similarity Ranking for Literature Retrieval
from transformers import AutoTokenizer, AutoModel
import torch
import numpy as np
model_name = "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract-fulltext"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
model.eval()
def embed(texts):
enc = tokenizer(texts, padding=True, truncation=True,
max_length=512, return_tensors="pt")
with torch.no_grad():
out = model(**enc)
vecs = out.last_hidden_state[:, 0, :] # [CLS] token
return torch.nn.functional.normalize(vecs, dim=1).numpy()
query = "CRISPR base editing for correction of point mutations in genetic disease"
corpus = [
"Base editing enables precise single-base changes in genomic DNA without double-strand breaks.",
"CAR-T cell therapy targets CD19 in B-cell acute lymphoblastic leukemia.",
"Prime editing uses reverse transcriptase to install targeted edits at specific loci.",
"RNA interference silences gene expression via RISC-mediated mRNA cleavage.",
]
q_emb = embed([query])
c_emb = embed(corpus)
scores = (q_emb @ c_emb.T).flatten()
ranked = sorted(zip(scores, corpus), reverse=True)
print("Top results:")
for score, text in ranked:
print(f" [{score:.3f}] {text[:70]}...")
Key Parameters
| Parameter | Module/Function | Default | Range / Options | Effect |
|---|---|---|---|---|
model |
pipeline() |
— | HuggingFace model ID string | Pre-trained model to load; must match task |
aggregation_strategy |
NER pipeline |
"none" |
"none", "simple", "first", "average" |
Merge subword NER predictions; use "simple" for word-level output |
device |
pipeline() |
-1 | -1 (CPU), 0 (GPU 0), 1 (GPU 1) | Inference device |
max_length |
tokenizer | 512 | 128–2048 (model-dependent) | Max token length; truncates longer inputs |
max_new_tokens |
model.generate() |
20 | 1–1000 | Tokens to generate for text generation models |
num_beams |
model.generate() |
1 | 1–10 | Beam search width; larger = better quality, slower |
num_train_epochs |
TrainingArguments |
3 | 1–10 | Fine-tuning epochs |
per_device_train_batch_size |
TrainingArguments |
8 | 4–32 | Batch size per GPU; reduce if OOM |
weight_decay |
TrainingArguments |
0.0 | 0.01–0.1 | L2 regularization for fine-tuning |
Best Practices
-
Use domain-specific models, not general BERT: PubMedBERT trained from scratch on PubMed outperforms BERT-base by 5–15% on biomedical NER. Always start with biomedical pre-training before fine-tuning on task-specific data.
-
Verify model licenses before production use: Some models (BioGPT, BioMedLM) have research-only licenses. Check the HuggingFace model card's license field before deploying in commercial applications.
-
Use
aggregation_strategy="simple"for word-level NER output: The default"none"returns subword tokens, making post-processing difficult."simple"merges subword tokens using the first-token strategy. -
Truncate at sentence boundaries, not mid-sentence: Long biomedical abstracts that exceed 512 tokens should be split at sentence boundaries before encoding. Mid-sentence truncation degrades NER accuracy for entities near the cutoff.
Common Recipes
Recipe: Extract Drug-Disease Pairs from PubMed Abstracts
from transformers import pipeline
from itertools import product
ner = pipeline("ner", model="d4data/biomedical-ner-all",
aggregation_strategy="simple", device=-1)
def extract_drug_disease_pairs(text):
entities = ner(text)
drugs = [e["word"] for e in entities if e["entity_group"] in ("DRUG", "CHEMICAL")]
diseases = [e["word"] for e in entities if e["entity_group"] in ("DISEASE", "CONDITION")]
return list(product(drugs, diseases))
text = "Imatinib and nilotinib both target BCR-ABL1 in chronic myeloid leukemia and Philadelphia chromosome-positive ALL."
pairs = extract_drug_disease_pairs(text)
print("Drug-Disease pairs:")
for drug, disease in pairs:
print(f" {drug} → {disease}")
Recipe: Sentence-Level Abstract Filtering
from transformers import pipeline
clf = pipeline("zero-shot-classification",
model="facebook/bart-large-mnli", device=-1)
abstracts = [
"We present a phase 3 randomized controlled trial of semaglutide in type 2 diabetes.",
"Structural analysis of the SARS-CoV-2 spike protein RBD domain by cryo-EM.",
"A retrospective cohort study of 1,200 ICU patients during the COVID-19 pandemic.",
]
label_options = ["randomized controlled trial", "observational study", "structural biology", "computational study"]
for abstract in abstracts:
result = clf(abstract, label_options)
print(f"Type: {result['labels'][0]} ({result['scores'][0]:.2f})")
print(f" {abstract[:70]}...\n")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
CUDA out of memory during inference |
Batch too large for GPU VRAM | Reduce batch size; use device=-1 for CPU; use model.half() for FP16 |
NER returns subword tokens (##CA) |
aggregation_strategy not set |
Set aggregation_strategy="simple" in pipeline() |
| Model download times out | Large model files (1–10 GB); slow connection | Set HF_HUB_OFFLINE=1 and download manually with huggingface-cli download |
| NER misses entities at end of long abstracts | Input truncated at 512 tokens | Split abstracts into sentences; process each separately |
Fine-tuning loss is NaN |
Learning rate too high or gradient explosion | Reduce learning_rate to 2e-5; enable gradient clipping max_grad_norm=1.0 |
| Wrong entities for specialized domain | Generic biomedical model not suited to subdomain | Fine-tune on domain-labeled data; use more specific model (e.g., gene-only NER) |
| BioGPT generates repetitive text | no_repeat_ngram_size too small |
Set no_repeat_ngram_size=3 or 4; increase num_beams |
Related Skills
pubmed-database— retrieve PubMed abstracts that serve as input to biomedical NLP pipelinesbiorxiv-database— retrieve preprints for NLP analysis before peer reviewscientific-critical-thinking— evaluate quality of NLP-extracted evidence before using for research conclusions
References
- HuggingFace Transformers docs — pipeline, tokenizer, and training API
- BioBERT paper: Lee et al. (2020), Bioinformatics — pre-training on PubMed and PMC
- PubMedBERT paper: Gu et al. (2021), ACL — from-scratch pre-training on PubMed
- BioGPT paper: Luo et al. (2022), Briefings in Bioinformatics — generative biomedical language model
- BioCreative benchmarks — standard NER and relation extraction datasets
skills/scientific-computing/umap-learn/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill umap-learn -g -y
SKILL.md
Frontmatter
{
"name": "umap-learn",
"license": "BSD-3-Clause",
"description": "UMAP dimensionality reduction for visualization, clustering prep, and feature engineering. Fast nonlinear manifold learning preserving local and global structure. Standard UMAP (fit\/transform, sklearn-compatible), supervised\/semi-supervised, Parametric UMAP (NN encoder\/decoder, TensorFlow), DensMAP (density), AlignedUMAP (temporal\/batch). 15+ distance metrics, custom Numba metrics, precomputed distances. For linear reduction use PCA; for neighborhood graphs use sklearn NearestNeighbors."
}
UMAP-Learn
Overview
UMAP (Uniform Manifold Approximation and Projection) is a dimensionality reduction algorithm for visualization and general non-linear dimensionality reduction. It is faster than t-SNE, scales to larger datasets, preserves both local and global structure, and supports supervised learning and embedding of new data points.
When to Use
- Reducing high-dimensional data to 2D/3D for visualization
- Preprocessing for density-based clustering (HDBSCAN, DBSCAN)
- Feature engineering in ML pipelines (transform new data into learned embedding)
- Supervised/semi-supervised embedding with partial labels
- Tracking embeddings across time points or batches (AlignedUMAP)
- Density-preserving embeddings (DensMAP)
- Neural network-based embedding with custom architectures (Parametric UMAP)
- For linear dimensionality reduction use PCA (scikit-learn)
- For neighborhood-graph construction without embedding use scikit-learn NearestNeighbors
Prerequisites
pip install umap-learn
# For Parametric UMAP (neural network variant)
pip install umap-learn[parametric_umap] # requires TensorFlow 2.x
Critical: Always standardize features before applying UMAP to ensure equal weighting across dimensions.
Quick Start
import umap
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_digits
# Load and scale data
X, y = load_digits(return_X_y=True)
X_scaled = StandardScaler().fit_transform(X)
# Fit and transform
embedding = umap.UMAP(random_state=42).fit_transform(X_scaled)
print(f"Input: {X_scaled.shape}, Output: {embedding.shape}")
# Input: (1797, 64), Output: (1797, 2)
Core API
1. Standard UMAP
Basic dimensionality reduction following scikit-learn conventions.
import umap
from sklearn.preprocessing import StandardScaler
X_scaled = StandardScaler().fit_transform(data)
# Method 1: fit_transform (single step)
embedding = umap.UMAP(
n_neighbors=15, # local neighborhood size (2-200)
min_dist=0.1, # min distance between embedded points (0.0-0.99)
n_components=2, # output dimensions
metric='euclidean', # distance metric
random_state=42, # reproducibility
).fit_transform(X_scaled)
print(f"Embedding shape: {embedding.shape}")
# Method 2: fit + access (for reuse)
reducer = umap.UMAP(random_state=42)
reducer.fit(X_scaled)
embedding = reducer.embedding_ # trained embedding
graph = reducer.graph_ # fuzzy simplicial set (sparse matrix)
# Visualization
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 6))
plt.scatter(embedding[:, 0], embedding[:, 1], c=labels, cmap='Spectral', s=5)
plt.colorbar()
plt.title('UMAP Embedding')
plt.tight_layout()
plt.savefig('umap_embedding.png', dpi=150)
2. Supervised & Semi-Supervised UMAP
Incorporate label information to guide embedding via the y parameter.
import umap
# Supervised — all labels known
embedding = umap.UMAP(random_state=42).fit_transform(X_scaled, y=labels)
# Semi-supervised — partial labels (mark unlabeled as -1)
semi_labels = labels.copy()
semi_labels[unlabeled_indices] = -1
embedding = umap.UMAP(random_state=42).fit_transform(X_scaled, y=semi_labels)
# Control label influence with target_weight (0.0=unsupervised, 1.0=fully supervised)
reducer = umap.UMAP(
target_weight=0.7, # emphasize labels
target_metric='categorical', # for classification; use distance metric for regression
random_state=42
)
embedding = reducer.fit_transform(X_scaled, y=labels)
print(f"Supervised embedding: {embedding.shape}")
3. Transform New Data
Project unseen data into the trained embedding space.
import umap
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# Fit on training data
reducer = umap.UMAP(n_components=10, random_state=42)
X_train_emb = reducer.fit_transform(X_train_scaled)
# Transform test data
X_test_emb = reducer.transform(X_test_scaled)
print(f"Train: {X_train_emb.shape}, Test: {X_test_emb.shape}")
# Works in sklearn Pipelines
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC
pipeline = Pipeline([
('scaler', StandardScaler()),
('umap', umap.UMAP(n_components=10, random_state=42)),
('classifier', SVC())
])
pipeline.fit(X_train, y_train)
accuracy = pipeline.score(X_test, y_test)
print(f"Pipeline accuracy: {accuracy:.3f}")
4. Parametric UMAP
Neural network-based embedding via TensorFlow/Keras. Enables efficient transform, reconstruction, and custom architectures.
from umap.parametric_umap import ParametricUMAP
# Default architecture (3-layer, 100-neuron FC network)
embedder = ParametricUMAP(n_components=2, random_state=42)
embedding = embedder.fit_transform(X_scaled)
new_emb = embedder.transform(new_data) # fast neural network inference
print(f"Parametric embedding: {embedding.shape}")
import tensorflow as tf
from umap.parametric_umap import ParametricUMAP
# Custom encoder/decoder for autoencoder mode
input_dim = X_scaled.shape[1]
encoder = tf.keras.Sequential([
tf.keras.layers.InputLayer(input_shape=(input_dim,)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dense(2),
])
decoder = tf.keras.Sequential([
tf.keras.layers.InputLayer(input_shape=(2,)),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dense(input_dim),
])
embedder = ParametricUMAP(
encoder=encoder, decoder=decoder, dims=(input_dim,),
parametric_reconstruction=True, autoencoder_loss=True,
n_training_epochs=10, batch_size=128,
n_neighbors=15, min_dist=0.1, random_state=42
)
embedding = embedder.fit_transform(X_scaled)
reconstructed = embedder.inverse_transform(embedding)
print(f"Reconstruction error: {np.mean((X_scaled - reconstructed)**2):.4f}")
5. DensMAP
Variant preserving local density information in the embedding.
import umap
reducer = umap.UMAP(
densmap=True, # enable DensMAP
dens_lambda=2.0, # density preservation weight
dens_frac=0.3, # fraction for density estimation
output_dens=True, # output density estimates
n_neighbors=15,
min_dist=0.1,
random_state=42
)
embedding = reducer.fit_transform(X_scaled)
# Access density estimates
original_density = reducer.rad_orig_ # density in original space
embedded_density = reducer.rad_emb_ # density in embedded space
print(f"DensMAP embedding: {embedding.shape}")
print(f"Density correlation: {np.corrcoef(original_density, embedded_density)[0,1]:.3f}")
6. AlignedUMAP
Align embeddings across multiple related datasets (time points, batches).
from umap import AlignedUMAP
# Multiple related datasets
datasets = [day1_data, day2_data, day3_data]
mapper = AlignedUMAP(
n_neighbors=15,
alignment_regularisation=1e-2, # alignment strength
alignment_window_size=2, # align with N adjacent datasets
n_components=2,
random_state=42
)
mapper.fit(datasets)
aligned_embeddings = mapper.embeddings_ # list of aligned embedding arrays
print(f"Aligned {len(aligned_embeddings)} datasets")
for i, emb in enumerate(aligned_embeddings):
print(f" Dataset {i}: {emb.shape}")
Key Concepts
Parameter Tuning Guide
| Parameter | Low | Medium (default) | High | Effect |
|---|---|---|---|---|
n_neighbors |
2-5 | 15 | 50-200 | Local detail vs global structure |
min_dist |
0.0 | 0.1 | 0.5-0.99 | Tight clusters vs spread out |
n_components |
2 | 2 | 5-50 | Visualization vs ML/clustering |
spread |
0.5 | 1.0 | 2.0 | Embedding scale (with min_dist) |
Configuration by Use-Case
| Use-Case | n_neighbors | min_dist | n_components | metric |
|---|---|---|---|---|
| Visualization | 15 | 0.1 | 2 | euclidean |
| Clustering (HDBSCAN) | 30 | 0.0 | 5-10 | euclidean |
| Text/document embedding | 15 | 0.1 | 2 | cosine |
| Global structure | 100 | 0.5 | 2 | euclidean |
| ML feature engineering | 15-30 | 0.1 | 10-50 | euclidean |
| Binary/set data | 15 | 0.1 | 2 | hamming/jaccard |
Supported Metrics
Minkowski family: euclidean, manhattan, chebyshev, minkowski. Spatial: canberra, braycurtis, haversine. Correlation: cosine, correlation. Binary: hamming, jaccard, dice, russellrao, rogerstanimoto, sokalmichener, sokalsneath, yule. Special: precomputed (distance matrix), custom Numba-compiled callables.
Standard UMAP vs Parametric UMAP
| Feature | Standard | Parametric |
|---|---|---|
| Backend | Direct optimization | TensorFlow neural network |
| Transform speed | Moderate | Fast (neural net inference) |
| Inverse transform | Approximate, expensive | Decoder network, fast |
| Custom architecture | No | Yes (CNNs, RNNs, etc.) |
| Requirements | umap-learn | umap-learn + TensorFlow 2.x |
| Best for | Quick exploration | Production pipelines, reconstruction |
Common Workflows
Workflow 1: UMAP + HDBSCAN Clustering Pipeline
import umap
import hdbscan
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score
# Step 1: Preprocess
X_scaled = StandardScaler().fit_transform(data)
print(f"Input shape: {X_scaled.shape}")
# Step 2: UMAP for clustering (NOT visualization parameters)
reducer = umap.UMAP(
n_neighbors=30, # more global structure for clustering
min_dist=0.0, # allow tight packing
n_components=10, # higher dims preserve density better than 2D
metric='euclidean',
random_state=42
)
embedding = reducer.fit_transform(X_scaled)
# Step 3: HDBSCAN clustering
clusterer = hdbscan.HDBSCAN(min_cluster_size=15, min_samples=5)
cluster_labels = clusterer.fit_predict(embedding)
n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0)
noise = sum(cluster_labels == -1)
print(f"Clusters: {n_clusters}, Noise: {noise}")
# Step 4: Separate 2D embedding for visualization
vis_emb = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=42).fit_transform(X_scaled)
plt.scatter(vis_emb[:, 0], vis_emb[:, 1], c=cluster_labels, cmap='Spectral', s=5)
plt.colorbar()
plt.title(f'HDBSCAN Clusters (n={n_clusters})')
plt.tight_layout()
plt.savefig('umap_clusters.png', dpi=150)
Workflow 2: Supervised Embedding for Classification
import umap
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.metrics import classification_report
# Split and scale
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# Supervised UMAP for feature engineering
reducer = umap.UMAP(n_components=10, random_state=42)
X_train_emb = reducer.fit_transform(X_train_s, y=y_train)
X_test_emb = reducer.transform(X_test_s)
# Downstream classifier
clf = SVC(kernel='rbf')
clf.fit(X_train_emb, y_train)
y_pred = clf.predict(X_test_emb)
print(classification_report(y_test, y_pred))
Workflow 3: Exploring Embedding Space with Inverse Transform
Text-only — combines Core API modules 1 and 3 (inverse_transform on standard UMAP):
- Fit standard UMAP on data (Core API: Standard UMAP)
- Create a grid of points spanning the embedding space
- Apply
reducer.inverse_transform(grid_points)to reconstruct high-dimensional data - Visualize reconstructed samples to understand embedding regions
Note: inverse transform is approximate; works poorly outside the convex hull of the training embedding.
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
n_neighbors |
UMAP | 15 | 2-200 | Local vs global structure balance |
min_dist |
UMAP | 0.1 | 0.0-0.99 | Cluster tightness |
n_components |
UMAP | 2 | 2-100 | Output dimensionality |
metric |
UMAP | 'euclidean' |
See metrics list | Distance calculation method |
spread |
UMAP | 1.0 | >0 | Embedding scale (with min_dist) |
n_epochs |
UMAP | None (auto) |
50-500+ | Training iterations |
learning_rate |
UMAP | 1.0 | >0 | SGD step size |
init |
UMAP | 'spectral' |
spectral/random/pca | Embedding initialization |
random_state |
UMAP | None |
int | Reproducibility seed |
target_weight |
UMAP | 0.5 | 0.0-1.0 | Label influence (supervised) |
densmap |
UMAP | False |
bool | Enable DensMAP |
dens_lambda |
UMAP | 2.0 | >0 | DensMAP density weight |
low_memory |
UMAP | True |
bool | Memory-efficient mode |
encoder |
ParametricUMAP | None |
Keras model | Custom encoder network |
decoder |
ParametricUMAP | None |
Keras model | Custom decoder network |
n_training_epochs |
ParametricUMAP | 1 | 1-100 | Neural network training epochs |
alignment_regularisation |
AlignedUMAP | 0.01 | >0 | Alignment strength |
alignment_window_size |
AlignedUMAP | 3 | 1-N | Adjacent datasets to align |
Best Practices
-
Always standardize features: Use
StandardScalerbefore UMAP — unscaled features with different ranges will dominate the embedding. -
Set
random_statefor reproducibility: UMAP uses stochastic optimization; results vary between runs without a fixed seed. -
Use different parameters for clustering vs visualization: Clustering needs
n_neighbors=30, min_dist=0.0, n_components=5-10. Visualization needsn_neighbors=15, min_dist=0.1, n_components=2. -
Anti-pattern — interpreting distances literally: UMAP preserves topology, not precise distances. Cluster separations and point distances in the embedding are not proportional to original distances.
-
Anti-pattern — using 2D embeddings for clustering: 2D projections lose density information. Use 5-10 components for HDBSCAN input.
-
Consider PCA preprocessing for very high dimensions: For data with >1000 features, reducing to 50-100 PCA components first can speed up UMAP without losing quality.
-
Use Parametric UMAP for production: When you need fast transform on new data or reconstruction capabilities, Parametric UMAP's neural network provides consistent, fast inference.
Common Recipes
Recipe: Custom Numba Distance Metric
from numba import njit
import umap
@njit()
def weighted_euclidean(x, y):
"""Custom distance with feature weights."""
result = 0.0
for i in range(x.shape[0]):
result += (x[i] - y[i]) ** 2 * (1.0 + i * 0.01) # increasing weight
return np.sqrt(result)
embedding = umap.UMAP(metric=weighted_euclidean, random_state=42).fit_transform(data)
Recipe: Precomputed Distance Matrix
import umap
from scipy.spatial.distance import pdist, squareform
# Compute custom distance matrix
dist_matrix = squareform(pdist(data, metric='correlation'))
# Use precomputed distances
embedding = umap.UMAP(
metric='precomputed', random_state=42
).fit_transform(dist_matrix)
print(f"Embedding from precomputed: {embedding.shape}")
Recipe: Metric Learning Pipeline
import umap
from sklearn.svm import SVC
# Train supervised embedding on labeled data
mapper = umap.UMAP(n_components=10, random_state=42)
train_emb = mapper.fit_transform(X_train, y=y_train)
# Transform unlabeled test data using learned metric
test_emb = mapper.transform(X_test)
# Downstream classifier
clf = SVC().fit(train_emb, y_train)
predictions = clf.predict(test_emb)
print(f"Accuracy: {(predictions == y_test).mean():.3f}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Disconnected/fragmented clusters | n_neighbors too low |
Increase n_neighbors (try 30-50) |
| Clusters too spread out | min_dist too high |
Decrease min_dist (try 0.0-0.05) |
| All points collapsed | Bad preprocessing or min_dist too low |
Check StandardScaler; increase min_dist |
| Poor clustering results | Using visualization parameters for clustering | Set n_neighbors=30, min_dist=0.0, n_components=5-10 |
| Transform results differ from training | Distribution shift | Ensure test data matches training distribution; use Parametric UMAP |
| Slow on large datasets (>100k) | Default settings | Set low_memory=True; preprocess with PCA to 50-100 dims |
| First run very slow | Numba JIT compilation | Expected — subsequent runs are fast (compiled cache) |
ImportError: umap |
Name conflict with umap package |
pip install umap-learn (not pip install umap) |
| Parametric UMAP import error | Missing TensorFlow | pip install umap-learn[parametric_umap] |
| Non-reproducible results | Missing random_state |
Always set random_state=42 (or any int) |
Bundled Resources
references/api_reference.md
Complete UMAP constructor parameter reference (60+ parameters organized by category: core, training, advanced structural, supervised, transform, performance, DensMAP), all methods and attributes, ParametricUMAP class with autoencoder parameters, AlignedUMAP class, utility functions (nearest_neighbors, fuzzy_simplicial_set). Core parameter tuning guidance was relocated to SKILL.md Key Concepts and Core API modules. Usage examples duplicating SKILL.md workflows omitted.
Related Skills
- scikit-learn-machine-learning — ML classifiers, preprocessing, pipelines for downstream tasks
- matplotlib-scientific-plotting — Visualization of UMAP embeddings
- scikit-bio — Biological distance matrices that can feed into UMAP via
metric='precomputed'
References
- McInnes L, Healy J, Melville J. UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arXiv:1802.03426
- Sainburg T, McInnes L, Gentner TQ. Parametric UMAP Embeddings for Representation and Semisupervised Learning. Neural Computation (2021)
- Narayan A, Berger B, Cho H. Assessing single-cell transcriptomic variability through density-preserving data visualization. Nature Biotechnology (2021) — DensMAP
- Official docs: https://umap-learn.readthedocs.io/
- GitHub: https://github.com/lmcinnes/umap
skills/scientific-computing/uspto-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill uspto-database -g -y
SKILL.md
Frontmatter
{
"name": "uspto-database",
"license": "CC0-1.0",
"description": "Access USPTO patent data via PatentsView REST API and Google Patents Public Data (BigQuery). Search by inventor, assignee, CPC, or keywords; download metadata and claims; analyze portfolios; track tech trends. For IP landscape analysis, competitor monitoring, prior art search, and tech forecasting in life sciences and biotech."
}
uspto-database
Overview
The USPTO provides two primary programmatic access points for patent data: the PatentsView API (REST, free, no key required for basic use) for structured queries by inventor, assignee, CPC classification, and keywords; and Google Patents Public Data (BigQuery public dataset) for large-scale analytics across the full patent corpus. Both expose data under the CC0 Public Domain Dedication. This skill covers Python-based access patterns for both, plus basic patent portfolio analytics.
When to Use
- Prior art search: Finding existing patents relevant to a technology before filing or to assess freedom-to-operate.
- Competitor IP landscape analysis: Querying all patents from a specific assignee (company or institution) to map their technology portfolio.
- CPC classification search: Finding patents in a specific technology area using Cooperative Patent Classification codes (e.g., C12N for nucleotides/genetic engineering).
- Inventor network analysis: Identifying prolific inventors in a field and their institutional affiliations.
- Technology trend tracking: Counting patent filings by year and technology category to identify emerging areas.
- Life sciences IP analysis: Searching biotech-specific classifications (A61K for pharmaceuticals, C12N for genetics, G16B for bioinformatics).
- For full-text patent PDF downloads, use the USPTO Bulk Data Storage System (BDSS) or Google Patents direct links.
- Rate limits: PatentsView API allows 45 requests/minute without an API key; request a free key for 45 req/min with higher daily limits.
Prerequisites
- Python packages:
requests,pandas,matplotlib - Optional:
google-cloud-bigqueryfor Google Patents Public Data queries - Data requirements: No account needed for PatentsView basic queries; Google Cloud account required for BigQuery
- Rate limits: PatentsView — 45 requests/minute (unauthenticated), higher with free API key
pip install requests pandas matplotlib
pip install google-cloud-bigquery # optional: for BigQuery access
Quick Start
import requests
import pandas as pd
# Search PatentsView API: patents assigned to "Genentech" in CPC class C12N
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"_and": [
{"_contains": {"assignee_organization": "Genentech"}},
{"_contains": {"cpc_subgroup_id": "C12N"}},
]},
"f": ["patent_number", "patent_title", "patent_date", "assignee_organization"],
"o": {"per_page": 25},
}
resp = requests.post(url, json=payload)
data = resp.json()
df = pd.DataFrame(data["patents"])
print(f"Found: {data['total_patent_count']} patents")
print(df[["patent_number", "patent_title", "patent_date"]].head())
Core API
Query Type 1: Search by Assignee (Company / Institution)
Find all patents granted to a specific organization.
import requests
import pandas as pd
def search_by_assignee(assignee_name: str, per_page: int = 100) -> pd.DataFrame:
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"_contains": {"assignee_organization": assignee_name}},
"f": [
"patent_number", "patent_title", "patent_date",
"patent_abstract", "assignee_organization", "assignee_country",
],
"o": {"per_page": per_page, "sort": [{"patent_date": "desc"}]},
}
resp = requests.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
df = pd.DataFrame(data.get("patents", []))
print(f"Assignee '{assignee_name}': {data.get('total_patent_count', 0)} total patents")
return df
# Example: patents from Broad Institute
df_broad = search_by_assignee("Broad Institute")
print(df_broad[["patent_number", "patent_title", "patent_date"]].head(10))
# Paginate through all results for large portfolios
def search_assignee_all_pages(assignee_name: str, page_size: int = 100) -> pd.DataFrame:
url = "https://api.patentsview.org/patents/query"
all_patents = []
page = 1
while True:
payload = {
"q": {"_contains": {"assignee_organization": assignee_name}},
"f": ["patent_number", "patent_title", "patent_date", "cpc_subgroup_id"],
"o": {"per_page": page_size, "page": page},
}
resp = requests.post(url, json=payload)
data = resp.json()
patents = data.get("patents", [])
if not patents:
break
all_patents.extend(patents)
total = data.get("total_patent_count", 0)
if len(all_patents) >= total:
break
page += 1
df = pd.DataFrame(all_patents)
print(f"Retrieved {len(df)} patents for '{assignee_name}'")
return df
Query Type 2: Search by CPC Classification
CPC (Cooperative Patent Classification) codes organize patents by technology. Life sciences codes include C12N (nucleotides/genetics), A61K (pharmaceuticals), and G16B (bioinformatics).
import requests
import pandas as pd
# Search by CPC subgroup: C12N15 (mutation/genetic engineering)
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"_begins": {"cpc_subgroup_id": "C12N15"}},
"f": [
"patent_number", "patent_title", "patent_date",
"assignee_organization", "cpc_subgroup_id",
],
"o": {"per_page": 50, "sort": [{"patent_date": "desc"}]},
}
resp = requests.post(url, json=payload)
data = resp.json()
df = pd.DataFrame(data["patents"])
print(f"C12N15 patents: {data['total_patent_count']}")
print(df[["patent_number", "patent_title", "assignee_organization"]].head(10))
# Common life sciences CPC codes
CPC_LIFE_SCIENCES = {
"C12N": "Microorganisms / enzymes / compositions",
"C12N15": "Mutation / genetic engineering",
"C12Q": "Measuring / testing involving enzymes or microorganisms",
"A61K": "Preparations for medical use",
"A61P": "Therapeutic activity of chemical compounds",
"G16B": "Bioinformatics",
"G16H": "Healthcare informatics",
"C07K": "Peptides / proteins",
}
for code, desc in CPC_LIFE_SCIENCES.items():
print(f" {code:10s}: {desc}")
Query Type 3: Full-Text Keyword Search
Search patent titles and abstracts for specific terms.
import requests
import pandas as pd
def keyword_search(keyword: str, per_page: int = 50) -> pd.DataFrame:
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"_or": [
{"_text_any": {"patent_title": keyword}},
{"_text_any": {"patent_abstract": keyword}},
]},
"f": [
"patent_number", "patent_title", "patent_date",
"patent_abstract", "assignee_organization",
],
"o": {"per_page": per_page, "sort": [{"patent_date": "desc"}]},
}
resp = requests.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
df = pd.DataFrame(data.get("patents", []))
print(f"Keyword '{keyword}': {data.get('total_patent_count', 0)} patents found")
return df
# Search for CRISPR-related patents
df_crispr = keyword_search("CRISPR")
print(df_crispr[["patent_number", "patent_title", "patent_date"]].head(10))
Query Type 4: Inventor Search
Find patents by inventor name or retrieve an inventor's full publication history.
import requests
import pandas as pd
# Search by inventor name
url = "https://api.patentsview.org/inventors/query"
payload = {
"q": {"_and": [
{"inventor_last_name": "Doudna"},
{"inventor_first_name": "Jennifer"},
]},
"f": ["inventor_id", "inventor_first_name", "inventor_last_name",
"inventor_city", "inventor_state", "inventor_country"],
"o": {"per_page": 10},
}
resp = requests.post(url, json=payload)
data = resp.json()
print(f"Found {data.get('total_inventor_count', 0)} inventors matching 'Jennifer Doudna'")
for inv in data.get("inventors", []):
print(f" ID: {inv['inventor_id']}, Location: {inv.get('inventor_city')}, {inv.get('inventor_country')}")
# Get all patents for a specific inventor by inventor_id
inventor_id = "fl:j_ln:doudna-1" # PatentsView inventor ID format
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"inventor_id": inventor_id},
"f": ["patent_number", "patent_title", "patent_date", "assignee_organization"],
"o": {"per_page": 100, "sort": [{"patent_date": "desc"}]},
}
resp = requests.post(url, json=payload)
data = resp.json()
df = pd.DataFrame(data.get("patents", []))
print(f"Patents for inventor {inventor_id}: {data.get('total_patent_count', 0)}")
print(df.head(5))
Query Type 5: Date Range and Combined Filters
Combine multiple filters for targeted searches.
import requests
import pandas as pd
# Patents in gene therapy (CPC A61K48) filed 2020-2024 by a US assignee
url = "https://api.patentsview.org/patents/query"
payload = {
"q": {"_and": [
{"_begins": {"cpc_subgroup_id": "A61K48"}},
{"_gte": {"patent_date": "2020-01-01"}},
{"_lte": {"patent_date": "2024-12-31"}},
{"_eq": {"assignee_country": "US"}},
]},
"f": [
"patent_number", "patent_title", "patent_date",
"assignee_organization", "patent_num_claims",
],
"o": {"per_page": 100, "sort": [{"patent_date": "desc"}]},
}
resp = requests.post(url, json=payload)
data = resp.json()
df = pd.DataFrame(data.get("patents", []))
print(f"Gene therapy patents 2020-2024 (US assignee): {data.get('total_patent_count', 0)}")
print(df[["patent_number", "patent_title", "patent_date", "assignee_organization"]].head(10))
Query Type 6: Google Patents BigQuery
For large-scale corpus analytics, use the public Google Patents dataset in BigQuery.
from google.cloud import bigquery
client = bigquery.Client(project="YOUR_GCP_PROJECT")
# Count CRISPR patents by year (Google Patents public data)
query = """
SELECT
EXTRACT(YEAR FROM filing_date) AS filing_year,
COUNT(*) AS patent_count,
COUNT(DISTINCT assignee) AS unique_assignees
FROM `patents-public-data.patents.publications`
WHERE
(LOWER(title_localized[SAFE_OFFSET(0)].text) LIKE '%crispr%'
OR LOWER(abstract_localized[SAFE_OFFSET(0)].text) LIKE '%crispr%')
AND filing_date >= '2010-01-01'
AND country_code = 'US'
GROUP BY filing_year
ORDER BY filing_year
"""
df_bq = client.query(query).to_dataframe()
print(df_bq)
print(f"Peak year: {df_bq.loc[df_bq.patent_count.idxmax(), 'filing_year']} "
f"({df_bq.patent_count.max()} patents)")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
per_page |
PatentsView "o" |
25 |
1–10000 |
Results per API call |
page |
PatentsView "o" |
1 |
1–max pages |
Page number for pagination |
sort |
PatentsView "o" |
API default | any field + "asc"/"desc" |
Sort order of results |
"f" fields |
PatentsView | minimal | any valid field list | Fields returned in response (controls payload size) |
"_begins" |
query operator | — | field + prefix string | Prefix match (e.g., CPC code prefix) |
"_contains" |
query operator | — | field + substring | Substring search (case-insensitive) |
"_text_any" |
query operator | — | field + keywords | Full-text search on title/abstract fields |
Best Practices
-
Request only the fields you need: The
"f"(fields) parameter controls what is returned. Requestingpatent_abstractfor thousands of patents significantly increases payload size and latency. -
Always handle pagination for large result sets: PatentsView caps responses at 10,000 per page maximum. For queries returning >10,000 results, use date-range slicing or narrower CPC codes to split the query.
-
Cache API responses to disk: PatentsView is rate-limited; if building a dataset iteratively, save responses to JSON/CSV after each API call.
import json, pathlib cache = pathlib.Path("cache") cache.mkdir(exist_ok=True) cache_file = cache / "genentech_patents.json" if not cache_file.exists(): resp = requests.post(url, json=payload) cache_file.write_text(resp.text) data = json.loads(cache_file.read_text()) -
Use CPC codes for technology-specific searches, not just keywords: Keywords miss synonyms and foreign-language patents; CPC codes are assigned by patent examiners and are more systematic.
-
Validate assignee names: Company names in patent records vary (e.g., "Genentech Inc.", "Genentech, Inc.", "GENENTECH INC"). Use
_containsfor fuzzy matching, then deduplicate in pandas.
Common Workflows
Workflow 1: Technology Landscape Analysis — Filing Trends by Year
Goal: Count patents filed in a CPC class by year and plot the trend.
import requests
import pandas as pd
import matplotlib.pyplot as plt
from collections import defaultdict
def count_patents_by_year(cpc_prefix: str, start_year: int = 2010) -> pd.DataFrame:
url = "https://api.patentsview.org/patents/query"
counts = defaultdict(int)
page = 1
while True:
payload = {
"q": {"_and": [
{"_begins": {"cpc_subgroup_id": cpc_prefix}},
{"_gte": {"patent_date": f"{start_year}-01-01"}},
]},
"f": ["patent_number", "patent_date"],
"o": {"per_page": 10000, "page": page},
}
resp = requests.post(url, json=payload)
patents = resp.json().get("patents", [])
if not patents:
break
for p in patents:
year = p["patent_date"][:4]
counts[year] += 1
total = resp.json().get("total_patent_count", 0)
if sum(counts.values()) >= total:
break
page += 1
df = pd.DataFrame(sorted(counts.items()), columns=["year", "count"])
return df
df_trend = count_patents_by_year("C12N15", start_year=2010)
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(df_trend["year"], df_trend["count"], color="steelblue", edgecolor="white")
ax.set_xlabel("Year")
ax.set_ylabel("Patents granted")
ax.set_title("US Patents: C12N15 (Genetic Engineering) by Year")
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig("cpc_trend.png", dpi=150)
print(f"Trend plotted: {df_trend['count'].sum()} total patents -> cpc_trend.png")
Workflow 2: Assignee Portfolio Comparison
Goal: Compare patent counts across multiple biotech companies in a target CPC class.
import requests
import pandas as pd
import matplotlib.pyplot as plt
def count_patents_by_assignee(assignees: list, cpc_prefix: str) -> pd.DataFrame:
url = "https://api.patentsview.org/patents/query"
records = []
for assignee in assignees:
payload = {
"q": {"_and": [
{"_contains": {"assignee_organization": assignee}},
{"_begins": {"cpc_subgroup_id": cpc_prefix}},
]},
"f": ["patent_number"],
"o": {"per_page": 1}, # only need total count
}
resp = requests.post(url, json=payload)
total = resp.json().get("total_patent_count", 0)
records.append({"assignee": assignee, "patent_count": total})
print(f" {assignee}: {total} patents")
df = pd.DataFrame(records).sort_values("patent_count", ascending=True)
return df
companies = ["Genentech", "Amgen", "Regeneron", "AstraZeneca", "Novartis"]
df_comp = count_patents_by_assignee(companies, cpc_prefix="A61K")
fig, ax = plt.subplots(figsize=(7, 4))
ax.barh(df_comp["assignee"], df_comp["patent_count"], color="salmon")
ax.set_xlabel("Patent count (A61K)")
ax.set_title("Pharmaceutical Patents by Assignee (CPC A61K)")
plt.tight_layout()
plt.savefig("assignee_comparison.png", dpi=150)
print("Comparison chart saved -> assignee_comparison.png")
Expected Outputs
pd.DataFramewith patent records (columns depend on requested"f"fields)cpc_trend.png— bar chart of patent counts by yearassignee_comparison.png— horizontal bar chart comparing companiestotal_patent_countin API response gives the full corpus size for a query
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTPError 429 Too Many Requests |
Exceeded 45 req/min rate limit | Add time.sleep(1.5) between requests; request a free API key |
Empty patents list in response |
Query too narrow or field name incorrect | Check field names in PatentsView API docs; test query in the web UI first |
| Results miss known patents | Exact string matching on assignee name | Use _contains instead of _eq; check for name variants |
KeyError: patent_date |
Field not requested in "f" list |
Add "patent_date" to the "f" array |
| BigQuery auth error | GCP credentials not configured | Run gcloud auth application-default login or set GOOGLE_APPLICATION_CREDENTIALS |
| CPC prefix returns no results | Invalid CPC code or typo | Verify code at CPC classification browser |
References
- PatentsView API Documentation — full field list and query operator reference
- PatentsView API Explorer — browser-based query builder for testing
- Google Patents Public Data (BigQuery) — schema documentation
- CPC Classification Browser — browse life sciences CPC codes
- USPTO Bulk Data Storage System — full-text patent XML downloads
skills/scientific-computing/vaex-dataframes/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill vaex-dataframes -g -y
SKILL.md
Frontmatter
{
"name": "vaex-dataframes",
"license": "MIT",
"description": "Out-of-core DataFrame for billion-row data via lazy evaluation and memory-mapped files. Use when data exceeds RAM (10 GB–TB) for fast aggregation, filtering, virtual columns, and visualization without loading. Supports HDF5, Arrow, Parquet, CSV with cloud (S3, GCS, Azure). Built-in ML transformers (scaling, PCA, K-means). In-memory: polars; distributed: dask."
}
Vaex DataFrames
Overview
Vaex is a high-performance Python library for lazy, out-of-core DataFrame operations on datasets too large to fit in RAM. It processes over a billion rows per second using memory-mapped files and lazy evaluation, enabling interactive exploration and analysis without loading data into memory.
When to Use
- Processing tabular datasets larger than available RAM (10 GB to terabytes)
- Fast statistical aggregations on massive datasets (mean, std, quantiles at billion-row scale)
- Creating visualizations (heatmaps, histograms) of large datasets without sampling
- Building ML preprocessing pipelines (scaling, encoding, PCA) on big data
- Converting between data formats (CSV to HDF5/Arrow for fast repeated access)
- Feature engineering with virtual columns that consume zero additional memory
- Working with astronomical catalogs, financial time series, or large scientific datasets
- For in-memory speed on data that fits in RAM, use polars instead
- For distributed multi-node computing, use dask instead
Prerequisites
pip install vaex
# Optional extras:
pip install vaex-hdf5 # HDF5 support (recommended)
pip install vaex-arrow # Apache Arrow support
pip install vaex-ml # Machine learning transformers
pip install vaex-viz # Visualization support
pip install vaex-jupyter # Jupyter widget support
pip install s3fs gcsfs adlfs # Cloud storage (S3, GCS, Azure)
Requires Python 3.7+. HDF5 and Arrow formats provide instant memory-mapped loading; CSV requires conversion for optimal performance.
Quick Start
import vaex
import numpy as np
df = vaex.from_arrays(
x=np.random.normal(0, 1, 1_000_000),
y=np.random.normal(0, 1, 1_000_000),
category=np.random.choice(['A', 'B', 'C'], 1_000_000),
)
df['radius'] = (df.x**2 + df.y**2).sqrt() # Virtual column, zero memory
df_inner = df[df.radius < 1.0] # Filtered view
print(df_inner.radius.mean()) # ~0.48
result = df.groupby('category').agg({'radius': 'mean'})
print(result) # shape: (3, 2)
df.export_hdf5('/tmp/sample.hdf5') # Export to efficient format
df2 = vaex.open('/tmp/sample.hdf5') # Future loads are instant
print(f"Loaded {len(df2):,} rows instantly")
Core API
1. DataFrame Creation and I/O
Create DataFrames from files, arrays, pandas, or Arrow tables. HDF5 and Arrow files are memory-mapped for instant loading.
import vaex
import numpy as np
# From files (HDF5/Arrow are instant via memory mapping)
df = vaex.open('data.hdf5') # Recommended: instant, memory-mapped
df = vaex.open('data.arrow') # Also instant, memory-mapped
df = vaex.open('data.parquet') # Fast, columnar, compressed
df = vaex.open('data_*.hdf5') # Wildcards: multiple files as one DataFrame
# From CSV (slow for large files — convert to HDF5)
df = vaex.from_csv('data.csv', convert='data.hdf5') # Auto-converts
# From Python objects
df = vaex.from_arrays(x=np.arange(100), y=np.random.rand(100))
df = vaex.from_dict({'name': ['Alice', 'Bob'], 'age': [30, 25]})
df = vaex.from_pandas(pd.DataFrame({'a': [1, 2, 3]}), copy_index=False)
# From Arrow table
import pyarrow as pa
df = vaex.from_arrow_table(pa.table({'x': [1, 2, 3]}))
# Inspect
print(df.shape) # (rows, cols)
print(df.column_names) # Column names
df.describe() # Statistical summary
# Export
df.export_hdf5('out.hdf5') # Recommended
df.export_arrow('out.arrow') # Interoperability
df.export_parquet('out.parquet', compression='snappy') # Compressed
df.export_parquet('s3://bucket/data.parquet') # Cloud storage
2. Filtering and Selection
Filter rows with boolean expressions. Named selections allow computing statistics on multiple subsets without creating new DataFrames.
import vaex
import numpy as np
df = vaex.from_arrays(
age=np.array([22, 35, 45, 19, 60]),
salary=np.array([30000, 70000, 90000, 25000, 120000]),
dept=np.array(['Eng', 'Sales', 'Eng', 'Sales', 'Eng']),
)
# Boolean filtering (creates a view, no copy)
df_eng_high = df[(df.dept == 'Eng') & (df.salary > 50000)]
print(len(df_eng_high)) # 2
# isin, between, string/null checks
df_mid = df[df.age.between(25, 50)]
# df[df.name.str.contains('Ali')], df[df.salary.notna()]
# Named selections (more efficient for multiple aggregations)
df.select(df.age >= 30, name='senior')
df.select(df.dept == 'Eng', name='engineers')
mean_senior = df.salary.mean(selection='senior')
mean_eng = df.salary.mean(selection='engineers')
print(f"Senior avg: {mean_senior}, Eng avg: {mean_eng}")
# Senior avg: 93333.33, Eng avg: 80000.0
3. Virtual Columns and Expressions
Virtual columns are computed on-the-fly with zero memory overhead. They are the core of Vaex's efficiency.
import vaex
import numpy as np
df = vaex.from_arrays(
price=np.array([10.0, 20.0, 30.0, 40.0]),
quantity=np.array([5, 3, 8, 2]),
discount=np.array([0.0, 0.1, 0.0, 0.2]),
)
# Arithmetic (virtual columns — no memory used)
df['revenue'] = df.price * df.quantity * (1 - df.discount)
df['log_price'] = df.price.log()
# Conditional logic
df['tier'] = (df.price >= 30).where('premium', 'standard')
# Math: .abs(), .sqrt(), .log(), .log10(), .exp(), .sin(), .cos(),
# .round(n), .floor(), .ceil(), .astype('float64')
# Check virtual vs materialized
print(df.get_column_names(virtual=False)) # Materialized only
# Materialize when needed (complex expr used repeatedly)
df['revenue_mat'] = df.revenue.values # Now stored in memory
4. Aggregation and GroupBy
Efficient aggregations across billions of rows. Use delay=True to batch multiple operations into a single data pass.
import vaex
import numpy as np
np.random.seed(42)
n = 100_000
df = vaex.from_arrays(
sales=np.random.uniform(10, 500, n),
quantity=np.random.randint(1, 20, n),
region=np.random.choice(['East', 'West', 'North'], n),
)
# Single-column aggregations
print(f"Mean: {df.sales.mean():.2f}, Std: {df.sales.std():.2f}")
# Also: .min(), .max(), .minmax(), .sum(), .count(), .nunique(),
# .quantile(0.5), .median_approx(), .kurtosis(), .skew()
# .correlation(df.x, df.y), .covar(df.x, df.y)
# Batch aggregations with delay=True (single pass through data)
mean_s = df.sales.mean(delay=True)
std_s = df.sales.std(delay=True)
sum_q = df.quantity.sum(delay=True)
results = vaex.execute([mean_s, std_s, sum_q])
print(f"Mean: {results[0]:.2f}, Std: {results[1]:.2f}, Total qty: {results[2]}")
# GroupBy
grouped = df.groupby('region').agg({'sales': ['sum', 'mean'], 'quantity': 'sum'})
print(grouped) # shape: (3, 4)
# Multi-dimensional binned aggregation (for heatmap data)
counts = df.count(binby=[df.sales, df.quantity],
limits=[[0, 500], [1, 20]], shape=(50, 19))
print(f"2D histogram shape: {counts.shape}") # (50, 19)
5. String and DateTime Operations
String methods via .str accessor; datetime methods via .dt accessor.
import vaex
import numpy as np
# String operations via .str accessor
df = vaex.from_dict({
'name': ['Alice Smith', 'Bob Jones', 'Charlie Brown'],
'email': ['ALICE@test.com', 'bob@TEST.com', 'charlie@test.com'],
})
df['email_clean'] = df.email.str.lower().str.strip()
df['first_name'] = df.name.str.split(' ')[0]
df['has_test'] = df.email_clean.str.contains('test')
# Also: .upper(), .title(), .startswith(), .endswith(), .len(),
# .replace(), .pad(), .slice(start, end)
# DateTime operations via .dt accessor
dates = np.array(['2024-01-15', '2024-06-20', '2024-12-01'], dtype='datetime64')
df2 = vaex.from_arrays(timestamp=dates, value=np.array([100, 200, 300]))
df2['year'] = df2.timestamp.dt.year
df2['month'] = df2.timestamp.dt.month
df2['weekday'] = df2.timestamp.dt.dayofweek # 0=Monday
# Also: .day, .hour, .minute, .second
print(df2[['timestamp', 'year', 'month']].head(3))
6. Visualization
Vaex visualizes billion-row datasets through efficient binning, using all data without sampling.
import vaex
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(0)
df = vaex.from_arrays(
x=np.random.normal(0, 1, 500_000),
y=np.random.normal(0, 1, 500_000),
z=np.random.uniform(0, 10, 500_000),
)
# 1D histogram
df.plot1d(df.x, limits=[-4, 4], shape=80, figsize=(8, 4))
plt.title('X Distribution')
plt.savefig('hist1d.png', dpi=150, bbox_inches='tight')
plt.close()
# 2D density heatmap (core vaex visualization)
df.plot(df.x, df.y, limits='99.7%', shape=(256, 256),
f='log', colormap='viridis', figsize=(8, 8))
plt.savefig('heatmap2d.png', dpi=150, bbox_inches='tight')
plt.close()
# Aggregation on grid (mean of z over x-y plane)
df.plot(df.x, df.y, what=df.z.mean(),
limits=[[-3, 3], [-3, 3]], shape=(100, 100))
plt.savefig('mean_grid.png', dpi=150, bbox_inches='tight')
plt.close()
print("Saved: hist1d.png, heatmap2d.png, mean_grid.png")
7. ML Integration
vaex.ml provides transformers for preprocessing, dimensionality reduction, clustering, and scikit-learn model wrapping. All transformers create virtual columns (zero memory overhead).
import vaex, vaex.ml
import numpy as np
np.random.seed(42)
n = 10_000
df = vaex.from_arrays(
age=np.random.randint(18, 70, n).astype(float),
income=np.random.uniform(20000, 150000, n),
category=np.random.choice(['A', 'B', 'C'], n),
target=np.random.randint(0, 2, n),
)
# Feature scaling (creates virtual columns: standard_scaled_age, ...)
scaler = vaex.ml.StandardScaler(features=['age', 'income'])
df = scaler.fit_transform(df)
# Also: MinMaxScaler, MaxAbsScaler, RobustScaler
# Categorical encoding
encoder = vaex.ml.LabelEncoder(features=['category'])
df = encoder.fit_transform(df)
# Also: OneHotEncoder, FrequencyEncoder, TargetEncoder, WeightOfEvidenceEncoder
# PCA
pca = vaex.ml.PCA(features=['standard_scaled_age', 'standard_scaled_income'],
n_components=2)
df = pca.fit_transform(df)
print(f"Explained variance: {pca.explained_variance_ratio_}")
# Scikit-learn bridge: wrap any sklearn model
from sklearn.ensemble import RandomForestClassifier
features = ['standard_scaled_age', 'standard_scaled_income',
'label_encoded_category']
model = vaex.ml.sklearn.Predictor(
features=features, target='target',
model=RandomForestClassifier(n_estimators=50, random_state=42),
prediction_name='rf_prediction',
)
train_df, test_df = df[:8000], df[8000:]
model.fit(train_df)
test_df = model.transform(test_df) # Predictions as virtual columns
accuracy = (test_df.rf_prediction == test_df.target).mean()
print(f"Accuracy: {accuracy:.3f}") # ~0.50 (random data)
# Save pipeline state (encoding + scaling + model)
train_df.state_write('pipeline_state.json')
# Deploy: prod_df.state_load('pipeline_state.json')
Key Concepts
Lazy Evaluation Model
Vaex operations build an expression graph without executing computation. Evaluation is triggered only when a result is accessed (printing a value, calling .values, exporting).
import vaex, numpy as np
df = vaex.from_arrays(x=np.random.rand(1_000_000))
# NOT computed: virtual column, expression, filtered view
df['x_sq'] = df.x ** 2; expr = df.x_sq.mean(); df_f = df[df.x > 0.5]
# COMPUTED: accessing value, .values, .to_pandas_df(), .export_hdf5()
print(f"Mean: {df.x.mean():.4f}")
Virtual vs Materialized Columns
| Aspect | Virtual | Materialized |
|---|---|---|
| Memory | Zero overhead | Stores full array |
| Speed | Recomputed each use | Instant access |
| Creation | df['col'] = expr |
df['col'] = expr.values |
| Best for | Simple expressions, infrequent use | Complex expressions used repeatedly |
| Check | df.is_local('col') returns False |
Returns True |
Rule of thumb: Keep columns virtual unless the same complex expression is used in 3+ aggregations.
Memory-Mapped File Architecture
HDF5 and Apache Arrow files are memory-mapped: the OS maps file pages to virtual memory on demand, so opening a 100 GB file is instant and uses minimal RAM. Data pages are read from disk only when accessed.
# Opens instantly regardless of file size
df = vaex.open('100gb_dataset.hdf5') # ~0.001s, minimal RAM
mean = df.column.mean() # Streams through data, ~RSS stays low
Format Comparison
| Feature | HDF5 | Arrow/Feather | Parquet | CSV |
|---|---|---|---|---|
| Load speed | Instant | Instant | Fast | Slow |
| Memory-mapped | Yes | Yes | No | No |
| Compression | Optional (gzip, lzf, blosc) | No | Default (snappy, gzip, brotli) | No |
| Columnar | Yes | Yes | Yes | No |
| Portability | Good | Excellent | Excellent | Excellent |
| Best for | Local Vaex workflows | Cross-language interop | Distributed systems | Data exchange |
Recommendation: Convert CSV to HDF5 once (vaex.from_csv('data.csv', convert='data.hdf5')), then use HDF5 for all future loads.
Common Workflows
Workflow 1: Large CSV Exploration and Conversion
import vaex
import matplotlib.pyplot as plt
# Convert CSV to HDF5 (one-time); future loads instant
df = vaex.from_csv('large_data.csv', convert='large_data.hdf5')
print(f"Shape: {df.shape}, Columns: {df.column_names}")
# Feature engineering with virtual columns
df['log_value'] = df.value.log()
df['category_clean'] = df.category.str.lower().str.strip()
df['is_high'] = df.value > df.value.mean()
# Batch statistics (single pass)
delayed = [df.value.mean(delay=True), df.value.std(delay=True),
df.value.quantile(0.5, delay=True), df.value.quantile(0.99, delay=True)]
results = vaex.execute(delayed)
print(f"Mean: {results[0]:.2f}, Std: {results[1]:.2f}, P99: {results[3]:.2f}")
# Visualize
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
df.plot1d(df.value, ax=axes[0], show=False)
axes[0].set_title('Value Distribution')
df.plot1d(df.log_value, ax=axes[1], show=False)
axes[1].set_title('Log Value Distribution')
plt.tight_layout()
plt.savefig('exploration.png', dpi=150, bbox_inches='tight')
plt.close()
Workflow 2: ML Pipeline with State Deployment
import vaex, vaex.ml
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
np.random.seed(42)
n = 50_000
df = vaex.from_arrays(
age=np.random.randint(18, 70, n).astype(float),
income=np.random.uniform(20000, 150000, n),
region=np.random.choice(['East', 'West', 'Central'], n),
target=np.random.randint(0, 2, n),
)
train, test = df[:40_000], df[40_000:]
# Preprocessing pipeline
train = vaex.ml.LabelEncoder(features=['region']).fit_transform(train)
train = vaex.ml.StandardScaler(features=['age', 'income']).fit_transform(train)
# Train model
features = ['standard_scaled_age', 'standard_scaled_income', 'label_encoded_region']
model = vaex.ml.sklearn.Predictor(
features=features, target='target',
model=GradientBoostingClassifier(n_estimators=50, random_state=42),
prediction_name='prediction',
)
model.fit(train)
# Save pipeline state (encoding + scaling + model in one file)
train.state_write('ml_pipeline.json')
# Deploy: apply saved state to new data
test.state_load('ml_pipeline.json')
accuracy = (test.prediction == test.target).mean()
print(f"Test accuracy: {accuracy:.3f}") # ~0.50 (random data)
# Production: prod_df = vaex.open('new_batch.hdf5'); prod_df.state_load('ml_pipeline.json')
Key Parameters
| Parameter | Module | Default | Range/Options | Effect |
|---|---|---|---|---|
shape |
plot, plot1d |
64 (1D), (256,256) (2D) | 32-2048 | Histogram bin count / heatmap resolution |
limits |
plot, plot1d |
'minmax' |
'99%', '99.7%', [min,max] |
Axis ranges; percentile-based for outlier handling |
f |
plot |
'identity' |
'log', 'log10', 'sqrt' |
Color scale transform for density plots |
delay |
aggregations | False |
True/False |
Batch multiple aggregations into single pass |
convert |
from_csv |
None |
file path string | Auto-convert CSV to HDF5 during load |
chunk_size |
from_csv |
5,000,000 | 100K-50M | Rows per chunk for CSV processing |
n_components |
PCA |
2 | 1-n_features | Number of principal components |
n_clusters |
KMeans |
8 | 2-100+ | Number of clusters |
features |
all ML transformers | required | list of column names | Columns to transform |
compression |
export_hdf5 |
None | 'gzip', 'lzf', 'blosc' |
Trade file size for I/O speed |
Best Practices
-
Always convert CSV to HDF5 or Arrow for repeated access. One-time conversion pays for itself on the first reload:
vaex.from_csv('data.csv', convert='data.hdf5'). -
Keep columns virtual until you must materialize. Virtual columns have zero memory cost. Materialize only when a complex expression is reused in 3+ aggregations.
-
Batch aggregations with
delay=True. Each separate aggregation call scans the entire dataset. Batching withvaex.execute([df.x.mean(delay=True), df.x.std(delay=True)])reduces N passes to 1. -
Use selections instead of creating filtered DataFrames when computing statistics on multiple subsets.
df.select(df.age > 30, name='senior')thendf.salary.mean(selection='senior')is more efficient than creatingdf_senior = df[df.age > 30]. -
Avoid
.valuesand.to_pandas_df()on large data. These load data into RAM, defeating Vaex's purpose. Use only on small subsets or samples. -
Save pipeline state for reproducibility.
df.state_write('state.json')captures virtual columns, selections, and ML transformers for deployment. -
Anti-pattern -- row iteration. Never iterate rows in Vaex. Use vectorized expressions and aggregations instead.
Common Recipes
Recipe: Multi-Source Data Consolidation
import vaex
# Load from multiple sources and formats
df_csv = vaex.from_csv('data_2022.csv')
df_hdf = vaex.open('data_2023.hdf5')
df_pq = vaex.open('data_2024.parquet')
# Concatenate vertically
df_all = vaex.concat([df_csv, df_hdf, df_pq])
print(f"Combined: {len(df_all):,} rows")
# Export as unified HDF5
df_all.export_hdf5('unified_data.hdf5')
# Future: vaex.open('unified_data.hdf5')
Recipe: Handling Missing Data
import vaex
import numpy as np
df = vaex.from_arrays(
x=np.array([1.0, np.nan, 3.0, np.nan, 5.0]),
y=np.array([10, 20, 30, 40, 50]),
)
# Detect missing
missing_pct = df.x.isna().mean() * 100
print(f"Missing: {missing_pct:.0f}%")
# Missing: 40%
# Fill with value or column mean
df['x_filled'] = df.x.fillna(df.x.mean())
# Filter out missing
df_clean = df[df.x.notna()]
print(f"Clean rows: {len(df_clean)}")
# Clean rows: 3
Recipe: Comparison Visualization with Selections
import vaex
import numpy as np
import matplotlib.pyplot as plt
df = vaex.from_arrays(
value=np.concatenate([
np.random.normal(50, 10, 200_000),
np.random.normal(70, 15, 200_000),
]),
group=np.array(['Control'] * 200_000 + ['Treatment'] * 200_000),
)
# Named selections for efficient comparison
df.select(df.group == 'Control', name='ctrl')
df.select(df.group == 'Treatment', name='treat')
plt.figure(figsize=(10, 5))
df.plot1d(df.value, selection='ctrl', label='Control', show=False)
df.plot1d(df.value, selection='treat', label='Treatment', show=False)
plt.legend()
plt.title('Distribution Comparison')
plt.savefig('comparison.png', dpi=150, bbox_inches='tight')
plt.close()
print("Saved comparison.png")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| CSV loading extremely slow | CSV is not memory-mappable; parsed row-by-row | Convert once: vaex.from_csv('data.csv', convert='data.hdf5'). Use HDF5 for all future loads |
MemoryError on simple operations |
Calling .values or .to_pandas_df() on full dataset |
Keep operations lazy. Use .sample(n=1000).to_pandas_df() for inspection |
| Empty or all-white 2D plot | Axis limits don't match data range | Use limits='99.7%' or limits='minmax' instead of manual limits |
| Heatmap shows only one bright spot | Linear color scale overwhelmed by high-density region | Use f='log' for logarithmic color scaling |
| Virtual column recomputes slowly in loop | Complex expression recomputed on every access | Materialize: df['col'] = df.complex_expr.values |
FileNotFoundError with cloud paths |
Missing filesystem library | Install s3fs (S3), gcsfs (GCS), or adlfs (Azure) |
| Slow export with many virtual columns | Each virtual column recomputed during export | Materialize first: df.materialize().export_hdf5('out.hdf5') |
| Column shows as string when numeric expected | CSV auto-detection chose wrong type | Cast: df['col_num'] = df.col.astype('float64') |
state_load fails on new data |
Column names in state don't match new DataFrame | Ensure new data has identical column names as the training data |
Bundled Resources
This entry includes two reference files in references/:
-
references/io_performance.md-- Consolidated from originalio_operations.md(704 lines) andperformance.md(572 lines). Covers: format-specific I/O details (HDF5 compression options, Parquet compression, Arrow integration, FITS), chunked I/O processing, cloud storage (S3/GCS/Azure with credentials), Vaex server (remote data), database integration (SQL read/write via pandas bridge), state files (save/load pipeline state), memory management, parallel computation (multithreading, Dask integration), JIT compilation with Numba, async operations, and detailed profiling/benchmarking patterns. Relocated inline: format comparison table, CSV-to-HDF5 conversion pattern,delay=Truebatching, memory-mapped architecture explanation, basic export methods. Omitted: redundant "Best Practices" lists that duplicated content already in the main file; "Related Resources" cross-links (superseded by Bundled Resources section). -
references/ml_visualization.md-- Consolidated from originalmachine_learning.md(729 lines) andvisualization.md(614 lines). Covers: full ML transformer catalog (MinMaxScaler, MaxAbsScaler, RobustScaler, FrequencyEncoder, TargetEncoder, WeightOfEvidenceEncoder, CycleTransformer, Discretizer, RandomProjection), KMeans clustering, external library integration (XGBoost, LightGBM, CatBoost, Keras), cross-validation, feature selection, imbalanced data handling, advanced visualization (contour plots, vector field overlays, interactive Jupyter widgets, faceted plots, batch plotting, Plotly/seaborn integration). Relocated inline: StandardScaler, LabelEncoder, OneHotEncoder, PCA, scikit-learn Predictor wrapper, basic plot1d/plot/what patterns, selection-based visualization. Omitted: model evaluation metrics section (standard sklearn metrics, not vaex-specific -- use scikit-learn directly); "Related Resources" cross-links.
Original reference file disposition:
core_dataframes.md(368 lines) -- fully consolidated into Core API Module 1 (DataFrame Creation & I/O) and Key Concepts. Combined coverage: ~65 lines inline covering all creation methods (open, from_csv, from_arrays, from_dict, from_pandas, from_arrow_table), inspection, export, and expression basics. Omitted: detailed row/column manipulation patterns (covered in Module 2-4), copy/concat patterns (covered in Recipes).data_processing.md(556 lines) -- fully consolidated into Core API Modules 2-5. Filtering, virtual columns, expressions, aggregation, groupby, string ops, datetime ops, missing data, joining, column management all represented inline. Omitted: advanced binning (searchsorted patterns, statistical binning details) -- niche usage, consult official docs.
Related Skills
- polars-dataframes -- In-memory DataFrame library; use when data fits in RAM for 10-100x faster processing
- dask-parallel-computing -- Distributed computing for multi-node clusters and parallel pandas/NumPy
- pandas (planned) -- Standard Python DataFrame library; Vaex interoperates via
from_pandas/to_pandas_df
References
- Vaex documentation: https://vaex.io/docs/latest/
- Vaex GitHub repository: https://github.com/vaexio/vaex
- Vaex PyPI: https://pypi.org/project/vaex/
- Breddels & Veljanoski (2018), "Vaex: Big Data exploration in the era of Gaia", Astronomy & Astrophysics, https://doi.org/10.1051/0004-6361/201732493
skills/scientific-computing/zarr-python/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill zarr-python -g -y
SKILL.md
Frontmatter
{
"name": "zarr-python",
"license": "MIT",
"description": "Chunked N-D arrays with compression and cloud storage. NumPy-style indexing. Backends: local, S3, GCS, ZIP, memory. Dask\/Xarray integration for parallel and labeled computation. For lineage use lamindb; for labeled arrays use xarray."
}
Zarr Python — Chunked N-D Arrays
Overview
Zarr is a Python library for storing large N-dimensional arrays with chunking, compression, and parallel I/O. It provides NumPy-compatible indexing with pluggable storage backends (local, cloud, in-memory), making it the standard format for cloud-native scientific data pipelines.
When to Use
- Storing arrays too large for memory with chunked access (out-of-core computing)
- Cloud-native data workflows with S3 or GCS storage backends
- Parallel read/write with Dask for large-scale computation
- Hierarchical data organization (groups of named arrays with metadata)
- Converting between formats (HDF5 → Zarr, NetCDF → Zarr)
- Appending time-series data incrementally without rewriting
- For labeled, coordinate-aware arrays (time, lat, lon), use xarray with Zarr backend instead
- For data management, lineage, and ontology validation, use lamindb (which uses Zarr as a storage format)
Prerequisites
pip install zarr
# Cloud storage support
pip install s3fs # Amazon S3
pip install gcsfs # Google Cloud Storage
Requires Python 3.11+.
Quick Start
import zarr
import numpy as np
# Create a chunked, compressed 2D array
z = zarr.create_array(
store="data/my_array.zarr",
shape=(10000, 10000),
chunks=(1000, 1000),
dtype="f4"
)
# Write with NumPy-style indexing
z[:, :] = np.random.random((10000, 10000)).astype("f4")
# Read a slice (only reads needed chunks)
subset = z[0:100, 0:100]
print(f"Shape: {subset.shape}, dtype: {subset.dtype}")
# Shape: (100, 100), dtype: float32
Core API
1. Array Creation
import zarr
import numpy as np
# Empty arrays
z = zarr.zeros(shape=(10000, 10000), chunks=(1000, 1000), dtype="f4", store="data.zarr")
z = zarr.ones((5000, 5000), chunks=(500, 500), dtype="f4")
z = zarr.full((1000, 1000), fill_value=42, chunks=(100, 100), dtype="i4")
# From existing NumPy data
data = np.arange(10000, dtype="f4").reshape(100, 100)
z = zarr.array(data, chunks=(10, 10), store="from_numpy.zarr")
print(f"Created: shape={z.shape}, chunks={z.chunks}, dtype={z.dtype}")
# Create like another array (matches shape, chunks, dtype)
z2 = zarr.zeros_like(z)
# Open existing array
z = zarr.open_array("data.zarr", mode="r+") # Read-write
z = zarr.open_array("data.zarr", mode="r") # Read-only
z = zarr.open("data.zarr") # Auto-detect array vs group
2. Reading, Writing, and Indexing
import zarr
import numpy as np
z = zarr.zeros((10000, 10000), chunks=(1000, 1000), dtype="f4")
# Write slices
z[0, :] = np.arange(10000, dtype="f4")
z[10:20, 50:60] = np.random.random((10, 10)).astype("f4")
z[:] = 42 # Fill entire array
# Read slices (returns NumPy array)
row = z[5, :]
block = z[0:100, 0:100]
print(f"Row shape: {row.shape}, block shape: {block.shape}")
# Advanced indexing
z.vindex[[0, 5, 10], [2, 8, 15]] # Coordinate (fancy) indexing
z.oindex[0:10, [5, 10, 15]] # Orthogonal indexing
z.blocks[0, 0] # Block/chunk indexing
# Resize and append
z.resize(15000, 15000)
z.append(np.random.random((1000, 10000)).astype("f4"), axis=0)
3. Chunking and Sharding
Chunk shape is the most important performance parameter.
import zarr
from zarr.codecs import ShardingCodec
# Chunk aligned with access pattern
# Row-wise access → chunk spans columns
z_row = zarr.zeros((10000, 10000), chunks=(10, 10000), dtype="f4")
# Column-wise access → chunk spans rows
z_col = zarr.zeros((10000, 10000), chunks=(10000, 10), dtype="f4")
# Mixed access → balanced square chunks (~1MB each for float32)
z_bal = zarr.zeros((10000, 10000), chunks=(512, 512), dtype="f4")
# 512*512*4 bytes = ~1MB per chunk
# Sharding: group small chunks into larger storage objects
# Useful when millions of small chunks cause filesystem overhead
z_sharded = zarr.create_array(
store="sharded.zarr",
shape=(100000, 100000),
chunks=(100, 100), # Small chunks for fine-grained access
shards=(1000, 1000), # Groups 100 chunks per shard
dtype="f4"
)
print(f"Chunks: {z_sharded.chunks}, shards reduce file count")
Chunk size guidelines:
- Target 1–10 MB per chunk (minimum 1 MB)
- Align chunk shape with your most common access pattern
- Entire chunks load into memory during read → don't exceed available RAM
- Entire shards load into memory during write
4. Compression
from zarr.codecs.blosc import BloscCodec
from zarr.codecs import GzipCodec, ZstdCodec, BytesCodec
import zarr
# Default: Blosc with Zstandard (good balance)
z = zarr.zeros((1000, 1000), chunks=(100, 100), dtype="f4")
# Explicit Blosc configuration
z = zarr.create_array(
store="compressed.zarr",
shape=(1000, 1000), chunks=(100, 100), dtype="f4",
codecs=[BloscCodec(cname="zstd", clevel=5, shuffle="shuffle")]
)
# Speed-optimized (LZ4)
z_fast = zarr.create_array(
store="fast.zarr",
shape=(1000, 1000), chunks=(100, 100), dtype="f4",
codecs=[BloscCodec(cname="lz4", clevel=1)]
)
# Maximum compression (Gzip level 9)
z_small = zarr.create_array(
store="small.zarr",
shape=(1000, 1000), chunks=(100, 100), dtype="f4",
codecs=[GzipCodec(level=9)]
)
# No compression
z_raw = zarr.create_array(
store="raw.zarr",
shape=(1000, 1000), chunks=(100, 100), dtype="f4",
codecs=[BytesCodec()]
)
Codec selection: Blosc/Zstd (default, balanced) → LZ4 (fastest) → Gzip (smallest). Enable shuffle="shuffle" for numeric data — it reorders bytes for better compression ratios.
5. Storage Backends
import zarr
import numpy as np
from zarr.storage import LocalStore, MemoryStore, ZipStore
# Local filesystem (default — string paths create LocalStore automatically)
z = zarr.open_array("data/array.zarr", mode="w", shape=(1000, 1000),
chunks=(100, 100), dtype="f4")
# In-memory (not persisted)
store = MemoryStore()
z_mem = zarr.open_array(store=store, mode="w", shape=(1000, 1000),
chunks=(100, 100), dtype="f4")
# ZIP file storage
store = ZipStore("data.zip", mode="w")
z_zip = zarr.open_array(store=store, mode="w", shape=(1000, 1000),
chunks=(100, 100), dtype="f4")
z_zip[:] = np.random.random((1000, 1000)).astype("f4")
store.close() # IMPORTANT: must close ZipStore
# Cloud storage: Amazon S3
import s3fs
s3 = s3fs.S3FileSystem(anon=False)
store = s3fs.S3Map(root="my-bucket/path/array.zarr", s3=s3)
z = zarr.open_array(store=store, mode="w", shape=(1000, 1000),
chunks=(500, 500), dtype="f4")
z[:] = np.random.random((1000, 1000)).astype("f4")
# Consolidate metadata for faster subsequent reads
zarr.consolidate_metadata(store)
# Google Cloud Storage
import gcsfs
gcs = gcsfs.GCSFileSystem(project="my-project")
store = gcsfs.GCSMap(root="my-bucket/path/array.zarr", gcs=gcs)
Cloud best practices: consolidate metadata, use 5–100 MB chunks, enable sharding to reduce object count, use Dask for parallel I/O.
6. Groups and Hierarchies
import zarr
# Create hierarchical structure (like HDF5 groups)
root = zarr.group(store="hierarchy.zarr")
# Create sub-groups
temperature = root.create_group("temperature")
precipitation = root.create_group("precipitation")
# Create arrays within groups
temp_arr = temperature.create_array(
name="t2m",
shape=(365, 720, 1440),
chunks=(1, 720, 1440),
dtype="f4"
)
precip_arr = precipitation.create_array(
name="prcp",
shape=(365, 720, 1440),
chunks=(1, 720, 1440),
dtype="f4"
)
# Access by path
arr = root["temperature/t2m"]
print(root.tree())
# /
# ├── temperature
# │ └── t2m (365, 720, 1440) f4
# └── precipitation
# └── prcp (365, 720, 1440) f4
7. Attributes and Metadata
import zarr
z = zarr.zeros((1000, 1000), chunks=(100, 100), dtype="f4")
# Attach metadata (must be JSON-serializable)
z.attrs["description"] = "Temperature data in Kelvin"
z.attrs["units"] = "K"
z.attrs["processing_version"] = 2.1
print(z.attrs["units"]) # K
# Group-level attributes
root = zarr.group("data.zarr")
root.attrs["project"] = "Climate Analysis"
root.attrs["institution"] = "Research Institute"
Key Concepts
Chunk Size Selection Guide
| Data Shape | Access Pattern | Recommended Chunks | Rationale |
|---|---|---|---|
| (N, M) 2D | Row-wise | (small, M) | Each chunk spans full row |
| (N, M) 2D | Column-wise | (N, small) | Each chunk spans full column |
| (N, M) 2D | Random/mixed | (√(1MB/dtype), √(1MB/dtype)) | Balanced ~1MB per chunk |
| (T, H, W) time series | Time slice | (1, H, W) | One timestep per chunk |
| (T, H, W) time series | Spatial region | (T, small, small) | Full time for region |
1 MB rule: For float32 (4 bytes), 1 MB = 262,144 elements. For float64 (8 bytes), 1 MB = 131,072 elements.
Consolidated Metadata
For stores with many arrays (10+), consolidate metadata into a single read:
zarr.consolidate_metadata("data.zarr")
root = zarr.open_consolidated("data.zarr") # Single metadata read
Critical for cloud storage (reduces N metadata requests to 1). Caveat: becomes stale if arrays update without re-consolidation.
Common Workflows
Workflow: Cloud-Native Data Pipeline
import zarr
import numpy as np
import s3fs
# Step 1: Write to S3 with cloud-optimized chunks
s3 = s3fs.S3FileSystem()
store = s3fs.S3Map(root="s3://my-bucket/experiment.zarr", s3=s3)
root = zarr.group(store=store)
data_arr = root.create_array(
name="measurements",
shape=(10000, 10000),
chunks=(500, 500), # ~1MB chunks, good for cloud
dtype="f4"
)
data_arr[:] = np.random.random((10000, 10000)).astype("f4")
data_arr.attrs["experiment"] = "batch_42"
# Step 2: Consolidate metadata
zarr.consolidate_metadata(store)
# Step 3: Read from anywhere
store_read = s3fs.S3Map(root="s3://my-bucket/experiment.zarr", s3=s3)
root_read = zarr.open_consolidated(store_read)
subset = root_read["measurements"][0:100, 0:100]
print(f"Read subset: {subset.shape}")
Workflow: Dask Parallel Computation
import dask.array as da
import zarr
import numpy as np
# Step 1: Create large Zarr array
z = zarr.open("large_data.zarr", mode="w", shape=(100000, 100000),
chunks=(1000, 1000), dtype="f4")
# (populate with data...)
# Step 2: Load as Dask array (lazy — no data loaded yet)
dask_arr = da.from_zarr("large_data.zarr")
print(f"Dask array: {dask_arr.shape}, {dask_arr.npartitions} partitions")
# Step 3: Compute in parallel (out-of-core)
col_means = dask_arr.mean(axis=0).compute()
print(f"Column means: {col_means.shape}")
# Step 4: Write Dask result back to Zarr
large_random = da.random.random((100000, 100000), chunks=(1000, 1000))
da.to_zarr(large_random, "output.zarr")
Workflow: Xarray-Zarr for Labeled Data
import xarray as xr
import numpy as np
import pandas as pd
# Step 1: Create labeled dataset
ds = xr.Dataset(
{
"temperature": (["time", "lat", "lon"], np.random.random((365, 180, 360)).astype("f4")),
"precipitation": (["time", "lat", "lon"], np.random.random((365, 180, 360)).astype("f4")),
},
coords={
"time": pd.date_range("2024-01-01", periods=365),
"lat": np.arange(-90, 90, 1.0),
"lon": np.arange(-180, 180, 1.0),
}
)
# Step 2: Save to Zarr
ds.to_zarr("climate.zarr")
# Step 3: Open with lazy loading
ds_loaded = xr.open_zarr("climate.zarr")
print(ds_loaded)
# Step 4: Label-based selection (only reads needed chunks)
subset = ds_loaded.sel(time="2024-06", lat=slice(30, 60))
print(f"June subset: {subset['temperature'].shape}")
Workflow: Format Conversion
import zarr
import numpy as np
# HDF5 → Zarr
import h5py
with h5py.File("data.h5", "r") as h5:
z = zarr.array(h5["dataset_name"][:], chunks=(1000, 1000), store="from_hdf5.zarr")
# NumPy → Zarr
data = np.load("data.npy")
z = zarr.array(data, chunks="auto", store="from_numpy.zarr")
# Zarr → NetCDF (via Xarray)
import xarray as xr
ds = xr.open_zarr("data.zarr")
ds.to_netcdf("data.nc")
Key Parameters
| Parameter | Module | Default | Options | Effect |
|---|---|---|---|---|
shape |
create_array |
Required | Tuple of ints | Array dimensions |
chunks |
create_array |
Auto | Tuple of ints, "auto" |
Chunk shape per dimension |
shards |
create_array |
None | Tuple of ints | Shard shape (groups chunks) |
dtype |
create_array |
"f8" |
NumPy dtype | Data type |
codecs |
create_array |
Blosc/Zstd | List of codec objects | Compression pipeline |
mode |
open_array |
"r" |
"r", "r+", "w", "a" |
File access mode |
store |
All | LocalStore | Store object or path | Storage backend |
cname |
BloscCodec |
"zstd" |
"lz4", "zstd", "gzip", etc. |
Compressor algorithm |
clevel |
BloscCodec |
5 | 0–9 | Compression level |
shuffle |
BloscCodec |
"noshuffle" |
"shuffle", "bitshuffle" |
Byte reordering for compression |
Best Practices
-
Target 1–10 MB per chunk: Smaller chunks = more metadata overhead; larger chunks = more memory per read. For float32: 512×512 ≈ 1 MB.
-
Align chunks with access patterns: The most common query dimension should be contiguous within chunks. A 65× performance difference is possible with misaligned chunks.
-
Anti-pattern — loading entire array with
z[:]: For large arrays, use Dask (da.from_zarr) or process in explicit chunks.z[:]loads everything into memory. -
Consolidate metadata for cloud stores: Call
zarr.consolidate_metadata(store)after creating all arrays. Then open withzarr.open_consolidated(). This reduces N metadata reads to 1 — critical for S3/GCS latency. -
Close ZipStore explicitly: Unlike other stores,
ZipStorerequiresstore.close()after writing. Forgetting this corrupts the ZIP file. -
Use sharding for millions of small chunks: When chunk count exceeds ~100k, filesystem overhead dominates. Sharding groups chunks into fewer storage objects.
-
Anti-pattern — choosing chunk shape based on array shape alone: Always consider access pattern first, then optimize chunk size. A "nice" shape (1000, 1000) may be terrible if you always read rows.
Common Recipes
Recipe: Profiling Array Performance
import zarr
z = zarr.open("data.zarr")
print(z.info)
# Shows: type, shape, chunks, dtype, compressor, storage size
print(f"Compressed: {z.nbytes_stored / 1e6:.2f} MB")
print(f"Uncompressed: {z.nbytes / 1e6:.2f} MB")
print(f"Ratio: {z.nbytes / z.nbytes_stored:.1f}x")
Recipe: Time Series Append
import zarr
import numpy as np
# Create extensible array (start with 0 timesteps)
z = zarr.open("timeseries.zarr", mode="a",
shape=(0, 720, 1440),
chunks=(1, 720, 1440),
dtype="f4")
# Append new timesteps incrementally
for day in range(365):
new_step = np.random.random((1, 720, 1440)).astype("f4")
z.append(new_step, axis=0)
print(f"Final shape: {z.shape}") # (365, 720, 1440)
Recipe: Parallel Write with Dask
import dask.array as da
# Generate large dataset in parallel
data = da.random.random((100000, 100000), chunks=(1000, 1000))
# Write to Zarr (parallel across chunks)
da.to_zarr(data, "parallel_output.zarr")
# Verify
z = da.from_zarr("parallel_output.zarr")
print(f"Written: {z.shape}, {z.npartitions} partitions")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Slow read performance | Chunk shape misaligned with access pattern | Profile access pattern; realign chunks (row-access → wide chunks) |
MemoryError on read |
Loading entire array or chunk too large | Use Dask da.from_zarr() for out-of-core; reduce chunk size |
| High cloud latency | Many small metadata reads | Call zarr.consolidate_metadata(store) then open_consolidated() |
| Corrupted ZIP store | Forgot to call store.close() |
Always close ZipStore after write; use context manager |
| Concurrent write conflicts | Multiple processes writing overlapping chunks | Use ProcessSynchronizer or ensure non-overlapping chunk writes |
| Poor compression ratio | No shuffle on numeric data | Add shuffle="shuffle" to BloscCodec |
| Stale consolidated metadata | Arrays modified after consolidation | Re-run zarr.consolidate_metadata() after updates |
ModuleNotFoundError: s3fs |
Missing cloud storage dependency | pip install s3fs (S3) or pip install gcsfs (GCS) |
Related Skills
- lamindb-data-management — uses Zarr as a storage backend; provides data management, lineage, and ontology validation on top of Zarr arrays
- anndata-data-structure — AnnData objects can be backed by Zarr stores for out-of-core single-cell data
- sympy-symbolic-math — unrelated but co-exists in scientific-computing category
References
- Zarr Python documentation — official user guide and API reference
- Zarr specifications — file format specification (V2, V3)
- Zarr GitHub — source code, issues, changelog
- NumCodecs — compression codec library used by Zarr
skills/scientific-writing/biorxiv-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill biorxiv-database -g -y
SKILL.md
Frontmatter
{
"name": "biorxiv-database",
"license": "CC0-1.0",
"description": "Query bioRxiv\/medRxiv preprints via REST API. Search by DOI, category, or date range; retrieve metadata (title, abstract, authors, category, DOI, version history) and PDFs. No auth. For peer-reviewed biomedical use pubmed-database; broader scholarly search use openalex-database."
}
bioRxiv / medRxiv Preprint Database
Overview
bioRxiv (biology) and medRxiv (health sciences) are free preprint servers hosting 200,000+ and 50,000+ manuscripts, respectively, before or alongside peer review. The unified REST API provides programmatic access to preprint metadata (title, abstract, authors, category, DOI, version history) without authentication. Preprints are available as PDF and can be retrieved by DOI, date range, or category.
When to Use
- Finding the most current research in fast-moving fields before peer review (e.g., infectious disease during outbreaks)
- Monitoring weekly preprint submissions in a specific discipline category (e.g., bioinformatics, genomics, neuroscience)
- Retrieving metadata and abstracts for a set of bioRxiv DOIs for literature screening
- Building a corpus of preprints to track the preprint-to-publication pipeline
- Checking whether a specific preprint has been updated or published in a peer-reviewed journal
- For peer-reviewed biomedical literature use
pubmed-database; for all disciplines useopenalex-database
Prerequisites
- Python packages:
requests,pandas - Data requirements: bioRxiv/medRxiv DOIs, date ranges, or category names
- Environment: internet connection; no API key or authentication required
- Rate limits: no stated hard limit; use reasonable delays for bulk queries
pip install requests pandas
Quick Start
import requests
BASE = "https://api.biorxiv.org"
# Retrieve recent bioinformatics preprints
r = requests.get(f"{BASE}/details/biorxiv/2024-01-01/2024-01-07/0",
params={"category": "bioinformatics"})
r.raise_for_status()
data = r.json()
print(f"Total preprints: {int(data['messages'][0]['total'])}") # API returns total as a string
for article in data["collection"][:3]:
print(f"\n{article['title'][:80]}")
print(f" Authors : {article['authors'][:60]}")
print(f" DOI : {article['doi']}")
print(f" Category: {article['category']}")
Core API
Query 1: Date-Range Preprint Listing
Retrieve all preprints posted within a date range, optionally filtered by category.
import requests, pandas as pd
BASE = "https://api.biorxiv.org"
def get_preprints(server, date_from, date_to, cursor=0, category=None):
"""
server: 'biorxiv' or 'medrxiv'
date_from, date_to: 'YYYY-MM-DD' strings
cursor: page offset (increments of 100)
"""
url = f"{BASE}/details/{server}/{date_from}/{date_to}/{cursor}"
r = requests.get(url)
r.raise_for_status()
return r.json()
data = get_preprints("biorxiv", "2024-01-01", "2024-01-03")
total = int(data["messages"][0]["total"]) # API returns total as a string — cast for arithmetic
print(f"bioRxiv preprints Jan 1-3, 2024: {total}")
rows = []
for article in data["collection"][:10]:
rows.append({
"doi": article["doi"],
"title": article["title"],
"authors": article["authors"][:80],
"category": article["category"],
"date": article["date"],
"version": article["version"],
})
df = pd.DataFrame(rows)
print(df[["title", "category", "date"]].head())
# Paginate through all results for a date range
def get_all_preprints(server, date_from, date_to, max_results=500):
all_articles = []
cursor = 0
while len(all_articles) < max_results:
data = get_preprints(server, date_from, date_to, cursor)
collection = data["collection"]
if not collection:
break
all_articles.extend(collection)
total = int(data["messages"][0]["total"]) # cast: API returns total as string
cursor += 100
if cursor >= total:
break
return all_articles[:max_results]
articles = get_all_preprints("biorxiv", "2024-01-01", "2024-01-07")
print(f"Retrieved {len(articles)} preprints from first week of 2024")
Query 2: Preprint Detail by DOI
Retrieve full metadata and version history for a specific preprint by DOI.
import requests
BASE = "https://api.biorxiv.org"
# Retrieve specific preprint by DOI
doi = "10.1101/2024.01.01.000001" # Replace with real DOI
def get_by_doi(server, doi):
r = requests.get(f"{BASE}/details/{server}/{doi}")
r.raise_for_status()
return r.json()
# Generic example using bioRxiv DOI pattern
r = requests.get(f"{BASE}/details/biorxiv/10.1101/2024.05.28.596311")
if r.ok:
data = r.json()
articles = data.get("collection", [])
if articles:
art = articles[-1] # Latest version
print(f"Title : {art['title']}")
print(f"Authors : {art['authors'][:100]}")
print(f"Category: {art['category']}")
print(f"Date : {art['date']}")
print(f"Version : {art['version']}")
print(f"DOI : {art['doi']}")
print(f"Abstract (first 300): {art['abstract'][:300]}")
Query 3: Published Preprint Lookup
Check if a preprint has been published in a peer-reviewed journal.
import requests
BASE = "https://api.biorxiv.org"
def check_published(server, doi):
"""Check if a preprint DOI has a corresponding published article."""
r = requests.get(f"{BASE}/publisher/{server}/{doi}")
r.raise_for_status()
data = r.json()
return data.get("collection", [])
# Check one known preprint
doi = "10.1101/2024.05.28.596311"
published = check_published("biorxiv", doi)
if published:
pub = published[0]
print(f"Published in: {pub.get('published_journal')}")
print(f"Published DOI: {pub.get('published_doi')}")
else:
print(f"Preprint {doi} has not been published yet (or not tracked)")
Query 4: Category-Based Monitoring
Monitor preprints by specific research category.
import requests, pandas as pd
from datetime import date, timedelta
BASE = "https://api.biorxiv.org"
# bioRxiv categories include: bioinformatics, genomics, neuroscience,
# immunology, cell-biology, biochemistry, microbiology, etc.
def weekly_category_digest(category, days_back=7):
"""Get preprints from last N days for a specific category."""
today = date.today()
date_from = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
date_to = today.strftime("%Y-%m-%d")
all_articles = []
cursor = 0
while True:
r = requests.get(f"{BASE}/details/biorxiv/{date_from}/{date_to}/{cursor}")
data = r.json()
batch = [a for a in data["collection"] if category.lower() in a["category"].lower()]
all_articles.extend(batch)
if len(data["collection"]) < 100:
break
cursor += 100
return pd.DataFrame(all_articles)[["doi", "title", "authors", "date"]] if all_articles else pd.DataFrame()
df = weekly_category_digest("genomics", days_back=3)
print(f"Recent genomics preprints: {len(df)}")
print(df[["title", "date"]].head())
Query 5: medRxiv Clinical/Health Research
Query medRxiv for health and clinical science preprints.
import requests, pandas as pd
BASE = "https://api.biorxiv.org"
# medRxiv categories: infectious diseases, epidemiology, oncology,
# cardiology, neurology, psychiatry, public and global health, etc.
r = requests.get(f"{BASE}/details/medrxiv/2024-01-01/2024-01-07/0")
r.raise_for_status()
data = r.json()
total = int(data["messages"][0]["total"]) # cast: API returns total as string
print(f"medRxiv preprints Jan 1-7, 2024: {total}")
# Group by category
from collections import Counter
category_counts = Counter(a["category"] for a in data["collection"])
print("\nTop categories:")
for cat, count in category_counts.most_common(5):
print(f" {cat}: {count}")
Query 6: Bulk DOI Resolution and Abstract Extraction
Retrieve abstracts for a list of bioRxiv DOIs.
import requests, time, pandas as pd
BASE = "https://api.biorxiv.org"
dois = [
"10.1101/2024.05.28.596311",
"10.1101/2023.11.28.569048",
"10.1101/2023.03.07.531523",
]
rows = []
for doi in dois:
r = requests.get(f"{BASE}/details/biorxiv/{doi}")
if r.ok:
collection = r.json().get("collection", [])
if collection:
art = collection[-1] # Latest version
rows.append({
"doi": doi,
"title": art.get("title"),
"category": art.get("category"),
"date": art.get("date"),
"abstract": art.get("abstract", "")[:300],
})
time.sleep(0.2)
df = pd.DataFrame(rows)
if not df.empty:
df.to_csv("preprint_abstracts.csv", index=False)
print(df[["doi", "title", "category"]].to_string(index=False))
else:
print("No valid preprints found for provided DOIs")
Key Concepts
API Endpoint Structure
The bioRxiv API follows the pattern: https://api.biorxiv.org/details/{server}/{interval}/{cursor}
server:biorxivormedrxivinterval: either a DOI (for single record) ordate_from/date_to(for date range)cursor: pagination offset (0, 100, 200…)
Version Tracking
Preprints can be updated; each update creates a new version (v1, v2, v3…). The API returns all versions chronologically; the last item in collection is always the most recent.
Common Workflows
Workflow 1: Weekly Preprint Digest Pipeline
Goal: Automatically collect last week's preprints in target categories and export for review.
import requests, time, pandas as pd
from datetime import date, timedelta
BASE = "https://api.biorxiv.org"
TARGET_CATEGORIES = ["bioinformatics", "genomics", "systems biology"]
DAYS_BACK = 7
today = date.today()
date_from = (today - timedelta(days=DAYS_BACK)).strftime("%Y-%m-%d")
date_to = today.strftime("%Y-%m-%d")
print(f"Fetching bioRxiv preprints from {date_from} to {date_to}")
all_articles = []
cursor = 0
while True:
r = requests.get(f"{BASE}/details/biorxiv/{date_from}/{date_to}/{cursor}")
r.raise_for_status()
data = r.json()
batch = data["collection"]
if not batch:
break
all_articles.extend(batch)
total = int(data["messages"][0]["total"]) # cast: API returns total as string
cursor += 100
if cursor >= total:
break
time.sleep(0.1)
# Filter by target categories
filtered = [a for a in all_articles
if any(cat in a.get("category", "").lower() for cat in TARGET_CATEGORIES)]
df = pd.DataFrame(filtered)[["doi", "title", "authors", "category", "date"]]
df = df.drop_duplicates(subset="doi") # Remove duplicate versions
output_file = f"biorxiv_digest_{date_to}.csv"
df.to_csv(output_file, index=False)
print(f"\nSaved {len(df)} preprints across {len(TARGET_CATEGORIES)} categories → {output_file}")
print(df[["title", "category", "date"]].head(5).to_string(index=False))
Workflow 2: Preprint-to-Publication Tracker
Goal: For a list of preprint DOIs, check which have been published and retrieve publication details.
import requests, time, pandas as pd
BASE = "https://api.biorxiv.org"
preprint_dois = [
"10.1101/2024.05.28.596311",
"10.1101/2023.11.28.569048",
]
results = []
for doi in preprint_dois:
# Get preprint metadata
r_meta = requests.get(f"{BASE}/details/biorxiv/{doi}")
meta = {}
if r_meta.ok and r_meta.json().get("collection"):
art = r_meta.json()["collection"][-1]
meta = {"title": art["title"], "category": art["category"],
"preprint_date": art["date"]}
# Check publication status
r_pub = requests.get(f"{BASE}/publisher/biorxiv/{doi}")
published = {}
if r_pub.ok and r_pub.json().get("collection"):
pub = r_pub.json()["collection"][0]
published = {"journal": pub.get("published_journal"),
"pub_doi": pub.get("published_doi")}
results.append({"preprint_doi": doi, **meta, **published})
time.sleep(0.25)
df = pd.DataFrame(results)
print(df.to_string(index=False))
df.to_csv("preprint_publication_status.csv", index=False)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
server |
URL path | required | "biorxiv", "medrxiv" |
Select preprint server |
date_from |
URL path | required | "YYYY-MM-DD" |
Start of date range |
date_to |
URL path | required | "YYYY-MM-DD" |
End of date range |
cursor |
URL path | 0 |
0, 100, 200… |
Pagination offset (100 per page) |
category |
Filter | — | e.g., "bioinformatics" |
Category name substring match (post-filter) |
version |
— | all versions | — | API returns all versions; use [-1] for latest |
Best Practices
-
Always take the last element for latest version: The
collectionarray is sorted oldest-to-newest version. Usecollection[-1]to get the most current version of a preprint. -
Post-filter by category: The API does not natively filter by category; retrieve all preprints for a date range and filter client-side using
if category in article["category"].lower(). -
Respect server resources: Add
time.sleep(0.2)between individual DOI lookups; avoid bulk hammering the API. -
Cross-check with PubMed: The
publisherendpoint reveals when a preprint is published; usepubmed-databaseto retrieve the full peer-reviewed article metadata. -
Handle missing abstracts: Some preprints have empty
abstractfields. Always guard withart.get("abstract", "") or "No abstract available".
Common Recipes
Recipe: Download Preprint PDF (Cloudflare-aware)
When to use: Retrieve full-text PDF for a bioRxiv preprint. Caveat: as of 2026, www.biorxiv.org is fronted by Cloudflare's anti-bot challenge — direct requests.get(..., headers={"User-Agent": "Mozilla/5.0"}) consistently returns HTTP 403 ("Just a moment...") even with a Session and a landing-page warmup. The pattern below attempts a best-effort download with realistic browser headers, then falls back to EuropePMC for metadata if blocked.
import requests
def download_biorxiv_pdf(doi, out_path=None):
"""Best-effort PDF download. If Cloudflare blocks, return False so the caller
can fall back to EuropePMC metadata or open the landing page in a browser."""
pdf_url = f"https://www.biorxiv.org/content/{doi}.full.pdf"
s = requests.Session()
s.headers.update({
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
})
# Warm up the landing page first (sometimes lets Cloudflare's "trust" cookie set)
s.get(f"https://www.biorxiv.org/content/{doi}v1", timeout=30)
r = s.get(pdf_url, timeout=60)
if r.ok and r.content.startswith(b"%PDF"):
out = out_path or f"{doi.replace('/', '_')}.pdf"
with open(out, "wb") as f:
f.write(r.content)
print(f"Downloaded {out} ({len(r.content)//1024} KB)")
return True
print(f"PDF blocked (HTTP {r.status_code}); falling back to metadata-only via EuropePMC")
return False
def europepmc_metadata(doi):
"""Fetch preprint metadata via EuropePMC when bioRxiv PDF is blocked.
EuropePMC indexes bioRxiv as source 'PPR' and exposes a stable landing URL."""
r = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search",
params={"query": f"DOI:{doi}", "format": "json"}, timeout=30)
r.raise_for_status()
hits = r.json().get("resultList", {}).get("result", [])
if not hits:
return None
h = hits[0]
return {
"source": h.get("source"), # 'PPR' for preprints
"epmc_id": h.get("id"), # e.g. 'PPR860608'
"title": h.get("title"),
"landing_url": f"https://europepmc.org/article/{h.get('source')}/{h.get('id')}",
}
doi = "10.1101/2024.05.28.596311"
if not download_biorxiv_pdf(doi):
meta = europepmc_metadata(doi)
print(f" EuropePMC landing: {meta['landing_url']}")
print(f" Title: {meta['title'][:80]}")
Recipe: Count Preprints by Category
When to use: Analyze the distribution of preprints across bioRxiv categories in a time window.
import requests, pandas as pd
from collections import Counter
r = requests.get("https://api.biorxiv.org/details/biorxiv/2024-01-01/2024-01-07/0")
data = r.json()
total = int(data["messages"][0]["total"]) # cast: API returns total as a string
# Fetch all pages
all_articles = data["collection"]
for cursor in range(100, min(total, 1000), 100):
r2 = requests.get(f"https://api.biorxiv.org/details/biorxiv/2024-01-01/2024-01-07/{cursor}")
all_articles.extend(r2.json()["collection"])
counts = Counter(a["category"] for a in all_articles)
df = pd.DataFrame(counts.most_common(), columns=["category", "count"])
print(df.head(10).to_string(index=False))
Recipe: Check if Preprint Has Been Published
When to use: Quick single-preprint publication check.
import requests
doi = "10.1101/2024.05.28.596311"
r = requests.get(f"https://api.biorxiv.org/publisher/biorxiv/{doi}")
collection = r.json().get("collection", [])
if collection:
print(f"Published: {collection[0]['published_journal']} | DOI: {collection[0]['published_doi']}")
else:
print("Not published or not tracked")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
collection is empty |
DOI not found or date range has no results | Verify DOI format (starts with 10.1101/); check date range |
| Duplicate preprints in results | Multiple versions returned | Deduplicate by DOI: df.drop_duplicates(subset='doi', keep='last') |
| Missing abstract field | Some preprints don't have structured abstracts | Guard with art.get("abstract", "") or "N/A" |
total count vs retrieved mismatch |
New preprints added during pagination | Accept approximate totals; preprints are added continuously |
| PDF download blocked (HTTP 403 "Just a moment...") | Cloudflare anti-bot on www.biorxiv.org/.../*.full.pdf (cannot be bypassed by a Mozilla/5.0 UA alone, nor by a Session + landing-page warmup) |
Try the Session + warmup recipe; if still blocked, fall back to EuropePMC (source=PPR) for metadata, or fetch the PDF interactively from the bioRxiv landing page in a browser |
cursor >= total never triggers; loop runs forever |
data['messages'][0]['total'] is returned as a string (e.g. '1119'); int_cursor >= str_total raises TypeError or compares lexically |
Cast explicitly: int(data["messages"][0]["total"]) in every pagination loop |
collection empty for a specific DOI |
The DOI never resolved to a real preprint (e.g. fake placeholder like 2023.01.01.000001, or a stale/withdrawn DOI) |
Verify the DOI on https://www.biorxiv.org/content/{doi}v1 first; recent DOIs from a date-range listing are the safest examples |
| Slow pagination for large date ranges | Large number of preprints | Use narrower date windows (3-7 days) for busy periods |
Related Skills
pubmed-database— Peer-reviewed biomedical literature for verifying published versions of preprintsopenalex-database— Broader scholarly index including bioRxiv content after indexing lagliterature-review— Guide for incorporating preprints into systematic reviewsscientific-brainstorming— Using preprint alerts as input for hypothesis generation
References
- bioRxiv API documentation — Official API endpoint and response format
- bioRxiv about page — Server overview, submission guidelines
- medRxiv about page — Health science preprint server details
- Preprint guidelines review (Fraser et al. 2021) — Best practices for citing and using preprints in research
skills/scientific-writing/cancer-research-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cancer-research-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "cancer-research-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "Cancer Research (AACR) figures: resolution (300-1200 DPI), formats (EPS\/TIFF\/AI), hierarchical panel labels (Ai, Aii, Bi), figure\/table limits, legend requirements with replicate counts.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
Cancer Research (AACR) Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to Cancer Research and other AACR (American Association for Cancer Research) journals. Cancer Research has a distinctive hierarchical panel labeling system (Ai, Aii, Bi, Bii) and strict limits on the total number of display items.
Official reference: https://aacrjournals.org/pages/article-style-and-format
Resolution Requirements
| Image Type | Minimum Resolution |
|---|---|
| Line art | 1,200 DPI |
| Halftone / color images | 300 DPI |
| Combination artwork | 600-900 DPI |
from PIL import Image
def check_cancer_res_resolution(image_path, image_type='halftone'):
"""Check if image meets Cancer Research resolution requirements.
Args:
image_type: 'lineart' (1200), 'halftone' (300), or 'combination' (600)
"""
min_dpi = {'lineart': 1200, 'halftone': 300, 'combination': 600}
required = min_dpi.get(image_type, 300)
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
print(f"Type: {image_type} | Required: {required} DPI | Actual: {dpi[0]} DPI")
passed = dpi[0] >= required
print("PASS" if passed else f"FAIL: Need {required} DPI minimum")
return passed
File Format
| Format | Accepted |
|---|---|
| EPS | Yes |
| TIFF | Yes |
| AI (Adobe Illustrator) | Yes |
| PSD (Photoshop) | Yes |
| PNG | Yes |
| PS (PostScript) | Yes |
Figure Size and Dimensions
Display Item Limits
| Article Type | Maximum Display Items |
|---|---|
| Research Articles | 7 figures + tables combined |
| Letters | 2 display items total |
- Each figure must fit on a single printed page
- Figures should be presented in sequential order adjacent to legends
Color Mode
- No strict CMYK/RGB mandate in published guidelines
- RGB recommended for online display
- Follow general best practices for color accessibility
Font Requirements
| Element | Font | Size |
|---|---|---|
| Manuscript body text | Arial, Helvetica, or Times New Roman | 12 pt |
| Figure text | Same fonts | 8-12 pt range |
Labeling Conventions
Hierarchical Panel Label System (AACR-Specific)
Cancer Research uses a unique three-level hierarchical labeling system:
- Level 1: Capital letters — A, B, C, D
- Level 2: Roman numerals — i, ii, iii, iv
- Level 3: Lowercase letters — a, b, c, d
Preferred format: Ai, Aii, Bi, Bii (NOT Aa, Ab, Ba, Bb)
Labeling Rules
- No boxes around panel labels
- No periods after panel labels
- Multiple panels should each be labeled and described in the legend
Legend Requirements
- Figure legends must include the number of technical AND biological replicates
- This is a mandatory requirement for Cancer Research
def generate_cancer_res_labels(n_main_panels, sub_panels_per_main=None):
"""Generate Cancer Research hierarchical panel labels.
Args:
n_main_panels: Number of main panels (A, B, C, ...)
sub_panels_per_main: List of sub-panel counts per main panel,
or None for no sub-panels
Returns:
List of label strings
Example:
generate_cancer_res_labels(3, [2, 3, 1])
# Returns: ['Ai', 'Aii', 'Bi', 'Bii', 'Biii', 'C']
"""
import string
labels = []
roman = ['i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii']
for i in range(n_main_panels):
main_label = string.ascii_uppercase[i]
if sub_panels_per_main and sub_panels_per_main[i] > 1:
for j in range(sub_panels_per_main[i]):
labels.append(f"{main_label}{roman[j]}")
else:
labels.append(main_label)
return labels
Example Usage
# 3 main panels: A has 2 sub-panels, B has 3, C has 1
labels = generate_cancer_res_labels(3, [2, 3, 1])
print(labels)
# Output: ['Ai', 'Aii', 'Bi', 'Bii', 'Biii', 'C']
Image Integrity and Manipulation Policy
Cancer Research follows general AACR editorial policies for image integrity:
- All image adjustments should be applied uniformly to the entire image
- No selective enhancement of specific features
- Original unprocessed data should be available upon request
- Processing details should be described in Methods
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_cancer_res_figure(image_path, image_type='halftone'):
"""Full validation of a figure against Cancer Research requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
min_dpi = {'lineart': 1200, 'halftone': 300, 'combination': 600}
required = min_dpi.get(image_type, 300)
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < required:
issues.append(f"Resolution {dpi[0]} DPI below {required} DPI for {image_type}")
# 2. Color mode check
if img.mode not in ('RGB', 'RGBA'):
issues.append(f"Color mode is {img.mode}; RGB recommended")
# 3. Format check
fmt = img.format
accepted = ['TIFF', 'EPS', 'PNG', 'JPEG', 'PDF']
if fmt and fmt.upper() not in accepted:
issues.append(f"Format '{fmt}' not in standard list")
# Report
print(f"=== Cancer Research Figure Validation ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
print(f"Format: {fmt}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
print("\nREMINDER: Cancer Research limits Research Articles to 7 figures+tables total")
print("REMINDER: Legends must include number of technical AND biological replicates")
return len(issues) == 0
Key Concepts
Hierarchical Panel Labeling
Cancer Research uses a unique three-level labeling system: Level 1 uses capital letters (A, B, C), Level 2 uses Roman numerals (i, ii, iii), and Level 3 uses lowercase letters (a, b, c). The preferred format is Ai, Aii, Bi, Bii — not Aa, Ab, Ba, Bb. No boxes or periods surround panel labels.
Display Item Limits
Cancer Research strictly limits the total number of display items (figures + tables combined). Research Articles are limited to 7 display items; Letters are limited to 2. Planning figure composition to maximize information density within these limits is essential.
Replicate Reporting in Legends
A distinctive Cancer Research requirement is that figure legends must include the number of both technical and biological replicates for each experiment shown. This transparency standard supports reproducibility and is checked during editorial review.
Decision Framework
What type of image are you preparing?
├── Line art (diagram, schematic) → 1,200 DPI
├── Halftone (photo, micrograph) → 300 DPI
├── Combination (mixed text + image) → 600-900 DPI
└── Multi-panel figure
├── Simple panels (A, B, C) → Standard uppercase labels
└── Sub-panels needed → Hierarchical: Ai, Aii, Bi, Bii
| Scenario | Format | Resolution | Labeling |
|---|---|---|---|
| Western blot with quantification | TIFF + EPS | 300 DPI (blot) + 1,200 DPI (graph) | Ai (blot), Aii (quantification) |
| Tumor growth curves | EPS or AI | Vector or 1,200 DPI | A, B, C (simple panels) |
| Immunohistochemistry panel | TIFF | 300 DPI | Ai, Aii, Bi, Bii (conditions x magnifications) |
| Flow cytometry dot plots | TIFF or PDF | 300 DPI | Hierarchical by condition |
| Kaplan-Meier survival curves | EPS or PDF | Vector | Simple A, B labeling |
Best Practices
- Plan display items early: With a 7-item limit for Research Articles, plan which results become figures vs. tables vs. supplementary material before drafting
- Use hierarchical labels for complex panels: When a main panel has sub-components (e.g., image + quantification), use the Ai/Aii system rather than separate figure letters
- Include replicate counts in every legend: State both technical and biological replicate numbers for each panel. This is a mandatory requirement, not optional
- Avoid boxes and periods on labels: Cancer Research specifically prohibits decorative elements around panel labels. Use clean, unboxed capital letters
- Size figures to fit a single page: Each figure must fit on one printed page. If content exceeds this, consider splitting into two figures or moving data to supplements
- Present figures in sequential order: Figures should appear adjacent to their legends in the order they are cited in the text
Common Pitfalls
- Exceeding the display item limit: Submitting more than 7 figures + tables for a Research Article triggers immediate revision request
- How to avoid: Count all display items before submission; consolidate related panels using hierarchical labeling
- Using incorrect hierarchical label format: Writing Aa/Ab instead of Ai/Aii is a common mistake
- How to avoid: Use Roman numerals (i, ii, iii) for Level 2, not lowercase letters
- Omitting replicate information from legends: Missing replicate counts delay review and may require revision
- How to avoid: Add a template line to every legend: "Data represent N=X biological replicates with Y technical replicates each"
- Adding boxes or periods to panel labels: Decorative elements around labels violate Cancer Research formatting rules
- How to avoid: Use plain capital letters without any surrounding decoration
- Using 300 DPI for line art: Line art requires 1,200 DPI — the highest tier. Graphs at 300 DPI will appear jagged in print
- How to avoid: Export vector graphics as EPS/AI, or rasterize at 1,200 DPI minimum
Pre-Submission Checklist
Before submitting figures to Cancer Research, verify:
- Line art at 1,200+ DPI
- Halftone/color images at 300+ DPI
- Combination images at 600-900 DPI
- File format is EPS, TIFF, AI, PSD, or PNG
- Research Article: maximum 7 figures + tables combined
- Letters: maximum 2 display items
- Each figure fits on a single printed page
- Panel labels use hierarchical format (Ai, Aii, Bi, Bii)
- No boxes around panel labels
- No periods after panel labels
- Font is Arial, Helvetica, or Times New Roman
- Legend includes number of technical and biological replicates
- Image adjustments applied uniformly
- Original data available upon request
References
- AACR Article Style and Format: https://aacrjournals.org/pages/article-style-and-format
- AACR Editorial Policies: https://aacrjournals.org/pages/editorial-policies
- Cancer Research Author Guidelines: https://aacrjournals.org/cancerresearch/pages/prep
skills/scientific-writing/cell-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cell-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "cell-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "Cell (Cell Press) figure preparation: resolution (300-1000 DPI), formats (TIFF\/PDF), RGB color, Avenir\/Arial fonts, uppercase panel labels, strict image manipulation policies.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
Cell Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to Cell and other Cell Press journals (e.g., Cell Stem Cell, Cell Reports, Molecular Cell). Cell Press has strict figure requirements and a rigorous image integrity policy.
Official reference: https://www.cell.com/information-for-authors/figure-guidelines
Resolution Requirements
| Image Type | Minimum Resolution | Notes |
|---|---|---|
| Color / Grayscale photographs | 300 DPI | At desired print size |
| Black-and-white images | 500 DPI | At desired print size |
| Line art (graphs, diagrams) | 1,000 DPI | At desired print size |
IMPORTANT: All resolution measurements are at the desired print size, not at full-page size.
Verify Resolution
from PIL import Image
def check_cell_resolution(image_path, image_type='color'):
"""Check if image meets Cell journal resolution requirements.
Args:
image_path: Path to the image file
image_type: 'color' (300 DPI), 'bw' (500 DPI), or 'lineart' (1000 DPI)
"""
min_dpi = {'color': 300, 'bw': 500, 'lineart': 1000}
required = min_dpi.get(image_type, 300)
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
print(f"Image type: {image_type}")
print(f"Required DPI: {required}")
print(f"Actual DPI: {dpi[0]} x {dpi[1]}")
if dpi[0] >= required:
print("PASS: Resolution meets Cell requirements")
else:
print(f"FAIL: Need at least {required} DPI, got {dpi[0]}")
return dpi[0] >= required
File Format
Preferred Formats
| Format | Best For | Notes |
|---|---|---|
| TIFF | Bitmap, grayscale, color images | Use LZW compression to reduce size |
| Any figure type | Universally accepted | |
| EPS | Vector images (graphs, diagrams) | Preserves scalability |
Also Accepted
- JPEG, CDX files
File Size
- Individual files: 20 MB maximum
Submission Notes
- Initial submission: Figures can be embedded in manuscript or uploaded separately
- Final production: Separate high-resolution files required
Figure Size and Dimensions
2-Column Format Journals (Cell, Molecular Cell, etc.)
| Layout | Width |
|---|---|
| 1 column | 85 mm (3.35 in) |
| 1.5 columns | 114 mm (4.49 in) |
| Full width | 174 mm (6.85 in) |
3-Column Format Journals
| Layout | Width |
|---|---|
| 1 column | 55 mm (2.17 in) |
| 2 columns | 114 mm (4.49 in) |
| Full width | 174 mm (6.85 in) |
Other Cell Press Journals (Chem, Joule, Matter, etc.)
| Layout | Width |
|---|---|
| 1 column | 112 mm (4.41 in) |
| Full width | 172 mm (6.77 in) |
All figures must fit on a single 8.5" x 11" page.
Python: Set Cell Figure Dimensions
import matplotlib.pyplot as plt
# Cell Press figure widths in inches
CELL_WIDTHS = {
'2col_single': 85 / 25.4, # 3.35 in
'2col_1.5': 114 / 25.4, # 4.49 in
'2col_full': 174 / 25.4, # 6.85 in
'3col_single': 55 / 25.4, # 2.17 in
'3col_double': 114 / 25.4, # 4.49 in
'3col_full': 174 / 25.4, # 6.85 in
}
def create_cell_figure(layout='2col_single', aspect_ratio=0.75):
"""Create a Matplotlib figure sized for Cell Press journals."""
width = CELL_WIDTHS[layout]
height = width * aspect_ratio
fig, ax = plt.subplots(figsize=(width, height))
fig.set_dpi(300)
return fig, ax
Color Mode
- Submit in RGB color space
- Journal converts to CMYK for print production
- RGB preserves brighter colors for online viewing
from PIL import Image
def convert_to_rgb_for_cell(image_path, output_path):
"""Convert image to RGB for Cell Press submission."""
img = Image.open(image_path)
if img.mode == 'CMYK':
img = img.convert('RGB')
print("Converted from CMYK to RGB")
elif img.mode != 'RGB':
img = img.convert('RGB')
print(f"Converted from {img.mode} to RGB")
img.save(output_path)
return output_path
Font Requirements
| Element | Font | Size |
|---|---|---|
| Primary font | Avenir (preferred), Arial, Helvetica | — |
| Figure text | — | ~7 pt at print size |
| Panel labels | — | Bold, capital letters |
Critical Rules
- All fonts must be embedded in Adobe Illustrator files
- Use Avenir as the first choice; Arial or Helvetica as alternatives
- Consistent font usage across all figure panels
import matplotlib.pyplot as plt
def set_cell_fonts():
"""Configure Matplotlib for Cell Press figure fonts."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Avenir', 'Arial', 'Helvetica'],
'font.size': 7,
'axes.labelsize': 7,
'axes.titlesize': 7,
'xtick.labelsize': 6,
'ytick.labelsize': 6,
'legend.fontsize': 6,
})
Labeling Conventions
Panel Labels
- Capital letters: A, B, C, D, ...
- Bold weight
- Position: top-left corner of each panel
Titles
- Do NOT place titles inside figures — include them in the figure caption instead
- Remove any labels not essential for understanding the figure; explain in caption
import matplotlib.pyplot as plt
import string
def add_cell_panel_labels(fig, axes):
"""Add Cell-style uppercase bold panel labels."""
if not hasattr(axes, '__iter__'):
axes = [axes]
for i, ax in enumerate(axes):
label = string.ascii_uppercase[i]
ax.text(-0.1, 1.1, label,
transform=ax.transAxes,
fontsize=8,
fontweight='bold',
va='top',
ha='right',
fontfamily='Arial')
Image Integrity and Manipulation Policy
STRICTLY PROHIBITED
- Photoshop clone/heal tool usage on scientific images
- Selective enhancement or alteration of any part of an image
- Splicing images from different experiments without clear indication
- Touching up gel/blot images to hide or alter data
PERMITTED (with full disclosure)
- Brightness, contrast, and color adjustments applied uniformly to the entire image
- Minimal image processing for microscopy (must be described in Methods)
REQUIRED
- Single color channel alterations must be explained in both legend and Methods
- Gel images: removed lanes must be clearly marked and alterations noted in legend
- Original unprocessed data must be available upon editor/reviewer request
- All processing steps must be transparently described
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_cell_figure(image_path, image_type='color', layout='2col_single'):
"""Full validation of a figure against Cell Press requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
min_dpi = {'color': 300, 'bw': 500, 'lineart': 1000}
required_dpi = min_dpi.get(image_type, 300)
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < required_dpi:
issues.append(f"Resolution {dpi[0]} DPI below minimum {required_dpi} DPI for {image_type}")
# 2. Color mode check
if img.mode != 'RGB':
issues.append(f"Color mode is {img.mode}; Cell requires RGB submission")
# 3. File size check
size_mb = os.path.getsize(image_path) / (1024 * 1024)
if size_mb > 20:
issues.append(f"File size {size_mb:.1f} MB exceeds 20 MB limit")
# 4. Dimension check (width in mm)
widths_mm = {
'2col_single': 85, '2col_1.5': 114, '2col_full': 174,
'3col_single': 55, '3col_double': 114, '3col_full': 174,
}
if layout in widths_mm and dpi[0] > 0:
max_width_mm = widths_mm[layout]
actual_width_mm = (img.size[0] / dpi[0]) * 25.4
print(f"Width at current DPI: {actual_width_mm:.1f} mm (target: {max_width_mm} mm)")
# Report
print(f"=== Cell Figure Validation: {os.path.basename(image_path)} ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
print(f"File size: {size_mb:.1f} MB")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
return len(issues) == 0
Key Concepts
Resolution Tiers by Image Type
Cell Press uses a three-tier resolution system: 300 DPI for color/grayscale photographs, 500 DPI for black-and-white images, and 1,000 DPI for line art. All measurements are at the desired print size, not at arbitrary large dimensions. This tiered approach ensures each image type reproduces at appropriate quality.
Cell Press Column System
Cell Press journals use different column layouts depending on the journal. Two-column journals (Cell, Molecular Cell) use 85/114/174 mm widths. Three-column journals use 55/114/174 mm. Other journals (Chem, Joule) use 112/172 mm. Sizing figures to the correct column width prevents unwanted rescaling.
Image Manipulation Policy
Cell Press enforces one of the strictest image integrity policies in biomedical publishing. The use of Photoshop clone/heal tools on scientific images is explicitly prohibited. Any single-channel color alterations must be disclosed in both the figure legend and Methods section.
Decision Framework
What type of image are you preparing?
├── Color or grayscale photograph → 300 DPI minimum
│ ├── Micrograph → TIFF (LZW compression)
│ └── Clinical image → TIFF or PDF
├── Black-and-white image → 500 DPI minimum
│ └── High-contrast micrograph → TIFF
├── Line art (graph, diagram) → 1,000 DPI minimum
│ ├── Vector source → Export as PDF or EPS
│ └── Raster source → Export TIFF at 1,000 DPI
└── Which journal layout?
├── Cell, Molecular Cell → 2-column (85/114/174 mm)
├── Cell Reports → 3-column (55/114/174 mm)
└── Chem, Joule, Matter → Single/full (112/172 mm)
| Scenario | Format | Resolution | Width |
|---|---|---|---|
| Fluorescence micrograph | TIFF (LZW) | 300 DPI | Journal-specific column |
| Western blot | TIFF | 500 DPI | Single column (85 mm) |
| Bar chart or scatter plot | PDF or EPS | 1,000 DPI (if rasterized) | Varies by data density |
| Gel electrophoresis | TIFF | 500 DPI | Full width if multi-lane |
| Pathway diagram | PDF or EPS | Vector | Full width (174 mm) |
Best Practices
- Use LZW compression for TIFF files: Cell accepts TIFF with LZW compression, which significantly reduces file size without quality loss. This helps stay under the 20 MB limit
- Remove titles from figures: Cell Press requires that figure titles appear only in captions, not inside the figure itself. Remove any embedded titles before submission
- Use Avenir as primary font: While Arial and Helvetica are accepted, Avenir is Cell Press's preferred font. Using it signals familiarity with journal conventions
- Embed all fonts in AI files: Adobe Illustrator files must have fonts fully embedded. Missing fonts cause rendering errors during production
- Disclose all gel image modifications: Removed lanes must be clearly marked in the figure and described in the legend. Undisclosed modifications are grounds for rejection
- Match resolution to image type: Do not use 300 DPI for line art (needs 1,000) or 1,000 DPI for photographs (wastes file size). Match the resolution tier to the content type
- Keep original unprocessed data: Cell Press may request raw data during review or post-publication. Archive all originals alongside processed versions
Common Pitfalls
- Applying 300 DPI to all image types: Line art requires 1,000 DPI; black-and-white images require 500 DPI. Using 300 DPI for these types produces jagged edges
- How to avoid: Check the three-tier resolution table and match your image type before export
- Using clone/heal tools on scientific images: Cell Press explicitly prohibits Photoshop clone and heal tool usage on scientific data
- How to avoid: Use only uniform brightness/contrast adjustments on the entire image; document all processing in Methods
- Placing titles inside figures: Cell Press requires all titles in the caption, not embedded in the figure
- How to avoid: Review each figure panel and remove any text that belongs in the caption
- Exceeding 20 MB file size: Uncompressed TIFF files frequently exceed the limit
- How to avoid: Apply LZW compression when saving TIFF files; check file size before upload
- Incorrect column width for journal: Using 2-column widths for a 3-column journal (or vice versa) causes figures to be rescaled
- How to avoid: Verify which Cell Press journal you are targeting and use the correct column width table
- Not disclosing single-channel color adjustments: Altering individual color channels without disclosure violates Cell Press policy
- How to avoid: Document any single-channel modifications in both the figure legend and Methods section
Pre-Submission Checklist
Before submitting figures to Cell, verify:
- Color/grayscale images at 300+ DPI
- Black-and-white images at 500+ DPI
- Line art at 1,000+ DPI
- File format is TIFF (LZW), PDF, or EPS
- Each file under 20 MB
- Color mode is RGB
- Font is Avenir, Arial, or Helvetica at ~7 pt
- Panel labels are uppercase bold (A, B, C)
- No titles inside figures (use captions)
- All fonts embedded in AI files
- No clone/heal tool usage on scientific images
- All brightness/contrast adjustments applied to entire image
- Gel lane removals clearly marked in legend
- Processing details described in Methods
- Original unprocessed data retained
References
- Cell Press Figure Guidelines: https://www.cell.com/information-for-authors/figure-guidelines
- Cell Press Digital Image Guidelines: https://www.cell.com/matter/figureguidelines
- Cell Press Image Integrity Policy: https://www.cell.com/cell/image-integrity
skills/scientific-writing/citation-management/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill citation-management -g -y
SKILL.md
Frontmatter
{
"name": "citation-management",
"license": "CC-BY-4.0",
"description": "Selecting a reference manager and applying citation styles. Compares Zotero, Mendeley, EndNote, Paperpile; covers APA\/Vancouver\/ACS\/Nature styles, DOI management, citation tracking, and Word\/Google Docs\/LaTeX integration. Use when setting up a reference workflow or fixing citation formatting."
}
Citation Management for Scientific Writing
Overview
Citation management is the systematic practice of collecting, organizing, annotating, and inserting bibliographic references in scientific documents. A well-chosen reference manager reduces manual formatting errors, keeps libraries synchronized across devices, and integrates cleanly with word processors and LaTeX. This guide covers tool selection, citation style conventions, DOI and persistent identifier workflows, citation tracking, and common pitfalls that account for a large share of post-publication corrections and retractions related to reference errors.
Key Concepts
1. Reference Manager Capabilities and Tool Comparison
All major reference managers share a common core: they store metadata (author, title, journal, year, DOI), generate formatted citations in a chosen style, and provide a browser extension for one-click capture from publisher pages. The differences lie in collaboration features, cloud storage, word processor plugin support, price, and data portability.
| Feature | Zotero | Mendeley | EndNote | Paperpile |
|---|---|---|---|---|
| Cost | Free (open-source) | Free / paid tiers | ~$275 (perpetual) | $36/year |
| Cloud storage | 300 MB free; paid plans | 2 GB free | Institutional / local | Unlimited (Google Drive) |
| Word processor | Word, LibreOffice, Google Docs | Word, LibreOffice | Word, LibreOffice | Word, Google Docs |
| LaTeX integration | BibTeX/BibLaTeX export; Better BibTeX plugin | BibTeX export (manual) | BibTeX export | BibTeX; direct Overleaf sync |
| Group libraries | Free (up to 300 MB); paid unlimited | Yes | Yes (institutional) | Yes |
| PDF annotation | Built-in (Zotero 6+) | Desktop app | Basic | PDF.js viewer |
| Open-source | Yes (GPLv3) | No (Elsevier) | No (Clarivate) | No |
| Data portability | RIS, BibTeX, CSV — no lock-in | RIS, BibTeX | RIS, BibTeX; proprietary .enl | BibTeX, RIS |
| Best for | Academic open-source / LaTeX users | Beginners; Elsevier users | Large institutional labs | Google Workspace users |
Key differences to know:
- Mendeley is owned by Elsevier. Desktop sync reliability has declined since acquisition; many labs have migrated to Zotero.
- EndNote has the deepest Microsoft Word integration and the largest style library, but high cost makes it unattractive for individual researchers.
- Paperpile's real-time Google Docs integration is unmatched, making it the best choice for teams that write primarily in Docs.
- Zotero is the most flexible: free, open-source, cross-platform, and extensible via plugins.
2. Citation Style Families
Citation styles are governed by the target journal's Instructions for Authors — the author does not choose independently. The two major families are author-date (parenthetical, e.g., "(Smith, 2023)") and citation-sequence (numbered, e.g., superscript¹ or bracketed [1]).
| Style | In-text format | Reference list order | Abbreviate journals? | DOI required? | Typical discipline |
|---|---|---|---|---|---|
| APA 7th | (Author, Year) | Alphabetical by first author | No | Yes (for journal articles) | Psychology, social science, biology |
| Vancouver | Superscript [1] | Citation order | Yes (ISO 4) | Recommended | Medicine (NEJM, Lancet, BMJ) |
| ACS | Superscript or italic (1) | Citation order | Yes (CASSI) | Required | Chemistry (JACS, ACS Nano) |
| Nature | Superscript¹ | Citation order | Yes (NLM) | Yes | Nature family journals |
| IEEE | Bracketed [1] | Citation order | Yes | Recommended | Engineering, computer science |
| Chicago author-date | (Author year) | Alphabetical | No | Recommended | History, humanities |
Journal name abbreviation: Vancouver uses ISO 4 abbreviations (e.g., "N. Engl. J. Med."); ACS uses Chemical Abstracts Service Source Index (CASSI); Nature uses PubMed/NLM abbreviations. These are not interchangeable. Your reference manager's built-in Citation Style Language (CSL) file handles this automatically — do not abbreviate manually.
3. DOIs and Persistent Identifiers
A Digital Object Identifier (DOI) is the canonical persistent identifier for a publication and should be included in every citation where available.
DOI format and resolution:
- All DOIs begin with
10.followed by a registrant code (e.g.,10.1038/) and a suffix - Always resolve DOIs through
https://doi.org/10.XXXX/suffix— a bare DOI is not a clickable URL - Do not use
http://dx.doi.org/(old URL scheme); usehttps://doi.org/(current)
Preprint DOIs: bioRxiv, medRxiv, and ChemRxiv assign DOIs to preprints that are distinct from the eventual journal article DOI. When a preprint is published, both DOIs are valid but refer to different versions. Always check whether a peer-reviewed version exists; if so, cite the journal article DOI, not the preprint DOI.
Other persistent identifiers:
- PMID (PubMed ID) and PMCID (PubMed Central ID): NLM-specific; include alongside DOI for medical literature; required by NIH for funded publications
- arXiv ID: physics, math, CS preprints; format
arXiv:YYMM.NNNNN - ISBN: books and book chapters — not resolvable as URL; use for archival purposes only
- Handle: institutional repository identifiers; less universal than DOI
4. Citation Tracking and Impact Metrics
Citation tracking serves two purposes: monitoring how often your own publications are cited (research impact) and discovering papers that have cited a key reference you found (forward citation chaining).
| Tool | Coverage | Cost | Strengths |
|---|---|---|---|
| Google Scholar | Broadest (includes gray literature, theses, book chapters) | Free | Zero barrier; personal citations dashboard; free alerts |
| Web of Science | Peer-reviewed journals; curated | Subscription | Journal Impact Factor; author disambiguation; citation maps |
| Scopus | Broad peer-reviewed; strong non-English | Subscription | Better than WoS for non-English content; CiteScore metrics |
| iCite / NIH OCC | PubMed-indexed only | Free | Relative Citation Ratio (RCR); NIH-funded publication tracking |
| Semantic Scholar | AI-curated; broad | Free | "Highly influential citations" classification; concept extraction |
| OpenAlex | Fully open; ~250M works | Free | Open API; no subscription; successor to Microsoft Academic |
Setting up citation alerts: Google Scholar Alerts are the simplest way to receive email notifications when a paper is cited. Create an alert for your own papers, for key papers in your field, and for relevant author names.
Decision Framework
What is your primary need?
├── Set up or switch reference manager
│ ├── LaTeX + Overleaf as primary editor
│ │ └── → Zotero + Better BibTeX plugin (auto-syncing .bib)
│ ├── Google Docs as primary editor
│ │ └── → Paperpile (native Docs integration) or Zotero (Docs plugin)
│ ├── Microsoft Word, no institutional license
│ │ └── → Zotero (free, robust Word plugin)
│ ├── Microsoft Word, institutional license available
│ │ └── → EndNote (deepest Word integration) or Zotero
│ └── Collaborative team across institutions
│ └── → Zotero group library (free, open, no vendor lock-in)
│
├── Choose a citation style
│ ├── Medical / clinical journal → Vancouver (superscript numbered)
│ ├── Chemistry journal → ACS style
│ ├── Nature / Science family → Nature style
│ ├── Psychology / behavioral science → APA 7th
│ └── Engineering → IEEE
│ (Always check journal Instructions for Authors — do not guess)
│
├── Systematic review workflow
│ ├── Reference collection → Zotero or Mendeley
│ ├── Deduplication → reference manager built-in + manual check
│ ├── Title/abstract screening → Rayyan, Covidence, or Abstrackr
│ └── Full-text review → download PDFs into reference manager
│
└── Citation tracking
├── Free tracking → Google Scholar + iCite
└── Institutional tracking (IF, h-index) → Web of Science or Scopus
| Scenario | Recommended Approach | Key Reason |
|---|---|---|
| Solo academic, LaTeX-heavy | Zotero + Better BibTeX | Auto-syncing .bib; free; no vendor lock-in |
| Lab group with shared library | Zotero group library | Free for groups; version-control-friendly |
| Google Docs primary | Paperpile | Best native Docs integration |
| Medical or clinical writing | Any manager + Vancouver CSL | Style is journal-dictated; tool choice is secondary |
| Systematic review (PRISMA) | Zotero → RIS export → Rayyan | Best deduplication + screening workflow |
| Grant application (NIH) | Any manager + PMCID tracking | NIH requires PMCID for funded-research citations |
| Large funded lab, Word-centric | EndNote | Deepest Word integration; comprehensive style library |
| High-throughput literature mining | OpenAlex API (programmatic) | Free, open, bulk-accessible |
Best Practices
-
Capture metadata at the point of discovery, using the browser extension from the publisher's page: Do not bookmark URLs to "add later" — metadata capture from publisher pages is far more accurate than from PDFs, which frequently garble author names with non-ASCII characters, truncate long author lists incorrectly, or misparse page ranges in old articles.
-
Always verify DOI resolution after import: After importing any reference, click the DOI link. Automated metadata import frequently fails for pre-2000 papers, conference proceedings, and book chapters. A broken or wrong DOI is a legitimate reason for a post-publication correction and will delay typesetting.
-
Store PDFs inside the reference manager, not in a parallel folder structure: Keeping PDFs as attachments within the reference manager (rather than external links to a file system location) ensures citations and full texts remain synchronized when you move, rename, or reorganize directories. External file links break silently on directory restructuring.
-
Use a consistent citation key format for LaTeX and never change it mid-project: In Zotero with Better BibTeX, configure a global citation key template (e.g.,
[auth:lower][year][veryshorttitle]) before importing your first reference. Changing the template mid-project regenerates all keys, orphaning every\cite{}command in your existing.texfiles. -
Organize by topic, not by manuscript: Create collections by research topic ("CRISPR mechanisms," "cardiac MRI methods") rather than by output document ("Chapter 3," "Paper 1"). Topic organization allows reference reuse across projects and prevents accumulating duplicate entries in separate project collections.
-
Run "Find Duplicates" before every manuscript submission: Duplicate entries produce references with inconsistent formatting for the same paper — one as "Smith et al." and one with the full author list — which editors and reviewers immediately flag. Most managers include automatic deduplication; run it at least once before generating the final reference list.
-
Export a static archive at submission: Export the reference list as
.bib,.ris, or.enlat the moment of manuscript submission and archive it alongside the submitted PDF. This ensures you can reproduce the exact reference list even if your live library changes during the review period (which often lasts 6–18 months for high-impact journals). -
For NIH grants, track PMCIDs separately: NIH requires that any publication resulting from NIH funding include the PubMed Central ID (PMCID) in grant progress reports and renewal applications. Maintain a spreadsheet of your publications with PMID, PMCID, and grant numbers to avoid last-minute compliance scrambles.
Common Pitfalls
-
Trusting autofill metadata without spot-checking: CrossRef metadata is usually correct for recent articles, but PDF parsing for author names, especially those with diacritics, double surnames, or non-Latin scripts, fails frequently. Conference proceedings and book chapters are particularly unreliable.
- How to avoid: After importing any reference, check the author list (especially last author, often truncated), page numbers, journal name, and year against the actual paper. For old or non-English references, enter metadata manually.
-
Using the wrong citation style for the target journal: Reformatting 80 references from APA to Vancouver by hand introduces errors in author lists, punctuation, and journal name abbreviation. Journals reject or return manuscripts with wrong styles as a matter of policy.
- How to avoid: Check the journal's "Instructions for Authors" before writing, not before submission. Set the correct style in your reference manager plugin at the start of the project. Most CSL files are maintained in the Zotero style repository.
-
Citing preprints without flagging preprint status: bioRxiv/medRxiv preprints are citable but must be identified as preprints. Formatting a preprint identically to a journal article misleads readers about peer-review status and may violate journal policies.
- How to avoid: Verify that the reference manager's "Item Type" is set to "Preprint" (not "Journal Article"). The formatted citation should include the repository name (bioRxiv, medRxiv) and note it as a preprint. Before submission, search PubMed or the journal's website for a peer-reviewed version.
-
Duplicate library entries generating inconsistent in-text numbers: The same paper imported twice may appear as both reference [14] and reference [31], producing a malformed numbered reference list.
- How to avoid: Use "Find Duplicates" regularly. When merging a collaborator's library, resolve all duplicates before inserting citations into the shared document.
-
Word processor field codes collapsing to plain text during collaboration: Co-authors who do not have the same reference manager plugin, or who accept tracked changes from plain-text versions, can destroy the field codes that link in-text citations to the reference list.
- How to avoid: Share documents with field codes intact (not "Remove Field Codes" exports). Only flatten to plain text in the final post-acceptance version. For cloud collaboration, use Google Docs + Paperpile or a LaTeX-based workflow to avoid this entirely.
-
LaTeX .bib file conflicts in multi-author teams: Multiple team members editing a shared
.bibfile produces citation key conflicts, duplicate entries, and garbled author fields.- How to avoid: Designate one person as the .bib file owner. Use Zotero + Better BibTeX with auto-export to a git-tracked
.bibfile. All team members use the same repository; never edit the.bibfile manually.
- How to avoid: Designate one person as the .bib file owner. Use Zotero + Better BibTeX with auto-export to a git-tracked
-
Over-citing in grant applications, crowding out scientific content: Excessive citations in the Specific Aims or Research Strategy reduce space for the scientific narrative and signal to reviewers that the applicant cannot distinguish foundational from peripheral literature.
- How to avoid: Aim for 1–3 references per factual claim in grants. Prefer the most recent authoritative review plus the key primary paper over exhaustive citation. Use citation managers to identify the single most-cited original study for each claim.
Workflow
-
One-time setup
- Select reference manager based on team workflow and editor (see Decision Framework)
- Install browser extension (Zotero Connector, Mendeley Web Importer, or Paperpile extension)
- Install word processor plugin (Word add-in or Google Docs add-on)
- Create a project-specific collection in the library
- Set the citation style in the word processor plugin (or configure Better BibTeX for LaTeX)
- (LaTeX) Configure Better BibTeX: set citation key template, enable auto-export to
references.bibin project directory, commit.bibto git
-
Active collection phase
- Use browser extension to capture from publisher pages, PubMed, Google Scholar, or arXiv
- For books and grey literature, enter metadata manually (browser extension won't parse them correctly)
- Annotate PDFs with relevance notes and key quotes for later synthesis
- Run "Find Duplicates" weekly during active collection
-
Writing integration
- Insert citations using the word processor plugin — never type numbers manually
- (LaTeX) Use
\cite{key}with keys from the synchronized.bibfile - Periodically refresh the bibliography to catch newly inserted references
-
Pre-submission final check
- Run "Find Duplicates" one final time
- Spot-check 10% of DOIs for correct resolution
- Confirm citation style, journal name abbreviation, and DOI inclusion policy match journal requirements
- (LaTeX) Compile clean from exported
.bib; fix anyundefined citationwarnings - Export static archive (
.bib,.ris) and store alongside submission version - (NIH grants) Verify all funded publications have PMCIDs recorded
Protocol Guidelines
- Reference integrity audit: Before submission to a high-impact journal, have a lab member not involved in writing check 5–10 random references by verifying the DOI resolves and that the cited fact appears in that paper — this catches "citation chaining errors" where a claim propagates through literature without ever being verified in the original source.
- Systematic review deduplication protocol: Export all search results as
.risfiles, import into reference manager, run automated deduplication, then perform a manual review pass checking titles, years, and first author names — automated tools miss duplicates with slightly different metadata. - Style migration: When switching citation styles (e.g., between journal submissions), use the reference manager's style change function — never reformat manually. Verify the first 5 and last 5 references after the switch to confirm the style applied correctly.
Further Reading
- Zotero documentation — complete reference for collections, tags, groups, and plugins
- Better BibTeX for Zotero — LaTeX integration, citation key management, auto-export
- Citation Style Language (CSL) style repository — 10,000+ journal-specific styles for all major managers
- ICMJE citation recommendations — medical journal citation standards and authorship guidelines
- CrossRef DOI registration and resolution — authoritative DOI resolver and metadata registry
Related Skills
scientific-manuscript-writing— where citations are integrated into manuscript sections (Introduction, Discussion)literature-review— systematic approaches to collecting and screening the references you will managelatex-research-posters— LaTeX-specific BibTeX configuration and citation key workflow
skills/scientific-writing/clinical-decision-support-documents/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill clinical-decision-support-documents -g -y
SKILL.md
Frontmatter
{
"name": "clinical-decision-support-documents",
"license": "CC-BY-4.0",
"description": "Guidelines for clinical decision support (CDS) documents: biomarker-stratified cohort analyses and GRADE-graded treatment reports. Covers structure, executive summaries, evidence grading (1A–2C), stats (HR, CI, survival), and biomarker integration. Use for pharma research docs, clinical guidelines, regulatory submissions."
}
Clinical Decision Support Documents
Overview
Clinical decision support (CDS) documents are analytical reports for pharmaceutical research, guideline development, and regulatory submissions. This knowhow covers two main document types: Patient Cohort Analyses (biomarker-stratified group outcomes) and Treatment Recommendation Reports (evidence-graded clinical guidelines). For individual patient-level treatment plans, use the treatment-plans skill instead.
Key Concepts
1. Document Types
Patient Cohort Analysis — Group-level statistical comparison of patient subgroups stratified by biomarkers, molecular subtypes, or clinical characteristics.
- Typical content: demographics, biomarker stratification, outcome metrics (OS, PFS, ORR), Kaplan-Meier curves, forest plots
- Audience: pharmaceutical companies, clinical researchers, regulatory bodies
- Length: 5–15 pages (1-page executive summary + detailed sections)
Treatment Recommendation Report — Evidence-based clinical guidelines with GRADE-graded recommendations for disease management.
- Typical content: evidence review, recommendations by line of therapy, decision algorithm flowcharts, monitoring protocols
- Audience: guideline committees, medical affairs, KOLs
- Length: 5–20 pages
2. GRADE Evidence Grading System
The Grading of Recommendations, Assessment, Development and Evaluations (GRADE) system classifies recommendations by strength and evidence quality:
| Grade | Strength | Evidence Quality | Meaning |
|---|---|---|---|
| 1A | Strong | High | Benefits clearly outweigh risks; consistent RCT data |
| 1B | Strong | Moderate | Benefits likely outweigh risks; limited RCT data |
| 2A | Weak | High | Trade-offs exist; high-quality evidence but patient values matter |
| 2B | Weak | Moderate | Uncertain trade-offs; limited evidence |
| 2C | Weak | Low | Very uncertain; expert opinion or observational data only |
3. Outcome Metrics
| Metric | Abbreviation | Definition |
|---|---|---|
| Overall Survival | OS | Time from treatment start to death from any cause |
| Progression-Free Survival | PFS | Time to disease progression or death |
| Objective Response Rate | ORR | Proportion with CR + PR per RECIST 1.1 |
| Duration of Response | DOR | Time from first response to progression |
| Disease Control Rate | DCR | Proportion with CR + PR + SD |
4. Statistical Reporting Standards
- Hazard ratios: Report with 95% CI (e.g., HR 0.65, 95% CI 0.48–0.89, p=0.007)
- Survival data: Median OS/PFS with 95% CI + landmark rates (6-mo, 12-mo, 24-mo)
- Response rates: Point estimate with 95% CI
- Kaplan-Meier curves: Include number-at-risk tables below, censoring markers, log-rank p-value
- Subgroup analyses: Forest plots with interaction p-values; clearly label pre-specified vs exploratory
Decision Framework
Use this framework to select the appropriate document type:
Is this about a POPULATION or an INDIVIDUAL patient?
├── POPULATION (group-level analysis)
│ ├── Comparing outcomes between subgroups? → Patient Cohort Analysis
│ ├── Developing treatment guidelines? → Treatment Recommendation Report
│ └── Both analysis and recommendations? → Combined (cohort analysis + recommendations chapter)
└── INDIVIDUAL (single patient)
└── Use treatment-plans skill instead
| Scenario | Document Type | Key Sections |
|---|---|---|
| Phase 2/3 trial subgroup analysis | Cohort Analysis | Biomarker stratification, survival curves, forest plots |
| Clinical practice guideline | Treatment Recommendations | GRADE-graded recs, decision algorithm, evidence tables |
| Companion diagnostic development | Cohort Analysis | Biomarker-response correlation, sensitivity/specificity |
| Medical affairs strategy | Treatment Recommendations | Competitive landscape, positioning, KOL education |
| Real-world evidence study | Cohort Analysis | EMR cohort definition, outcomes by treatment arm |
Best Practices
-
Always start with a full-page executive summary: Page 1 should contain 3–5 colored summary boxes (findings, biomarkers, implications, statistics, safety) that are scannable in 60 seconds. No table of contents on page 1. This is the single most impactful formatting decision for CDS documents.
-
Use GRADE consistently: Every treatment recommendation must have a GRADE rating (1A–2C) with documented rationale. Do not mix GRADE with other rating systems within the same document.
-
Report effect sizes, not just p-values: Always include hazard ratios or odds ratios with 95% confidence intervals. A p-value alone does not convey clinical significance or effect magnitude.
-
Specify biomarker assay details: Name the platform (e.g., FoundationOne CDx, Ventana PD-L1 SP263), cut-points, and validation status. Biomarker results are only actionable when the assay is known.
-
Use RECIST 1.1 for response assessment: For immunotherapy cohorts, note iRECIST criteria and pseudoprogression handling. Clearly state which criteria were used.
-
Include number-at-risk tables: Below every Kaplan-Meier curve, show the number of patients at risk at each time point. This is mandatory for credible survival analysis.
-
Declare data completeness and follow-up: Report median follow-up time, data maturity (% events), and how missing data was handled (complete case, imputation method).
-
De-identify per HIPAA Safe Harbor: Remove all 18 HIPAA identifiers before including any patient-level data. Add confidentiality headers for proprietary pharmaceutical data.
-
Color-code consistently: Blue = data/information, green = biomarkers/positive, orange = clinical implications/caution, red = warnings/safety, gray = statistics/methods.
-
Date and version all recommendations: Include analysis date, data cutoff date, and planned update schedule. Treatment guidelines become outdated as new trial data emerges.
Common Pitfalls
-
Mixing population-level and individual-level recommendations: CDS documents analyze cohorts, not individuals. Stating "Patient X should receive..." is inappropriate. How to avoid: Use language like "Patients with biomarker X may benefit from..." or "Evidence supports [therapy] for [population] (Grade 1B)."
-
Over-interpreting subgroup analyses: Post-hoc subgroup analyses are hypothesis-generating, not confirmatory. How to avoid: Always label exploratory vs pre-specified subgroups. Report interaction p-values. State "These findings require prospective validation."
-
Omitting confidence intervals: Reporting median PFS = 12.5 months without CI makes the precision invisible. How to avoid: Always format as "median PFS 12.5 months (95% CI: 9.8–15.2)."
-
Ignoring competing risks: In oncology cohorts, patients may die from non-cancer causes, biasing standard Kaplan-Meier estimates. How to avoid: For OS analysis, note competing causes. For PFS, acknowledge censoring for non-disease events.
-
Inconsistent GRADE application: Grading one recommendation as 1A but not grading others leaves quality gaps. How to avoid: Grade every recommendation. If evidence is insufficient, assign 2C with "insufficient evidence" note.
-
Executive summary that is too detailed: A 2-page executive summary defeats the purpose. How to avoid: Limit to page 1 only. Use bullet points in colored boxes, not paragraphs. End with
\newpagebefore TOC. -
Missing regulatory compliance elements: Omitting confidentiality notices or HIPAA de-identification in pharmaceutical documents. How to avoid: Add confidentiality header to every page. Include de-identification statement in methods section.
Workflow
Standard CDS Document Development Process
- Define scope: Identify document type (cohort analysis vs treatment recommendations), disease state, target audience, and data sources
- Gather evidence: Collect trial data, biomarker results, published guidelines. For recommendations, perform systematic evidence review
- Design document structure: Select appropriate sections based on document type (see Decision Framework). Plan visual elements (survival curves, forest plots, decision algorithms)
- Draft executive summary first: Write the page-1 summary boxes before detailed sections. This forces clarity about key findings
- Author detailed sections: Write each section with proper statistical reporting. For cohort analyses: demographics → biomarkers → outcomes → subgroup comparisons. For recommendations: evidence review → GRADE assessment → recommendations by line → algorithm
- Create visual elements: Generate Kaplan-Meier curves, forest plots, waterfall plots, TikZ decision algorithms. Include number-at-risk tables below survival curves
- Apply GRADE ratings (recommendations only): Assess each recommendation against evidence quality criteria. Document rationale for each grade
- Format in LaTeX/PDF: Apply document template (0.5-inch margins, colored tcolorbox elements, professional tables). Ensure page 1 is executive summary only
- Quality check: Verify HIPAA compliance, statistical completeness (all CIs reported), GRADE consistency, reference completeness
Protocol Guidelines
LaTeX Document Setup
CDS documents use specific LaTeX packages and formatting:
- Margins: 0.5-inch all sides (compact, data-dense)
- Color boxes:
tcolorboxpackage with color-coded environments - Tables:
booktabsfor professional formatting,longtablefor multi-page tables - Figures: TikZ for decision algorithms,
pgfplotsfor survival curves - First page:
\thispagestyle{empty}+ executive summary boxes +\newpage
Executive Summary Box Pattern
Each CDS document's page 1 should have 3–5 tcolorbox elements:
- Report Information (blue): Document type, date, population, methodology
- Primary Results (blue): Main efficacy findings with key statistics
- Biomarker Insights (green): Molecular subtype findings or biomarker correlations
- Clinical Implications (orange): Actionable recommendations or treatment implications
- Safety/Warnings (red, if applicable): Critical adverse events or contraindications
Biomarker Classification Guide
When stratifying cohorts by biomarkers:
- Genomic: Mutations (EGFR, KRAS), CNV (HER2 amplification), fusions (ALK, ROS1)
- Expression: IHC scores (PD-L1 TPS/CPS), RNA-seq signatures
- Molecular subtypes: Disease-specific (PAM50 breast cancer, GBM clusters)
- Always specify: assay platform, cut-point, validation status, FDA companion diagnostic approval
Further Reading
- GRADE Handbook — official GRADE methodology documentation
- RECIST 1.1 Guidelines — response evaluation criteria in solid tumors
- CONSORT Statement — reporting standards for randomized trials
- STROBE Statement — reporting standards for observational studies
- ICH E9 Guidelines — statistical principles for clinical trials
Related Skills
- treatment-plans — individual patient-level care plans (complementary to population-level CDS)
- scientific-writing — manuscript structure, citation management, reporting guidelines
- statistical-analysis — detailed statistical methods (Cox regression, Kaplan-Meier, log-rank tests)
- matplotlib / plotly — figure generation for survival curves, forest plots, waterfall plots
skills/scientific-writing/elife-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill elife-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "elife-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "eLife figure preparation: file formats (TIFF\/EPS\/PDF), striking image requirements (1800x900 px), figure supplement naming, and image screening policy treating selective enhancement as misconduct.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
eLife Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to eLife. eLife is known for its open-access model, figure supplement system, striking image requirements, and a strict image screening policy where selective enhancement of scientific images is treated as research misconduct.
Official reference: https://elife-rp.msubmit.net/html/elife-rp_author_instructions.html
Resolution Requirements
| Image Type | Minimum Resolution |
|---|---|
| Striking images | 1800 x 900 pixels minimum |
| Regular figures | No strict DPI mandate (standard 300 DPI recommended) |
from PIL import Image
def check_elife_resolution(image_path, is_striking=False):
"""Check if image meets eLife resolution requirements.
Args:
is_striking: True for striking/graphical abstract images
"""
img = Image.open(image_path)
w, h = img.size
dpi = img.info.get('dpi', (72, 72))
print(f"Dimensions: {w} x {h} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
if is_striking:
if w >= 1800 and h >= 900:
print("PASS: Meets striking image minimum (1800x900 px)")
return True
else:
print(f"FAIL: Striking image needs 1800x900 px, got {w}x{h}")
return False
else:
if dpi[0] >= 300:
print("PASS: Resolution meets standard (300+ DPI)")
else:
print(f"NOTE: DPI is {dpi[0]}; 300+ DPI recommended")
return True
File Format
Regular Figures
| Format | Accepted |
|---|---|
| TIFF | Yes |
| EPS | Yes |
| Yes | |
| JPEG / JPG | Yes |
| GIF | Yes |
| PS (PostScript) | Yes |
| RTF | Yes |
| Microsoft Excel | Yes (for data-based figures) |
| CorelDraw | Yes |
Striking Images
| Format | Recommended |
|---|---|
| PNG | Yes (preferred) |
| TIFF | Yes |
| JPEG | Yes |
Figure Size and Dimensions
Striking Images (Graphical Abstract)
- Minimum: 1800 x 900 pixels
- Format: Landscape orientation
- Composed of 1-2 panels maximum
- No labels or text should be included
Regular Figures
- Each figure uploaded as an individual file
- No specific column width requirements published
Color Mode
- No explicit RGB/CMYK mandate in published guidelines
- Follow standard practice: RGB for digital submission
Font Requirements
Striking Images
- No labels or text should be included in striking images
Regular Figures
- No strict font specifications published
- Follow standard scientific figure conventions (sans-serif, 6-8 pt)
import matplotlib.pyplot as plt
def set_elife_fonts():
"""Configure Matplotlib with standard settings for eLife."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Helvetica', 'Arial'],
'font.size': 7,
'axes.labelsize': 7,
'axes.titlesize': 8,
'xtick.labelsize': 6,
'ytick.labelsize': 6,
'legend.fontsize': 6,
})
Labeling Conventions
Figure Supplements (eLife-Specific System)
eLife uses a unique figure supplement system instead of traditional supplementary figures:
- Format:
Figure 1--Figure Supplement 1,Figure 1--Figure Supplement 2, etc. - Each supplement should have a short title and an optional legend
- Supplements are linked to their parent figure in the publication
Striking Images
- Must NOT contain any labels or text
- Limited to 1-2 panels
def generate_elife_supplement_names(main_figure_num, n_supplements):
"""Generate eLife figure supplement naming.
Args:
main_figure_num: The main figure number (1, 2, 3, ...)
n_supplements: Number of supplements for this figure
Returns:
List of supplement name strings
"""
names = []
for i in range(1, n_supplements + 1):
names.append(f"Figure {main_figure_num}--Figure Supplement {i}")
return names
# Example
supplements = generate_elife_supplement_names(3, 4)
for s in supplements:
print(s)
# Output:
# Figure 3--Figure Supplement 1
# Figure 3--Figure Supplement 2
# Figure 3--Figure Supplement 3
# Figure 3--Figure Supplement 4
Image Integrity and Manipulation Policy
CRITICAL: eLife Screens Images Routinely
eLife routinely screens submitted images for inappropriate digital processing. This is not a random check — it is a systematic process.
PROHIBITED
- Enhancing, obscuring, moving, removing, or introducing any specific feature within an image
- Selective enhancement constitutes research misconduct
- Example: Adjusting the intensity of a single band in a blot = misconduct
PERMITTED
- Brightness, contrast, and color balance adjustments applied equally across the entire image
- Adjustments must NOT obscure or eliminate information present in the original
Consequences
- Inappropriate image manipulation may result in:
- Manuscript rejection
- Investigation by the author's institution
- Public correction or retraction of published articles
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_elife_figure(image_path, is_striking=False):
"""Full validation of a figure against eLife requirements."""
img = Image.open(image_path)
issues = []
w, h = img.size
# 1. Striking image checks
if is_striking:
if w < 1800 or h < 900:
issues.append(f"Striking image: {w}x{h} px below 1800x900 minimum")
if w < h:
issues.append("Striking image should be landscape orientation")
# 2. Resolution check (standard recommendation)
dpi = img.info.get('dpi', (72, 72))
if not is_striking and dpi[0] < 300:
issues.append(f"Resolution {dpi[0]} DPI; 300+ DPI recommended")
# 3. Format check
fmt = img.format
accepted = ['TIFF', 'JPEG', 'PNG', 'EPS', 'PDF', 'GIF']
if fmt and fmt.upper() not in accepted:
issues.append(f"Format '{fmt}' may not be standard")
if is_striking:
striking_formats = ['PNG', 'TIFF', 'JPEG']
if fmt and fmt.upper() not in striking_formats:
issues.append(f"Striking image: prefer PNG, TIFF, or JPEG (got {fmt})")
# Report
print(f"=== eLife Figure Validation {'(Striking)' if is_striking else ''} ===")
print(f"Dimensions: {w} x {h} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
print(f"Format: {fmt}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
print("\nWARNING: eLife routinely screens images for manipulation")
print("Selective enhancement (e.g., single band intensity) = MISCONDUCT")
return len(issues) == 0
Key Concepts
Figure Supplement System
eLife replaces traditional supplementary figures with a linked figure supplement system. Supplements are named "Figure X--Figure Supplement Y" and are directly associated with their parent figure in the publication. Each supplement requires a short title and optional legend. This system improves discoverability compared to buried supplementary files.
Striking Image Requirements
eLife requires a striking image (graphical abstract) with specific constraints: minimum 1800 x 900 pixels, landscape orientation, 1-2 panels maximum, and no labels or text. This image appears prominently in article listings and social media. It must be a standalone visual that conveys the paper's main finding.
Routine Image Screening Policy
eLife routinely screens all submitted images for inappropriate digital processing. This is systematic, not random. Selective enhancement of any part of a scientific image (e.g., adjusting intensity of a single band in a blot) is treated as research misconduct and may result in rejection, institutional investigation, or retraction.
Decision Framework
What type of figure are you preparing?
├── Striking image (graphical abstract)
│ ├── Dimensions: 1800 x 900 px minimum, landscape
│ ├── Format: PNG (preferred), TIFF, or JPEG
│ └── Content: No text, no labels, 1-2 panels max
├── Main figure
│ ├── Standard format: TIFF, EPS, PDF, JPEG
│ └── Upload as individual file
└── Figure supplement
├── Naming: "Figure X--Figure Supplement Y"
├── Include short title
└── Same format requirements as main figures
| Scenario | Format | Resolution | Notes |
|---|---|---|---|
| Striking image | PNG | 1800 x 900 px minimum | No text or labels |
| Western blot | TIFF | 300+ DPI | Will be screened for manipulation |
| Graph or chart | EPS or PDF | Vector | Individual file upload |
| Micrograph | TIFF | 300+ DPI | Uniform adjustments only |
| Supplementary panel | Same as main | Same as main | Name as "Figure X--Figure Supplement Y" |
| Data from Excel | Excel (.xlsx) | N/A | Accepted for data-based figures |
Best Practices
- Prepare striking images without any text: eLife strictly prohibits labels or text in striking images. The image must communicate visually without words
- Use the figure supplement system correctly: Name supplements using the exact format "Figure X--Figure Supplement Y" with double hyphens. Include a short descriptive title for each
- Apply all adjustments uniformly to entire images: eLife's screening software detects selective enhancement. Any brightness/contrast change must cover the whole image
- Maintain original unprocessed data: Given eLife's screening policy, having originals available demonstrates integrity and speeds up any inquiries
- Submit each figure as an individual file: Do not combine multiple figures into a single file. Each figure gets its own upload
- Use landscape orientation for striking images: Portrait-oriented striking images do not meet eLife's requirements. Ensure width exceeds height with minimum 1800 x 900 pixels
- Describe all processing in Methods: Document every image processing step (software used, adjustments applied) to preempt screening concerns
Common Pitfalls
- Adding text or labels to striking images: Any text in the striking image violates eLife requirements
- How to avoid: Create a purely visual composition; remove all annotations, labels, and text overlays
- Selectively enhancing parts of scientific images: Adjusting one region (e.g., a single gel lane) while leaving others unchanged is treated as misconduct
- How to avoid: Apply all brightness, contrast, and color adjustments to the entire image uniformly
- Using incorrect figure supplement naming: Missing the double-hyphen format or incorrect numbering breaks the linking system
- How to avoid: Use the exact format "Figure X--Figure Supplement Y" with double hyphens (--) and sequential numbering
- Submitting striking images below minimum size: Images under 1800 x 900 pixels are rejected
- How to avoid: Check pixel dimensions before upload; create striking images at or above the minimum
- Submitting portrait-oriented striking images: eLife requires landscape orientation for striking images
- How to avoid: Ensure width (1800+ px) exceeds height (900+ px) in the final composition
Pre-Submission Checklist
Before submitting figures to eLife, verify:
- Striking image: minimum 1800 x 900 px, landscape, no text/labels
- Striking image format: PNG, TIFF, or JPEG
- Regular figures: 300+ DPI recommended
- File format: TIFF, EPS, PDF, JPEG, or other accepted format
- Each figure uploaded as individual file
- Figure supplements named: "Figure X--Figure Supplement Y"
- Each supplement has a short title
- No selective enhancement of any image region
- All brightness/contrast adjustments applied to entire image
- Adjustments do not obscure or eliminate original information
- Original unprocessed data retained
- Aware that eLife routinely screens images for manipulation
References
- eLife Author Guide: https://elife-rp.msubmit.net/html/elife-rp_author_instructions.html
- eLife Image Screening Policy: https://elifesciences.org/inside-elife/77dd2808/taking-a-closer-look-screening-of-images
- eLife Submission Guidelines: https://reviewer.elifesciences.org/author-guide/full
skills/scientific-writing/general-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill general-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "general-figure-guide",
"license": "CC-BY-4.0",
"description": "Universal QA checklist for generated scientific plots: overlapping labels, clipped text, missing axes\/legends, overcrowded data, and cross-journal resolution\/format guidance."
}
General Scientific Figure Quality Guide
Overview
This guide provides a universal quality checklist for evaluating scientific figures, whether generated programmatically (matplotlib, seaborn, R/ggplot2) or assembled manually. It focuses on visual readability issues that are common across all journals and easily missed during automated plot generation.
Key Concepts
Visual Readability
A figure must communicate its data without requiring the reader to guess. The most common readability failures are overlapping labels, clipped text that runs outside the figure boundary, missing or unlabeled axes, absent legends, empty plot areas from incorrect data filtering, and overcrowded data points that merge into an unreadable mass.
Resolution and Output Format
Scientific figures generally require 300+ DPI for raster output (TIFF, PNG, JPEG) and vector formats (PDF, EPS, SVG) for line art and graphs. Vector formats are preferred for plots because they scale without quality loss. Raster formats are appropriate for photographs and micrographs.
Color Accessibility
Approximately 8% of males have some form of color vision deficiency. Figures that rely solely on red-green color differences exclude these readers. Use colorblind-friendly palettes (blue-orange, or viridis/cividis colormaps), add pattern or shape differentiation, and test figures with a colorblindness simulator before submission.
Uniform Image Adjustments
All major journals require that brightness, contrast, and color adjustments be applied uniformly to the entire image. Selective enhancement of specific regions (e.g., adjusting one gel lane) is considered data manipulation and grounds for rejection across all journals. Always document processing steps in the Methods section and retain original unprocessed files.
Decision Framework
Is the figure a generated plot or a photograph?
├── Generated plot (matplotlib, ggplot2, seaborn)
│ ├── Export as vector (PDF, SVG, EPS) → preferred
│ └── Export as raster → 300+ DPI minimum, PNG or TIFF
├── Photograph or micrograph
│ └── Export as raster → 300+ DPI, TIFF preferred
└── Multi-panel composite
├── Assemble panels first, then export as single file
└── Use consistent font sizes and label styles across panels
| Issue | How to Detect | Fix |
|---|---|---|
| Overlapping labels | Text visually collides on axes or legend | Rotate labels, reduce font size, or increase figure dimensions |
| Clipped text | Labels or titles cut off at figure edge | Increase margins with tight_layout() or constrained_layout |
| Missing axes or legends | No axis labels, units, or legend present | Add xlabel, ylabel, legend() calls |
| Empty plot area | Blank canvas with axes but no visible data | Check data filtering, column names, and plot function arguments |
| Overcrowded data | Points merge into solid mass | Reduce marker size, add transparency (alpha), or use density plots |
Best Practices
- Run a visual check after every plot generation: Inspect the rendered image for overlapping labels, clipped text, missing axes/legends, empty areas, and overcrowded data before proceeding
- Use
tight_layout()orconstrained_layout=True: These prevent text clipping at figure boundaries, the most common layout failure in matplotlib - Set DPI at figure creation time: Configure
fig.set_dpi(300)orplt.savefig(..., dpi=300)to ensure publication-quality output from the start - Export plots as vector formats: Use PDF, SVG, or EPS for graphs and diagrams. Reserve raster formats (PNG, TIFF) for photographs and micrographs
- Apply consistent font sizes across panels: All text in a multi-panel figure should use the same font family and comparable sizes (typically 6-8 pt for labels, 8 pt for panel identifiers)
- Add transparency for dense scatter plots: Use
alpha=0.3-0.5when plotting thousands of points to reveal density structure instead of a solid mass - Include units on all axes: Every axis should show the measured parameter and units in parentheses (e.g., "Time (hours)", "Concentration (nM)")
Common Pitfalls
- Not inspecting generated plots before saving: Automated pipelines often produce figures with layout issues that go unnoticed until review
- How to avoid: Always render and visually inspect each figure; if reviewing programmatically, check for text bounding-box overlaps
- Clipped axis labels or titles: Default matplotlib margins often cut off long labels or suptitles
- How to avoid: Call
fig.tight_layout()or create figures withconstrained_layout=True
- How to avoid: Call
- Missing legends on multi-series plots: Plots with multiple lines or groups are unreadable without a legend
- How to avoid: Always call
ax.legend()when plotting more than one series; verify legend entries match the data
- How to avoid: Always call
- Overcrowded scatter plots at large N: Scatter plots with >10,000 points become solid blobs at default settings
- How to avoid: Use
alphatransparency, hexbin plots, or kernel density estimation for large datasets
- How to avoid: Use
- Saving at screen resolution (72 DPI): Default screen DPI produces figures that are unprintable in journals
- How to avoid: Always specify
dpi=300(minimum) insavefig()or set it on the figure object at creation
- How to avoid: Always specify
Protocol Guidelines
- Set up figure dimensions and DPI: Before plotting, configure target width (journal column width), aspect ratio, and DPI (300+ minimum) on the figure object
- Generate the figure using your plotting library with the pre-configured settings
- Visually inspect the rendered output for these specific issues:
- Overlapping labels or annotations
- Clipped text at figure boundaries
- Missing axis labels, units, or legends
- Empty plot areas (data not rendered)
- Overcrowded or indistinguishable data points
- If any issue is found, regenerate with targeted fixes (adjust layout, font size, margins, alpha, or figure dimensions)
- Export in the correct format: vector (PDF/EPS/SVG) for plots, raster (TIFF/PNG at 300+ DPI) for photographs
- Verify the saved file: reopen the exported file to confirm it matches the on-screen rendering
Further Reading
- Matplotlib Figure Layout Guide -- tight_layout and constrained_layout usage
- Ten Simple Rules for Better Figures (PLOS) -- general principles for scientific figure design
- Points of View: Nature Methods Column -- visual design principles for scientific data
Related Skills
nature-figure-guide-- Nature-specific figure requirementscell-figure-guide-- Cell Press figure requirementsscience-figure-guide-- Science (AAAS) figure requirementspnas-figure-guide-- PNAS figure requirements
skills/scientific-writing/hypothesis-generation/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill hypothesis-generation -g -y
SKILL.md
Frontmatter
{
"name": "hypothesis-generation",
"license": "CC-BY-4.0",
"description": "Structured hypothesis formulation: turn observations into testable hypotheses with predictions, propose mechanisms, design experiments. Follows the scientific method. Use scientific-brainstorming for open ideation; hypogenic for automated LLM hypothesis testing on datasets."
}
Scientific Hypothesis Generation
Overview
Hypothesis generation is a systematic process for developing testable mechanistic explanations from observations. This knowhow covers the full cycle: from understanding a phenomenon through literature synthesis, generating competing hypotheses, evaluating hypothesis quality, designing experimental tests, and formulating testable predictions.
Key Concepts
1. Hypothesis vs Observation vs Prediction
- Observation: A factual statement about what was measured or seen (e.g., "Drug X reduces tumor size in mice")
- Hypothesis: A proposed mechanistic explanation for the observation (e.g., "Drug X inhibits angiogenesis via VEGF pathway blockade, reducing tumor nutrient supply")
- Prediction: A testable consequence of the hypothesis (e.g., "VEGF levels should decrease after Drug X treatment; tumors in VEGF-knockout mice should show no additional effect")
Good hypotheses are mechanistic (explain HOW/WHY), not descriptive (restate WHAT).
2. Hypothesis Quality Criteria
| Criterion | Definition | Example of Strong | Example of Weak |
|---|---|---|---|
| Testability | Can be empirically investigated | "Protein X binds to receptor Y" (can test with co-IP) | "Life force drives cellular growth" (untestable) |
| Falsifiability | Specific observations would disprove it | "If X is absent, effect disappears" | "X contributes to the effect somehow" |
| Parsimony | Simplest explanation fitting the evidence | Single mechanism | Multi-step chain without evidence |
| Explanatory Power | Accounts for observed patterns | Explains dose-response and tissue specificity | Explains only one observation |
| Scope | Range of phenomena covered | Applies across related systems | Limited to single dataset |
| Consistency | Aligns with established knowledge | Consistent with known pathway biology | Contradicts thermodynamics |
| Novelty | Offers new insight | Proposes unexplored mechanism | Restates established knowledge |
3. Levels of Mechanistic Explanation
Hypotheses can operate at different scales. Strong hypothesis sets include explanations at multiple levels:
- Molecular: Protein interactions, gene regulation, enzymatic activity
- Cellular: Signaling pathways, cell fate decisions, metabolic changes
- Tissue/Organ: Microenvironment, cell-cell communication, organ function
- Organismal: Systemic responses, physiological adaptation
- Population: Evolutionary pressures, epidemiological patterns
Decision Framework
What is your starting point?
├── Specific observation / data → Follow the full 8-step Workflow below
├── Broad research question → Start with Step 2 (literature search) to narrow scope
├── Existing hypothesis to refine → Start at Step 5 (evaluate quality) and iterate
└── Need creative ideation first → Use scientific-brainstorming skill, then return here
| Starting Situation | Approach | Key Steps |
|---|---|---|
| Unexpected experimental result | Phenomenon-driven | Steps 1→2→3→4 (focus on competing explanations) |
| Literature gap identified | Gap-driven | Steps 2→3→4→5 (focus on novelty criterion) |
| Cross-domain analogy noticed | Analogy-driven | Steps 1→4→5→6 (focus on translating mechanism) |
| Contradictory findings in literature | Conflict-driven | Steps 2→3→4→7 (focus on discriminating predictions) |
| Large dataset patterns | Data-driven | Use hypogenic first, then Steps 5→6→7 here |
Best Practices
-
Always generate competing hypotheses (3–5): A single hypothesis is a confirmation trap. Multiple competing explanations force you to design experiments that discriminate between alternatives, not just confirm your favorite.
-
Start with mechanism, not correlation: "X is associated with Y" is not a hypothesis. "X causes Y via mechanism Z" is. Always include the mechanistic link (HOW the cause produces the effect).
-
Make predictions that differ between hypotheses: The most valuable predictions are those where Hypothesis A predicts outcome X and Hypothesis B predicts outcome Y. This is called a "crucial experiment" — design your tests around these discriminating predictions.
-
Ground every hypothesis in evidence: Cite existing literature for each hypothesis. "It is known that pathway X can regulate process Y [Author, 2023]; therefore, we hypothesize that..." Unsupported hypotheses are speculation, not science.
-
State falsification criteria explicitly: For each hypothesis, write "This hypothesis would be falsified if..." before designing experiments. If you cannot state falsification criteria, the hypothesis is untestable.
-
Consider the null hypothesis: The simplest explanation — that there is no novel mechanism and observed effects are due to known processes, artifact, or chance — should always be included as one of the competing hypotheses.
-
Scale predictions quantitatively when possible: "Expression should increase" is weaker than "Expression should increase 2–5 fold (based on known pathway kinetics)." Quantitative predictions enable power analysis for experimental design.
Common Pitfalls
-
Confirmation bias in hypothesis selection: Generating one "main" hypothesis and 2-3 weak alternatives to make the main one look good. How to avoid: Generate hypotheses independently, then rank them by quality criteria. Have someone else review whether alternatives are genuinely competitive.
-
Untestable "just-so" stories: Hypotheses that sound plausible but cannot be empirically tested with current technology. How to avoid: For each hypothesis, immediately write the experiment that would test it. If you cannot design an experiment, the hypothesis needs revision.
-
Confusing correlation-based claims with mechanistic hypotheses: "Gene X is upregulated in disease Y" is not a hypothesis. How to avoid: Always include HOW and WHY in the hypothesis statement. Use the template: "[Mechanism] leads to [effect] because [rationale]."
-
Ignoring contradictory evidence: Cherry-picking literature that supports your hypothesis while ignoring opposing data. How to avoid: In Step 3 (Synthesize Evidence), explicitly section contradictory findings. Each hypothesis must address how it handles conflicting data.
-
Scope creep in hypothesis evaluation: Trying to make one hypothesis explain everything. How to avoid: A hypothesis does not need to explain all observations — it needs to explain the specific phenomenon under investigation. State scope boundaries explicitly.
-
Designing experiments that can only confirm: If your experiment cannot produce a negative result, it does not test your hypothesis. How to avoid: For each experiment, write down what "failure" looks like. Include negative and positive controls.
-
Neglecting feasibility in experimental design: Proposing experiments requiring technology, samples, or timelines that are unrealistic. How to avoid: Include feasibility assessment (available reagents, equipment, sample access, timeline) alongside each experimental proposal.
Workflow
Structured Hypothesis Generation Process (8 Steps)
-
Understand the phenomenon: Clarify the core observation, define scope and boundaries, note what is known vs uncertain, identify the relevant scientific domain(s)
-
Conduct literature search: Search PubMed (biomedical) and general databases for reviews, primary research, related mechanisms, and analogous systems. Look for gaps, contradictions, and unresolved debates
-
Synthesize existing evidence: Summarize current understanding, identify established mechanisms that may apply, note conflicting evidence, recognize knowledge gaps, find cross-domain analogies
-
Generate 3–5 competing hypotheses: Each must be mechanistic (explain HOW/WHY), distinguishable from others, evidence-grounded, and consider different levels of explanation (molecular → population)
-
Evaluate hypothesis quality: Score each hypothesis against the 7 quality criteria (testability, falsifiability, parsimony, explanatory power, scope, consistency, novelty). Note strengths and weaknesses explicitly
-
Design experimental tests: For each viable hypothesis, propose specific experiments with: measurements, controls, methods, sample sizes, statistical approaches, and potential confounds
-
Formulate testable predictions: State what should be observed if the hypothesis is correct, specify expected direction and magnitude, identify conditions where predictions hold, distinguish predictions between competing hypotheses
-
Present structured output: Organize findings into: executive summary, competing hypotheses with evidence, testable predictions, critical comparisons, and detailed appendices (literature review, experimental protocols, quality assessments)
Further Reading
- Platt, JR (1964) "Strong Inference" — Science 146:347-353. Classic paper on designing experiments to discriminate between competing hypotheses
- Popper, KR (1959) "The Logic of Scientific Discovery" — foundational framework for hypothesis falsification
- Chamberlin, TC (1890) "The Method of Multiple Working Hypotheses" — Science 15:92-96. Original argument for generating competing explanations
- NIH Guide to Hypothesis Development — practical guidance for grant-writing hypothesis sections
- Kell, DB & Oliver, SG (2004) "Here is the evidence, now what is the hypothesis?" — BioEssays 26:99-105. Data-driven hypothesis generation
Related Skills
- scientific-brainstorming — open-ended creative ideation when you need divergent thinking before structured hypothesis formulation
- scientific-critical-thinking — evaluating evidence quality and logical reasoning; complements hypothesis quality assessment
- literature-review — systematic evidence gathering; feeds into Steps 2–3 of this workflow
- statistical-analysis — power analysis and experimental design statistics for Step 6
- scientific-writing — structuring hypothesis-driven manuscripts for publication
skills/scientific-writing/lancet-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill lancet-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "lancet-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "The Lancet figure preparation: resolution (300+ DPI at 120%), preferred editable formats (PowerPoint\/Word\/SVG), column widths (75\/154 mm), Times New Roman, in-house redraw policy.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
The Lancet Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to The Lancet and Lancet family journals. A key distinguishing feature of The Lancet is that most figures are redrawn by in-house illustrators into Lancet house style. Therefore, editable source formats (PowerPoint, Word, SVG) are strongly preferred over rasterized images.
Official reference: https://www.thelancet.com/submission-guidelines
Resolution Requirements
| Requirement | Specification |
|---|---|
| All photographic images | 300 DPI minimum |
| Submission size | 120% of intended publication size |
TIP: Supply images 20% larger than publication size to ensure quality is maintained after any resizing by the production team.
from PIL import Image
def check_lancet_resolution(image_path):
"""Check if image meets Lancet resolution requirements."""
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Minimum required: 300 DPI")
print(f"TIP: Submit at 120% of publication size")
if dpi[0] >= 300:
print("PASS: Resolution meets Lancet requirements")
else:
print("FAIL: Need at least 300 DPI")
return dpi[0] >= 300
File Format
Preferred Formats (Editable)
| Format | Notes |
|---|---|
| PowerPoint (.pptx) | Most preferred — easy for in-house redraw |
| Word (.docx) | Accepted for simple figures |
| SVG | Scalable vector graphics |
Also Accepted
| Format | Notes |
|---|---|
| TIFF | For photographic images |
| JPEG | Acceptable quality |
| EPS | Vector format |
| General purpose |
Why Editable Formats?
The Lancet's in-house illustration team redraws most submitted figures into the Lancet house style. Providing editable source files (especially PowerPoint) makes this process smoother and more accurate.
Figure Size and Dimensions
| Layout | Width |
|---|---|
| 1 column | 75 mm (2.95 in) |
| 2 columns | 154 mm (6.06 in) |
| Minimum width | 107 mm (4.21 in) |
- All panels must fit on a single page
import matplotlib.pyplot as plt
LANCET_WIDTHS = {
'single': 75 / 25.4, # 2.95 inches
'minimum': 107 / 25.4, # 4.21 inches
'double': 154 / 25.4, # 6.06 inches
}
def create_lancet_figure(layout='single', aspect_ratio=0.75):
"""Create a Matplotlib figure sized for The Lancet."""
width = LANCET_WIDTHS[layout]
height = width * aspect_ratio
fig, ax = plt.subplots(figsize=(width, height))
fig.set_dpi(300)
return fig, ax
Color Mode
| Purpose | Color Mode |
|---|---|
| Online publication | RGB |
| Print publication | CMYK |
- Color and black-and-white photographs both accepted
Font Requirements
| Element | Font | Size | Style |
|---|---|---|---|
| Main figure heading | Times New Roman | 10 pt | Bold |
| Figure legend | Times New Roman | 10 pt | Regular, single-spaced |
Key Difference from Other Journals
- The Lancet uses Times New Roman (serif font), unlike most other biomedical journals that prefer sans-serif fonts
import matplotlib.pyplot as plt
def set_lancet_fonts():
"""Configure Matplotlib for Lancet figure fonts."""
plt.rcParams.update({
'font.family': 'serif',
'font.serif': ['Times New Roman', 'Times'],
'font.size': 10,
'axes.labelsize': 10,
'axes.titlesize': 10,
'axes.titleweight': 'bold',
'xtick.labelsize': 8,
'ytick.labelsize': 8,
'legend.fontsize': 8,
})
Labeling Conventions
Rules
- No box outlines around graphs
- No titles inside graphs or artwork — include in caption instead
- Number figures in order of citation in text
Best Practices
- Keep figures as simple and clear as possible
- The Lancet illustrators will redraw most figures, so focus on conveying data accurately rather than visual polish
Image Integrity and Manipulation Policy
- Follow standard biomedical publishing ethics
- All image adjustments should be applied uniformly
- Describe any image processing in Methods
- Maintain original data for potential review
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_lancet_figure(image_path, layout='single'):
"""Full validation of a figure against Lancet requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check (300 DPI at 120% size)
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < 300:
issues.append(f"Resolution {dpi[0]} DPI below 300 DPI minimum")
# 2. Dimension check
widths_mm = {'single': 75, 'minimum': 107, 'double': 154}
if layout in widths_mm and dpi[0] > 0:
actual_w_mm = (img.size[0] / dpi[0]) * 25.4
target = widths_mm[layout]
# Should be 120% of publication size
target_120 = target * 1.2
print(f"Width: {actual_w_mm:.1f} mm (target at 120%: {target_120:.1f} mm)")
# 3. Format recommendation
fmt = img.format
if fmt and fmt.upper() in ('TIFF', 'JPEG', 'PNG'):
print(f"NOTE: Format is {fmt}. Lancet prefers PowerPoint/Word/SVG for redrawing")
# Report
print(f"=== Lancet Figure Validation ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
return len(issues) == 0
Key Concepts
In-House Figure Redraw Policy
The Lancet's most distinctive feature is that most submitted figures are redrawn by in-house illustrators into Lancet house style. This means editable source formats (PowerPoint, Word, SVG) are strongly preferred over polished raster images. Authors should focus on data accuracy rather than visual refinement.
The 120% Size Rule
The Lancet requires photographic images to be submitted at 120% of intended publication size at 300+ DPI. This buffer ensures quality is maintained after any resizing during production. A figure intended for single-column (75 mm) should be supplied at 90 mm width.
Serif Font Convention
Unlike most biomedical journals that use sans-serif fonts, The Lancet uses Times New Roman as its standard figure font. Headings are 10 pt bold; legend text is 10 pt regular single-spaced. This aligns with Lancet's traditional typographic style.
Decision Framework
What type of figure are you preparing?
├── Data visualization (graph, chart)
│ ├── PowerPoint available → Submit .pptx (preferred for redraw)
│ └── Only image available → SVG or EPS (vector, editable)
├── Photograph or clinical image
│ ├── High quality needed → TIFF at 300+ DPI, 120% size
│ └── Standard quality → JPEG at 300+ DPI, 120% size
└── Schematic or diagram
├── Editable source → PowerPoint or Word
└── Vector only → SVG or EPS
| Scenario | Format | Resolution | Notes |
|---|---|---|---|
| Bar chart from clinical trial | PowerPoint (.pptx) | N/A (vector) | Lancet redraws in house style |
| Kaplan-Meier curve | PowerPoint or SVG | N/A (vector) | Provide raw data if possible |
| Histology micrograph | TIFF | 300+ DPI at 120% size | Cannot be redrawn |
| Flowchart (CONSORT) | PowerPoint or Word | N/A (editable) | Lancet will redraw |
| Forest plot (meta-analysis) | PowerPoint or SVG | N/A (vector) | Data accuracy is priority |
Best Practices
- Submit editable formats whenever possible: PowerPoint is most preferred because Lancet illustrators redraw figures. Provide the editable source, not a rasterized export
- Supply images at 120% of publication size: Calculate target size (75 mm or 154 mm column width), then multiply by 1.2 for the submission dimensions
- Use Times New Roman consistently: The Lancet's serif font convention differs from most journals. Set Times New Roman as default before creating figures
- Remove box outlines from graphs: Lancet style does not use borders around graph panels. Remove any automatic axis boxes
- Place titles in captions only: Do not embed titles inside figures or graphs. All descriptive text goes in the figure caption
- Focus on data accuracy over aesthetics: Since figures will be redrawn, ensure data values are correct and clearly labeled rather than investing in visual polish
- Number figures in citation order: Figures must be numbered sequentially in the order they are first cited in the text
Common Pitfalls
- Submitting only rasterized images for data figures: Lancet illustrators cannot redraw from JPEG/PNG rasters of graphs
- How to avoid: Always provide the editable source file (PowerPoint, Word, or SVG) alongside any raster export
- Forgetting the 120% size requirement: Submitting at 100% publication size means the image will be undersized after production resizing
- How to avoid: Multiply the target column width by 1.2 before setting your canvas size
- Using sans-serif fonts: Most journals use Arial/Helvetica, but Lancet uses Times New Roman. Sans-serif fonts signal unfamiliarity with Lancet style
- How to avoid: Set Times New Roman as the default font before creating any Lancet figures
- Including box outlines around graphs: Lancet style prohibits boxes around graph panels, a common default in plotting software
- How to avoid: Disable axis box/frame in your plotting settings (e.g.,
ax.spinesin matplotlib)
- How to avoid: Disable axis box/frame in your plotting settings (e.g.,
- Over-polishing figure aesthetics: Since Lancet redraws most figures, spending excessive time on visual refinement is wasted effort
- How to avoid: Ensure data accuracy and clear labeling; leave visual styling to Lancet's illustration team
Pre-Submission Checklist
Before submitting figures to The Lancet, verify:
- Photographic images at 300+ DPI
- Images supplied at 120% of publication size
- Preferred format: PowerPoint, Word, or SVG (for in-house redraw)
- TIFF/JPEG/EPS acceptable for photographs
- 1-column figures: 75 mm wide
- 2-column figures: 154 mm wide
- All panels fit on a single page
- Font is Times New Roman (10 pt bold for headings)
- No box outlines around graphs
- No titles inside graphs (use captions)
- Figures numbered in order of text citation
- Data clearly and accurately represented (Lancet will redraw)
- Original data retained for review
References
- Lancet Submission Guidelines: https://www.thelancet.com/submission-guidelines
- Lancet Artwork Guidelines: https://www.thelancet.com/pb/assets/raw/Lancet/test/artwork-guidelines-aug2015.pdf
- Lancet Information for Authors: https://www.thelancet.com/information-for-authors
skills/scientific-writing/latex-research-posters/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill latex-research-posters -g -y
SKILL.md
Frontmatter
{
"name": "latex-research-posters",
"license": "CC-BY-4.0",
"description": "Research posters in LaTeX using beamerposter, tikzposter, or baposter. Layout, typography, color schemes, figure integration, accessibility, and QA for conferences. Includes templates. For figure generation use matplotlib-scientific-plotting or plotly-interactive-plots."
}
LaTeX Research Posters
Overview
Research posters are a critical medium for scientific communication at conferences, symposia, and academic events. This knowhow covers end-to-end poster creation in LaTeX: package selection, layout design, typography, color schemes, figure integration, accessibility, compilation, and quality control for print and digital display.
Key Concepts
1. LaTeX Poster Packages
Three major packages, each with distinct strengths:
| Package | Architecture | Best For | Learning Curve |
|---|---|---|---|
| beamerposter | Beamer extension | Traditional academic posters, institutional branding | Low (if you know Beamer) |
| tikzposter | TikZ-based blocks | Modern colorful designs, custom graphics | Medium |
| baposter | Box-based grid | Multi-column layouts, consistent spacing | Low |
beamerposter: Uses Beamer's \begin{block} syntax. Themes and color schemes from Beamer carry over. Best when your institution already has a Beamer theme.
tikzposter: Declarative block placement with \block{Title}{Content}. Built-in color styles (Denmark, Germany, etc.) and layout themes (Rays, Wave). Most flexible for custom designs.
baposter: Defines named boxes in a grid. Automatic positioning and spacing. Best when you want structured, uniform column layouts without manual placement.
2. Poster Dimensions and Orientation
| Standard | Size | Region | Use |
|---|---|---|---|
| A0 | 841 × 1189 mm (33.1 × 46.8 in) | Europe | Most common academic standard |
| A1 | 594 × 841 mm (23.4 × 33.1 in) | Europe | Smaller venues |
| 36 × 48 in | 914 × 1219 mm | North America | Standard US conference size |
| 42 × 56 in | 1067 × 1422 mm | North America | Large format |
| 48 × 72 in | 1219 × 1829 mm | North America | Extra large |
Orientation: Portrait (vertical) is most common and traditional. Landscape (horizontal) works better for timelines, wide figures, or side-by-side comparisons.
3. Typography Rules
| Element | Size Range | Purpose |
|---|---|---|
| Title | 72–120 pt | Readable from 15+ feet |
| Section headers | 48–72 pt | Readable from 8–10 feet |
| Body text | 24–36 pt | Readable from 4–6 feet |
| Captions | 20–28 pt | Readable from 3 feet |
- Use sans-serif fonts (Helvetica, Calibri, Arial) for poster readability
- Limit to 2–3 font families maximum
- Avoid italics (harder to read from distance)
- Use bold for emphasis, not underlining
4. Visual Content Guidelines
- 40–50% of poster area should be visual content (figures, diagrams, tables)
- 300 DPI minimum for raster images at final print size
- Use vector graphics (PDF, SVG) whenever possible for scalability
- Target 3–6 main figures per poster
- Total text: 300–800 words (less is more)
Decision Framework
Package Selection
Start: What is your design priority?
├── Institutional branding / existing Beamer theme?
│ └── YES → beamerposter
├── Modern, colorful, custom TikZ graphics?
│ └── YES → tikzposter
├── Structured multi-column grid with minimal setup?
│ └── YES → baposter
└── Not sure
└── tikzposter (most flexible default)
Layout Decision Table
| Poster Content | Columns | Layout Strategy |
|---|---|---|
| Few key results, large figures | 2 columns | Wide figure panels, brief text |
| Balanced text and figures | 3 columns | Standard academic layout |
| Data-heavy with many small figures | 4 columns | Compact grid, small text |
| Narrative flow / timeline | 2 columns landscape | Left-to-right story |
Content vs Visual Balance
| Poster Type | Text % | Visual % | Word Count |
|---|---|---|---|
| Experimental research | 40% | 60% | 400–600 |
| Computational/modeling | 50% | 50% | 500–700 |
| Review/survey | 55% | 45% | 600–800 |
| Method paper | 35% | 65% | 300–500 |
Best Practices
-
MANDATORY: Every poster must include at least 2 figures. Posters are primarily visual media — text-heavy posters fail to communicate. Target 3–4 figures for comprehensive posters: methodology diagram, key results, conceptual framework.
-
MANDATORY: Verify poster dimensions match conference requirements exactly. Use
pdfinfo poster.pdf | grep "Page size"after compilation. A0 should show ~2384 × 3370 points. -
Follow the Z-pattern reading flow. Place the most important content (title, key result figure) in the top-left quadrant. Readers naturally scan: top-left → top-right → bottom-left → bottom-right.
-
Use white space intentionally. White space is not wasted space — it improves readability and visual hierarchy. Don't fill every gap with text.
-
Keep text scannable. Use bullet points instead of paragraphs. Each section should be understandable in under 30 seconds.
-
Anti-pattern — cramming the full paper into poster format. A poster is not a shrunken paper. Extract 1–3 key messages and design around those. Leave details for the handout or QR-linked paper.
-
Test readability at reduced scale. Print at 25% scale on letter/A4 paper. If the title isn't readable from 6 feet, the body text isn't readable from 2 feet, or figures are unclear, revise.
-
Use color-blind friendly palettes. Avoid red-green combinations (affects ~8% of males). Use Viridis, ColorBrewer, or IBM Color Blind Safe palettes. Add patterns or shapes alongside color coding.
-
Embed all fonts in the final PDF. Run
pdffonts poster.pdf— every font should show "yes" in the "emb" column. Non-embedded fonts may render differently on the printer's system. -
Include QR codes for supplementary materials. Link to: full paper (DOI), code repository (GitHub), data (Zenodo), video demo. Minimum size: 2 × 2 cm for reliable scanning.
Common Pitfalls
-
Font sizes too small (under 24pt body text). Viewers stand 4–6 feet away; small text is unreadable. How to avoid: Set minimum body text to 24pt and verify with a reduced-scale print test.
-
Too much text (over 1000 words). Poster sessions are 2–5 minutes per viewer; they won't read paragraphs. How to avoid: Target 300–800 words. Replace explanatory text with annotated figures.
-
Low-resolution images that pixelate when printed. Screen-resolution images (72–150 DPI) look fine on monitor but terrible at poster size. How to avoid: Ensure all raster images are 300+ DPI at final print size. Use
pdfimages -list poster.pdfto verify. -
RGB colors sent to CMYK printer cause color shift. Bright screen colors appear dull or shifted when printed. How to avoid: Request the printer's color profile; convert color space if required. When in doubt, use muted, high-contrast palettes.
-
Content extends beyond page boundaries or large white margins. Default margin settings often waste 10–15% of poster area. How to avoid: Set explicit margins (10mm recommended) in documentclass options. Debug with a visible page boundary frame during development.
-
Placeholder text left in final version. "Lorem ipsum", "TODO", or template instructions left visible. How to avoid: Use the QC checklist (Step 8 of Workflow) systematically — check for all placeholder text before sending to print.
-
Unembedded fonts cause rendering failures. The printer's system substitutes different fonts, breaking layout and character rendering. How to avoid: Compile with
pdflatex(auto-embeds), or use-dEmbedAllFonts=true. Verify withpdffonts. -
No clear visual hierarchy — everything looks the same importance. Viewers can't identify the key message or navigate the poster. How to avoid: Use 3 distinct size levels (title, headers, body). Use color blocks to group related content. Add a "Take-Home Message" box.
Workflow
Stage 1: Planning
- Confirm requirements: poster size, orientation, submission deadline, format requirements
- Draft content outline: identify 1–3 core messages, select 3–6 key figures
- Choose package: beamerposter (institutional), tikzposter (modern), or baposter (structured)
- Plan layout: number of columns (2–4), content flow (Z-pattern), space allocation (title 10–15%, content 70–80%, footer 5–10%)
Stage 2: Template Setup
- Start from a template (see Companion Assets): customize color scheme, page size, orientation
- Configure typography: set font sizes per hierarchy level (title → headers → body → captions)
- Set margins: 10mm outer margins recommended; configure column spacing (15–20mm)
- Add institutional elements: logos (high-resolution), color codes (official RGB/CMYK values)
Stage 3: Content Integration
- Create header: title (10–15 words, concise), authors, affiliations, logos
- Populate sections: Introduction, Methods, Results, Conclusions — bullet points preferred
- Generate figures: create publication-quality figures using matplotlib, plotly, or specialized tools:
- Methodology flowcharts and experimental design diagrams
- Results plots (bar charts, heatmaps, scatter plots, Kaplan-Meier curves)
- Conceptual framework or model architecture diagrams
- Integrate figures: use
\includegraphicswith consistent sizing and clear captions - Add references: 5–10 key citations in abbreviated style; consider QR code to full bibliography
- Add QR codes: link to paper DOI, GitHub repo, supplementary data
Stage 4: Refinement
- Review visual balance: ensure 40–50% visual content, no overcrowded sections
- Check typography: all fonts readable at intended viewing distances
- Verify color accessibility: test with color-blindness simulator (e.g., Coblis)
- Check contrast ratios: text-background ≥ 4.5:1 (WCAG AA), important elements ≥ 7:1 (WCAG AAA)
- Proofread: all text, author names, affiliations, numbers, statistics
Stage 5: Compilation and QC
- Compile:
pdflatex poster.tex(orlualatexfor better font support) - Verify page size:
pdfinfo poster.pdf | grep "Page size"— must match requirements exactly - Verify font embedding:
pdffonts poster.pdf— all "yes" in "emb" column - Verify image resolution:
pdfimages -list poster.pdf— all ≥300 DPI - Reduced-scale print test: print at 25% on letter/A4; check readability from 2, 4, 6 feet
- Peer review: 30-second test (can they identify main message?), 5-minute review (do they understand conclusions?)
- Optimize for delivery: compress for email (< 10MB) if needed; keep original for printing
- Final checklist: no placeholders, all citations resolved (no [?] marks), file named
[LastName]_[Conference]_Poster.pdf
Protocol Guidelines
Compilation Commands
# Basic compilation
pdflatex poster.tex
# With bibliography
pdflatex poster.tex && bibtex poster && pdflatex poster.tex && pdflatex poster.tex
# Better font support
lualatex poster.tex
# or
xelatex poster.tex
Quality Control Commands
# Verify page dimensions
pdfinfo poster.pdf | grep "Page size"
# Check font embedding
pdffonts poster.pdf
# Check image resolution
pdfimages -list poster.pdf
# Check for compilation warnings
grep -i "warning\|error\|overfull\|underfull" poster.log
# Compress for email (keeps print quality)
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 \
-dPDFSETTINGS=/printer -dNOPAUSE -dQUIET -dBATCH \
-sOutputFile=poster_compressed.pdf poster.pdf
Package Installation
# TeX Live (Linux/Mac)
tlmgr install beamerposter tikzposter baposter qrcode subcaption tcolorbox
# MiKTeX (Windows) — packages auto-install on first use
Further Reading
- beamerposter CTAN page — official documentation and examples
- tikzposter CTAN page — themes, color styles, block customization
- baposter project page — box-based layout documentation
- Colin Purrington, "Designing conference posters" — evidence-based design guidelines (https://colinpurrington.com/tips/poster-design)
- Web AIM Contrast Checker — WCAG compliance verification
Related Skills
- matplotlib-scientific-plotting — generate publication-quality figures (bar charts, heatmaps, scatter plots) for poster content
- plotly-interactive-plots — create interactive figures exportable as static PNG/PDF for posters
- seaborn-statistical-plots — statistical plots with automatic aggregation for results figures
Companion Assets
Templates are provided in the assets/ subdirectory:
| File | Package | Description |
|---|---|---|
beamerposter_template.tex |
beamerposter | Traditional 3-column portrait layout (A0) with institutional header |
tikzposter_template.tex |
tikzposter | Modern block-based design with Denmark color style |
baposter_template.tex |
baposter | Structured 3-column portrait grid with automatic spacing |
Each template is a complete, compilable LaTeX document with placeholder content. Customize: (1) replace placeholder text and figures, (2) adjust color scheme, (3) update page size if needed.
skills/scientific-writing/literature-review/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill literature-review -g -y
SKILL.md
Frontmatter
{
"name": "literature-review",
"license": "CC-BY-4.0",
"description": "Conducting systematic, scoping, and narrative literature reviews. Covers PRISMA\/PRISMA-ScR protocols, search strategy (Boolean, MeSH), database selection (PubMed, Scopus, Web of Science, Embase), screening, data extraction, evidence synthesis (narrative, meta-analysis, thematic), and reporting. Use when planning or executing a formal literature review."
}
Conducting a Literature Review
Overview
A literature review systematically identifies, appraises, and synthesizes published evidence on a defined research question. The method ranges from informal narrative reviews to highly structured systematic reviews with meta-analysis. Choosing the correct review type, building a reproducible search strategy, and applying transparent inclusion/exclusion criteria are the foundational decisions that determine whether a review can be trusted and published in a high-impact journal. This guide covers the full workflow from question formulation to synthesis and reporting.
Key Concepts
1. Review Type Taxonomy
| Review Type | Definition | When to Use | Time Required |
|---|---|---|---|
| Narrative review | Selective, expert-curated synthesis; no protocol; no PRISMA | Introducing a topic; describing mechanistic background | Days to weeks |
| Scoping review | Comprehensive mapping of evidence landscape; PRISMA-ScR; no quality appraisal | Understand what evidence exists before committing to systematic review | Weeks to months |
| Systematic review | Exhaustive search; predefined protocol (PROSPERO); quality appraisal; PRISMA | Answer a specific clinical/scientific question with highest rigor | Months to years |
| Meta-analysis | Systematic review + quantitative pooling of effect estimates | Quantify pooled effect size and heterogeneity across studies | Months to years |
| Umbrella review | Systematic review of existing systematic reviews | Synthesize evidence from multiple reviews on one topic | Months |
| Rapid review | Streamlined systematic review with time-limited methods | Time-sensitive policy or clinical decisions | Weeks |
Peer reviewer expectations: Journals in the biomedical domain expect systematic and scoping reviews to follow PRISMA or PRISMA-ScR reporting standards and to be pre-registered in PROSPERO (systematic reviews only). Narrative reviews are typically invited by editors rather than submitted unsolicited.
2. PICO / PICOS Framework for Question Formulation
Systematic reviews require a precisely defined research question. The PICO framework structures the question into searchable, operationalizable components:
| Component | Meaning | Example (for a clinical question) |
|---|---|---|
| P | Population | Adults with type 2 diabetes aged ≥ 40 |
| I | Intervention | SGLT2 inhibitors (empagliflozin, dapagliflozin) |
| C | Comparison | Placebo or standard care |
| O | Outcome | Cardiovascular mortality, HbA1c, eGFR decline |
| S | Study design (PICOS) | Randomized controlled trials only |
For basic science questions, adapt to PECO (Population, Exposure, Comparator, Outcome) or a custom framework. A well-formed PICO directly maps to search terms for each database.
3. Evidence Hierarchy
Different study designs provide different levels of certainty about causal effects. Standard hierarchy for intervention questions (highest to lowest):
Systematic reviews and meta-analyses of RCTs (highest certainty)
↓
Individual randomized controlled trials (RCTs)
↓
Non-randomized controlled trials / quasi-experiments
↓
Prospective cohort studies
↓
Retrospective cohort / case-control studies
↓
Cross-sectional studies
↓
Case series and case reports
↓
Expert opinion / narrative review / editorials (lowest certainty)
For diagnostic accuracy, prognosis, and etiology questions, the hierarchy differs. The GRADE framework (Grading of Recommendations Assessment, Development and Evaluation) formalizes evidence quality across four domains: risk of bias, inconsistency, indirectness, and imprecision.
4. Database Coverage
No single database covers all literature. Major databases and their coverage:
| Database | Coverage | Strength | Access |
|---|---|---|---|
| PubMed / MEDLINE | >35M biomedical records; 1946+ | Free; MeSH controlled vocabulary; high precision | Free |
| Embase | >34M records; European + drug focus; 1947+ | Best for pharmacology and European journals | Subscription |
| Web of Science | ~90M records across science and humanities | Citation analysis; interdisciplinary | Subscription |
| Scopus | ~90M records; broad | Largest abstract database; good non-English | Subscription |
| CINAHL | Nursing and allied health | Best for nursing/PT/OT research | Subscription |
| PsycINFO | Psychology and behavioral science | Deep coverage of behavioral literature | Subscription |
| Cochrane CENTRAL | Controlled trials only; curated | Highest precision for RCTs | Free/subscription |
| ClinicalTrials.gov | US-registered clinical trials | Includes unpublished/ongoing trials | Free |
| Grey literature | Reports, theses, guidelines | Reduces publication bias | Various |
Systematic reviews should search at minimum: PubMed + Embase + one domain-specific database + Cochrane CENTRAL (for clinical topics). Searching only PubMed biases toward US/English publications and misses up to 30% of relevant trials.
Decision Framework
What is your literature review goal?
│
├── "Understand the topic background for my paper's Introduction"
│ └── → Narrative review: select key papers; no protocol needed
│
├── "Map what evidence exists before designing a study"
│ └── → Scoping review (PRISMA-ScR): comprehensive but no quality appraisal
│
├── "Answer a specific clinical or scientific question rigorously"
│ ├── Quantitative data poolable across studies?
│ │ ├── Yes → Systematic review + meta-analysis (PRISMA + PROSPERO)
│ │ └── No → Systematic review with narrative synthesis only
│ └── Time-constrained (policy deadline)?
│ └── → Rapid review (document scope limitations)
│
└── "Synthesize existing systematic reviews"
└── → Umbrella review
| Review type | Pre-registration required? | Quality appraisal? | Reporting standard | Minimum databases |
|---|---|---|---|---|
| Narrative | No | No | None formal | Author's choice |
| Scoping | No (recommended) | No | PRISMA-ScR | ≥2 major databases |
| Systematic | Yes (PROSPERO) | Yes (RoB 2, ROBINS-I, etc.) | PRISMA 2020 | ≥3 databases |
| Meta-analysis | Yes (PROSPERO) | Yes | PRISMA 2020 | ≥3 databases |
| Umbrella | Recommended | Yes (AMSTAR-2) | PRISMA | ≥2 databases |
Best Practices
-
Register systematic reviews in PROSPERO before screening begins: Registration after screening introduces risk of outcome reporting bias (selectively reporting favorable results). PROSPERO registration is free, takes 2–3 days for approval, and is required by most high-impact journals publishing systematic reviews. Include a draft protocol with primary and secondary outcomes, eligibility criteria, and analysis plan.
-
Build search strategies with a medical librarian, then peer-review the search: Systematic review search strategies are technically complex — balancing sensitivity (comprehensive recall) and specificity (manageable results). Most university libraries offer free search consultation. The PRESS (Peer Review of Electronic Search Strategies) checklist provides a structured framework for a second librarian to validate the search.
-
Screen in two stages with at least two independent reviewers at each stage: Stage 1 (title/abstract): 2 reviewers independently assess each record; disagreements resolved by a third reviewer or consensus. Stage 2 (full-text): same process. Single-reviewer screening at either stage is not acceptable for a systematic review published in a peer-reviewed journal.
-
Pilot test your eligibility criteria on 50–100 records before full screening: Before screening the full results set, both reviewers independently screen the same 50–100 records and calculate inter-rater agreement (Cohen's kappa). Kappa < 0.6 indicates the eligibility criteria are ambiguous and must be refined. Refining criteria after full screening introduces bias.
-
Use structured data extraction forms with pre-defined fields: Design the extraction form before reading full texts, based on your PICO components and outcomes. Pre-defining fields prevents selective extraction of only favorable data. Tools: Cochrane's RevMan, Covidence, Rayyan, or a structured Excel/Google Sheets template.
-
Search for grey literature and trial registries to reduce publication bias: Studies with null or negative results are less likely to be published in peer-reviewed journals (publication bias). Searching ClinicalTrials.gov, WHO ICTRP, OpenGrey, and government reports captures unpublished and ongoing evidence that may shift the pooled estimate.
-
Report according to PRISMA 2020 and include the PRISMA flow diagram: The PRISMA 2020 checklist has 27 items across title, abstract, introduction, methods, results, discussion, and other sections. The flow diagram shows records identified, screened, assessed for eligibility, and included at each stage. Most journals require the PRISMA checklist as a supplementary submission item.
Common Pitfalls
-
Starting the search before finalizing eligibility criteria: Running database searches before defining inclusion/exclusion criteria leads to circular logic — researchers unconsciously adjust criteria based on what papers they found.
- How to avoid: Write and finalize the eligibility criteria (including PICO, study design, language, date range, and publication type restrictions) before running any database search. Register in PROSPERO to lock in the protocol.
-
Searching only PubMed and calling it systematic: PubMed covers MEDLINE but misses significant literature indexed only in Embase, PsycINFO, CINAHL, or regional databases. A single-database search fails the comprehensiveness criterion for a systematic review.
- How to avoid: Use at least 3 databases for clinical topics; document all databases searched, date of search, and search strings in the Methods section.
-
Updating search results during screening without documenting the update: Literature is published continuously; some researchers run updated searches mid-screening and add results without documenting. This invalidates the flow diagram and makes the review non-reproducible.
- How to avoid: Define a database lock date before screening begins. If an update is needed, document it as a separate search with its own date and numbers. Consider running a final "top-up" search immediately before submission.
-
Applying inclusion criteria inconsistently between screeners: Without a piloting phase, Screener A may interpret "adult" as ≥18 and Screener B as ≥21, or one screener may include conference abstracts while the other excludes them.
- How to avoid: Pilot screen on 50–100 records, calculate kappa, discuss every disagreement to align interpretations, and update the eligibility criteria document with clarifying examples before full screening.
-
Conducting meta-analysis when studies are too heterogeneous: Pooling estimates from studies with very different populations, interventions, outcomes, or follow-up durations produces a misleading "average" that may not apply to any real clinical scenario. I² > 75% typically signals unacceptably high statistical heterogeneity.
- How to avoid: Pre-specify a heterogeneity threshold (e.g., I² < 50% required for pooling) in the PROSPERO protocol. When heterogeneity is high, report a narrative synthesis with subgroup analysis, not a single pooled estimate.
-
Omitting quality/risk of bias assessment: Without appraising study quality, a systematic review cannot assess confidence in the evidence. Reviewers routinely reject systematic reviews that compile evidence without evaluating methodological rigor.
- How to avoid: Select the appropriate risk of bias tool before screening: RoB 2 (randomized trials), ROBINS-I (non-randomized interventions), QUADAS-2 (diagnostic accuracy), NOS (cohort/case-control). Apply it to all included studies and include results in the synthesis.
-
Writing the discussion as if a narrative review: After conducting a rigorous systematic review, authors sometimes revert to selective, opinion-driven discussion that ignores the quality appraisal results and treats all evidence as equivalent.
- How to avoid: Anchor each discussion paragraph to specific evidence quality assessments. Use GRADE language: "moderate-certainty evidence suggests..."; "low-certainty evidence from a single small RCT...". Distinguish what the evidence shows from what the authors believe.
Workflow
-
Question formulation and scoping
- Define the research question using PICO/PECS framework
- Conduct a preliminary search (PubMed, Google Scholar) to confirm the topic is not already covered by a recent systematic review
- Decide on review type (systematic, scoping, narrative)
- Write the protocol; register in PROSPERO (systematic reviews)
-
Search strategy development
- Identify index terms (MeSH for PubMed, Emtree for Embase) + free-text synonyms for each PICO component
- Combine within components using OR; combine components using AND
- Have search peer-reviewed using PRESS checklist
- Run searches across all databases; record date, database, and total hits
-
Deduplication and screening
- Import all results into reference manager or screening tool (Rayyan, Covidence)
- Remove duplicates (automated + manual)
- Stage 1: title/abstract screening by ≥2 independent reviewers; resolve conflicts
- Stage 2: full-text screening by ≥2 independent reviewers; document reasons for exclusion
- Calculate and report inter-rater agreement (Cohen's kappa)
-
Data extraction and quality appraisal
- Extract data using pre-designed structured forms
- Assess risk of bias using appropriate tool (RoB 2, ROBINS-I, QUADAS-2, NOS)
- Contact study authors for missing data if needed
-
Synthesis
- Narrative synthesis: tabulate studies, describe patterns, explore heterogeneity qualitatively
- Meta-analysis (if appropriate): calculate pooled effect estimates (OR, RR, MD, SMD) with 95% CI; test heterogeneity (I², Cochran Q); assess publication bias (funnel plot, Egger's test)
- Rate certainty of evidence using GRADE
-
Reporting and submission
- Draft manuscript following PRISMA 2020 checklist
- Create PRISMA flow diagram
- Submit PRISMA checklist as supplementary material
- Share search strategies, data extraction forms, and risk of bias assessments as supplementary data or OSF repository
Further Reading
- PRISMA 2020 statement and checklist — the standard reporting guideline for systematic reviews and meta-analyses
- Cochrane Handbook for Systematic Reviews of Interventions — comprehensive methodological reference; freely available
- PROSPERO registration — international register for systematic review protocols
- Rayyan systematic review screening tool — free web-based title/abstract screening platform
- GRADE Working Group — evidence quality assessment framework used in clinical systematic reviews
Related Skills
citation-management— reference managers for collecting and organizing search results before and during reviewstatistical-analysis— statistical methods for meta-analysis (pooled effects, heterogeneity, forest plots)scientific-critical-thinking— evaluating individual study quality and interpreting effect sizes in the context of a review
skills/scientific-writing/nature-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill nature-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "nature-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "Nature figure preparation: resolution (300+ DPI), formats (AI\/EPS\/TIFF), RGB color, Helvetica\/Arial fonts, lowercase panel labels, image integrity requirements.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
Nature Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to Nature and Nature Research journals. Following these guidelines ensures smooth processing and high-quality reproduction of your figures in both print and online formats.
Official reference: https://www.nature.com/nature/for-authors/formatting-guide
Resolution Requirements
| Image Type | Minimum Resolution | Notes |
|---|---|---|
| All figures | 300 DPI | At maximum print size |
| Optimal quality | 450 DPI+ | Recommended for best online display |
| Online submission | 300 PPI max | To keep file sizes manageable |
CRITICAL: Do NOT artificially increase resolution (upsampling) in image editing software. This does not improve quality and may introduce artifacts.
Verify Resolution with Python
from PIL import Image
def check_nature_resolution(image_path):
"""Check if image meets Nature's resolution requirements."""
img = Image.open(image_path)
width_px, height_px = img.size
dpi = img.info.get('dpi', (72, 72))
print(f"Image: {image_path}")
print(f"Dimensions: {width_px} x {height_px} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
if dpi[0] < 300 or dpi[1] < 300:
print("WARNING: Resolution below 300 DPI minimum")
print(f" Need at least 300 DPI for Nature submission")
elif dpi[0] >= 450:
print("PASS: Meets optimal resolution (450+ DPI)")
else:
print("PASS: Meets minimum resolution (300+ DPI)")
# Check file size
import os
size_mb = os.path.getsize(image_path) / (1024 * 1024)
print(f"File size: {size_mb:.1f} MB")
if size_mb > 10:
print("WARNING: File exceeds 10 MB limit")
return dpi[0] >= 300 and dpi[1] >= 300
File Format
Preferred Formats by Figure Type
| Figure Type | Preferred Format | Alternative |
|---|---|---|
| Line drawings, graphs, schematics | Adobe Illustrator (AI), EPS, PDF | Vector formats preserve editability |
| Photographs, micrographs | Photoshop (PSD), TIFF | High-quality raster |
| General purpose | JPEG (300-600 DPI) | Acceptable for most figures |
| Extended Data figures | JPEG (preferred), TIFF, EPS |
Also Accepted
- CorelDraw (up to version 8)
- Microsoft Word, Excel, PowerPoint
File Size
- Maximum: 10 MB per figure file
- Most files should be well under this limit
Important Rules
- Multi-panel figures must be assembled into a single image file
- Individual panels must NOT be uploaded separately
- Each complete figure must be a separate file upload
Figure Size and Dimensions
Nature does not specify fixed column widths for initial submission, but figures should be:
- Composed as a single image for multi-panel figures
- Sized appropriately for the intended display
- Not uploaded as individual panels
Python: Set Figure Dimensions
import matplotlib.pyplot as plt
def create_nature_figure(n_panels=1, fig_type='single_column'):
"""Create a Matplotlib figure sized for Nature."""
if fig_type == 'single_column':
fig_width = 3.5 # inches (approx 89 mm)
elif fig_type == 'double_column':
fig_width = 7.0 # inches (approx 178 mm)
else:
fig_width = 5.0 # 1.5 column
fig_height = fig_width * 0.75 # default aspect ratio
fig, axes = plt.subplots(1, n_panels, figsize=(fig_width, fig_height))
fig.set_dpi(450)
return fig, axes
Color Mode
- RGB recommended (wider color gamut; faithful reproduction of fluorescent colors online)
- CMYK also accepted (converted for print automatically)
- Accessibility: Use colorblind-friendly palettes
Recommended Colorblind-Safe Palette
# Nature-friendly colorblind-safe colors
NATURE_COLORS = {
'blue': '#0072B2',
'orange': '#E69F00',
'green': '#009E73',
'red': '#D55E00',
'purple': '#CC79A7',
'cyan': '#56B4E9',
'yellow': '#F0E442',
'black': '#000000',
}
Font Requirements
| Element | Font | Size | Style |
|---|---|---|---|
| Body text in figures | Helvetica or Arial | 5-7 pt | Regular |
| Panel labels | Helvetica or Arial | 8 pt | Bold, lowercase (a, b, c) |
| Amino acid sequences | Courier | — | Monospace |
| Greek characters | Symbol | — | — |
Critical Rules
- Use sans-serif fonts only (Helvetica or Arial)
- Do NOT outline text — text must remain editable
- Embed fonts as TrueType 2 or 42 (NOT TrueType 3)
- Panel labels: lowercase bold letters (a, b, c — not A, B, C)
Python: Apply Nature Font Settings
import matplotlib.pyplot as plt
def set_nature_fonts():
"""Configure Matplotlib for Nature figure fonts."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Helvetica', 'Arial'],
'font.size': 7,
'axes.labelsize': 7,
'axes.titlesize': 7,
'xtick.labelsize': 6,
'ytick.labelsize': 6,
'legend.fontsize': 6,
'figure.titlesize': 8,
})
Labeling Conventions
Figure Numbering
- Sequential: Figure 1, Figure 2, Figure 3, etc.
- All figures must be cited in the text in order
Panel Labels
- Lowercase bold letters: a, b, c, d, ...
- Font size: 8 pt bold
- Position: top-left corner of each panel
Axes and Legends
- Include units in parentheses on all axes
- Scale bars must be on separate layers (not flattened into the image)
- Figure legends placed on a separate manuscript page after References
Example Panel Labeling
import matplotlib.pyplot as plt
import string
def add_nature_panel_labels(fig, axes):
"""Add Nature-style lowercase bold panel labels."""
if not hasattr(axes, '__iter__'):
axes = [axes]
for i, ax in enumerate(axes):
label = string.ascii_lowercase[i]
ax.text(-0.1, 1.1, label,
transform=ax.transAxes,
fontsize=8,
fontweight='bold',
va='top',
ha='right',
fontfamily='Arial')
Image Integrity and Manipulation Policy
Prohibited
- Flattening scale bars into the image layer
- Outlining text (must remain editable)
- Adding gridlines, patterns, or drop shadows
- Using colored text in graphs
- Publishing copyrighted images without permission
Permitted (with transparency)
- Linear brightness/contrast adjustments applied uniformly to the entire image
- Color balance corrections applied to the whole image
Best Practices
- Keep scale bars on separate layers for editability
- Avoid busy backgrounds behind text
- Remove superfluous decorative elements
- Obtain permissions for all copyrighted figures
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_nature_figure(image_path):
"""Full validation of a figure against Nature requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < 300 or dpi[1] < 300:
issues.append(f"Resolution too low: {dpi[0]}x{dpi[1]} DPI (need 300+)")
# 2. Color mode check
if img.mode == 'CMYK':
issues.append("Color mode is CMYK; RGB is recommended for Nature")
elif img.mode not in ('RGB', 'RGBA'):
issues.append(f"Unexpected color mode: {img.mode}; use RGB")
# 3. File size check
size_mb = os.path.getsize(image_path) / (1024 * 1024)
if size_mb > 10:
issues.append(f"File size {size_mb:.1f} MB exceeds 10 MB limit")
# 4. Format check
fmt = img.format
accepted = ['TIFF', 'JPEG', 'PNG', 'EPS', 'PDF']
if fmt and fmt.upper() not in accepted:
issues.append(f"Format '{fmt}' may not be accepted; prefer TIFF or JPEG")
# Report
print(f"=== Nature Figure Validation: {os.path.basename(image_path)} ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
print(f"Format: {fmt}")
print(f"File size: {size_mb:.1f} MB")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
return len(issues) == 0
Key Concepts
Resolution and DPI
DPI (dots per inch) measures print resolution. Nature requires 300+ DPI at maximum print size. Upsampling (artificially increasing resolution in software) does not improve image quality and introduces interpolation artifacts. Always capture or export images at native high resolution.
Vector vs Raster Formats
Vector formats (AI, EPS, PDF) store images as mathematical paths and scale without quality loss — ideal for graphs, schematics, and line art. Raster formats (TIFF, JPEG, PSD) store pixel grids and degrade when enlarged — appropriate for photographs and micrographs. Nature prefers vector for line drawings and raster for photographic content.
Image Integrity
Nature enforces strict image integrity policies aligned with the Committee on Publication Ethics (COPE) guidelines. All adjustments must be applied uniformly to the entire image. Selective enhancement of specific regions (e.g., adjusting brightness on one gel lane) is considered data manipulation and grounds for rejection or retraction.
Decision Framework
What type of figure are you preparing?
├── Graph, schematic, or diagram → Vector format (AI, EPS, PDF)
│ ├── Created in Illustrator → Export as AI or EPS
│ └── Created in Python/R → Export as PDF or EPS
├── Photograph or micrograph → Raster format (TIFF, PSD, JPEG)
│ ├── Need highest quality → TIFF at 450+ DPI
│ └── File size constrained → JPEG at 300+ DPI
└── Multi-panel composite → Single assembled file
├── Mixed vector + raster → Assemble in Illustrator, export as AI/PDF
└── All raster panels → Assemble in Photoshop, export as TIFF
| Scenario | Recommended Format | Resolution | Notes |
|---|---|---|---|
| Bar chart or line graph | AI, EPS, PDF | Vector (resolution-independent) | Keep text editable |
| Fluorescence micrograph | TIFF | 450+ DPI | RGB mode, colorblind-safe palette |
| Western blot image | TIFF | 300+ DPI | No selective adjustments |
| Flow chart or pathway | AI, EPS | Vector | Use Helvetica/Arial fonts |
| Extended Data figure | JPEG | 300+ DPI | Same standards as main figures |
Best Practices
- Export at native resolution: Always capture or generate images at the target resolution (300+ DPI). Never upsample low-resolution images in Photoshop or similar tools
- Use colorblind-friendly palettes: Nature strongly recommends accessible color schemes. Avoid red-green combinations; use blue-orange or include pattern/shape differentiation
- Keep text editable in vector files: Do not outline or rasterize text in AI/EPS files. Nature's production team may need to edit fonts during typesetting
- Assemble multi-panel figures before upload: Combine all panels (a, b, c, etc.) into a single image file. Individual panel uploads will be rejected
- Maintain separate layers for scale bars: Scale bars must remain on a separate layer from the image data so they can be repositioned during production
- Apply adjustments uniformly: Any brightness, contrast, or color correction must be applied to the entire image, not selectively to specific regions
- Retain original unprocessed data: Editors or reviewers may request raw image files at any stage of review or post-publication
Common Pitfalls
- Upsampling low-resolution images: Artificially increasing DPI in image software does not add real detail and introduces blurring artifacts
- How to avoid: Re-export from the original source (microscope, plotting software) at 300+ DPI natively
- Submitting individual panels instead of assembled figures: Nature requires multi-panel figures as a single composite file
- How to avoid: Assemble all panels in Illustrator or Photoshop before uploading; use lowercase bold labels (a, b, c)
- Using uppercase panel labels: Nature uses lowercase bold (a, b, c), unlike many other journals that use uppercase
- How to avoid: Double-check the journal's labeling convention; Nature specifically requires lowercase
- Outlining text in vector files: Converting text to outlines makes it uneditable, which Nature prohibits
- How to avoid: Embed fonts as TrueType 2 or 42 instead of converting to outlines
- Submitting in CMYK color mode: While accepted, CMYK narrows the color gamut and may misrepresent fluorescence data online
- How to avoid: Submit in RGB; Nature handles CMYK conversion for print internally
- Exceeding the 10 MB file size limit: Large TIFF files often exceed this threshold
- How to avoid: Use LZW compression for TIFF files or convert to high-quality JPEG (300+ DPI)
Pre-Submission Checklist
Before submitting figures to Nature, verify:
- Resolution is at least 300 DPI (450+ DPI preferred)
- File format is AI, EPS, TIFF, PSD, or high-quality JPEG
- Each file is under 10 MB
- Multi-panel figures assembled as a single image file
- Color mode is RGB (not CMYK)
- Colorblind-accessible palette used
- Fonts are Helvetica or Arial, 5-7 pt body, 8 pt bold panel labels
- Panel labels are lowercase bold (a, b, c)
- Text is NOT outlined (remains editable)
- Fonts embedded as TrueType 2 or 42
- Scale bars on separate layers (not flattened)
- No gridlines, patterns, or drop shadows
- Axes include units in parentheses
- All image adjustments applied uniformly to entire image
- Original unprocessed data retained for editor/reviewer requests
- Copyright permissions obtained for any third-party images
References
- Nature Formatting Guide: https://www.nature.com/nature/for-authors/formatting-guide
- Nature Final Submission: https://www.nature.com/nature/for-authors/final-submission
- Nature Research Figure Guide: https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/
skills/scientific-writing/nejm-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill nejm-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "nejm-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "NEJM figure preparation: resolution (300-1200 DPI), editable vector formats (AI\/EPS\/SVG), in-house medical illustration policy, and strict image integrity requirements.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
NEJM Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to the New England Journal of Medicine (NEJM). A unique feature of NEJM is that medical illustrations are created by NEJM's in-house illustrators working directly with authors — authors should NOT submit finished medical illustrations due to copyright considerations.
Official reference: https://www.nejm.org/author-center/new-manuscripts
Resolution Requirements
| Image Type | Minimum Resolution | Notes |
|---|---|---|
| Black-and-white line art | 1,200 DPI | Highest requirement |
| Photographic / halftone images | 300 DPI | Standard for photographs |
| Peer review stage | Lower resolution acceptable | High-res required for final publication |
from PIL import Image
def check_nejm_resolution(image_path, image_type='photo', stage='final'):
"""Check if image meets NEJM resolution requirements.
Args:
image_type: 'lineart' (1200 DPI) or 'photo' (300 DPI)
stage: 'review' (lower OK) or 'final' (strict requirements)
"""
min_dpi = {'lineart': 1200, 'photo': 300}
required = min_dpi.get(image_type, 300)
if stage == 'review':
print("NOTE: Lower resolution acceptable for peer review")
required = 150 # relaxed for review
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
print(f"Stage: {stage} | Type: {image_type}")
print(f"Required: {required} DPI | Actual: {dpi[0]} DPI")
passed = dpi[0] >= required
print("PASS" if passed else "FAIL")
return passed
File Format
| Figure Type | Preferred Format | Notes |
|---|---|---|
| Data visualizations (graphs, plots, diagrams) | AI, EPS, SVG | Editable vector files preferred |
| Photographic images | TIFF | High-resolution raster |
| Medical illustrations | Do NOT submit | NEJM illustrators create these |
Submission Options
- Figures can be inserted in text files (preferred) or uploaded separately
Medical Illustration Policy
IMPORTANT: NEJM's in-house medical illustrators will work directly with authors to create medical illustrations. Authors should NOT submit finished illustrations due to copyright considerations. The journal retains copyright on illustrations created by their team.
Figure Size and Dimensions
NEJM does not publish detailed size specifications in their public guidelines. General best practices:
- Size figures appropriately for the intended column layout
- Ensure all text is legible at final print size
- Follow standard medical journal conventions
Color Mode
- No explicit RGB/CMYK mandate in published guidelines
- Follow standard practice: submit in RGB for online; journal handles print conversion
Font Requirements
| Element | Specification |
|---|---|
| Preferred style | Sans-serif |
| Historical font | Univers (NEJM house font) |
| Alternatives | Helvetica, Arial |
- Ensure all text is legible at final reduction size
import matplotlib.pyplot as plt
def set_nejm_fonts():
"""Configure Matplotlib for NEJM figure fonts."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Univers', 'Helvetica', 'Arial'],
'font.size': 8,
'axes.labelsize': 8,
'axes.titlesize': 8,
'xtick.labelsize': 7,
'ytick.labelsize': 7,
'legend.fontsize': 7,
})
Labeling Conventions
Images in Clinical Medicine
- Title: maximum 8 words
- Legend: maximum 150 words
Patient Privacy
- All patient-identifying information must be removed from images
- This includes faces, names, medical record numbers, and any other identifiable information
def check_clinical_image_text(title, legend):
"""Validate text limits for NEJM Images in Clinical Medicine."""
title_words = len(title.split())
legend_words = len(legend.split())
issues = []
if title_words > 8:
issues.append(f"Title has {title_words} words (max 8)")
if legend_words > 150:
issues.append(f"Legend has {legend_words} words (max 150)")
if issues:
for issue in issues:
print(f"ISSUE: {issue}")
else:
print(f"PASS: Title ({title_words} words), Legend ({legend_words} words)")
return len(issues) == 0
Image Integrity and Manipulation Policy
PROHIBITED
- Enhancing, obscuring, moving, removing, or introducing any specific feature within an image
REQUIRED
- Brightness, color, and contrast adjustments must be applied uniformly to the entire image
- Adjustments must not misrepresent any features of the original image
Best Practices
- Maintain original unprocessed image files
- Document all processing steps
- Be prepared to provide raw data upon request
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_nejm_figure(image_path, image_type='photo', stage='final'):
"""Full validation of a figure against NEJM requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
min_dpi = {'lineart': 1200, 'photo': 300}
required = min_dpi.get(image_type, 300)
if stage == 'review':
required = 150
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < required:
issues.append(f"Resolution {dpi[0]} DPI below {required} DPI for {image_type} ({stage})")
# 2. Color mode
if img.mode not in ('RGB', 'RGBA', 'L'):
issues.append(f"Color mode {img.mode} may not be ideal; use RGB or Grayscale")
# 3. Format check
fmt = img.format
vector_preferred = image_type != 'photo'
if vector_preferred and fmt and fmt.upper() in ('JPEG', 'PNG'):
issues.append(f"Data visualizations: prefer vector format (AI, EPS, SVG) over {fmt}")
# Report
print(f"=== NEJM Figure Validation ({stage}) ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
print("\nREMINDER: Do NOT submit finished medical illustrations (NEJM creates these)")
print("REMINDER: Remove ALL patient-identifying information from images")
return len(issues) == 0
Key Concepts
In-House Medical Illustration
NEJM's most distinctive policy is that medical illustrations are created by their in-house illustrators working directly with authors. Authors should NOT submit finished medical illustrations. The journal retains copyright on illustrations created by their team. This applies only to medical illustrations — data visualizations and photographs are author-submitted.
Two-Stage Resolution Requirements
NEJM accepts lower-resolution figures during peer review to reduce submission friction. However, final publication requires full resolution: 1,200 DPI for line art and 300 DPI for photographs. Authors should prepare high-resolution originals from the start to avoid rework.
Patient Privacy in Clinical Images
NEJM enforces strict patient privacy requirements. All patient-identifying information must be removed from images, including faces, names, medical record numbers, and any other identifiable features. This is non-negotiable and applies to all clinical images regardless of consent status.
Decision Framework
What type of figure are you preparing?
├── Medical illustration (anatomy, mechanism)
│ └── Do NOT submit → NEJM illustrators create these
├── Data visualization (graph, chart, diagram)
│ ├── Vector source available → AI, EPS, or SVG (preferred)
│ └── Raster only → TIFF at 1,200 DPI (line art) or 300 DPI (photo)
├── Clinical photograph
│ ├── Patient identifiable? → Remove ALL identifying information
│ └── Ready for submission → TIFF at 300+ DPI
└── What stage?
├── Peer review → Lower resolution acceptable
└── Final publication → Full resolution required
| Scenario | Format | Resolution | Special Considerations |
|---|---|---|---|
| Kaplan-Meier curve | AI, EPS, SVG | Vector | Editable format preferred |
| Clinical photograph | TIFF | 300+ DPI | Remove patient identifiers |
| Histology image | TIFF | 300+ DPI | Include scale bar |
| Flowchart or diagram | AI, EPS, SVG | Vector | Use Univers or Helvetica |
| Medical illustration | Do NOT submit | N/A | NEJM creates in-house |
| Images in Clinical Medicine | TIFF/JPEG | 300+ DPI | Title max 8 words, legend max 150 words |
Best Practices
- Submit data visualizations as editable vector files: NEJM prefers AI, EPS, or SVG for graphs and diagrams. Vector formats allow their production team to make typographic adjustments
- Do not submit finished medical illustrations: NEJM's in-house illustrators will create these. Submit reference sketches or descriptions instead
- Remove all patient-identifying information: Strip faces, names, record numbers, and any identifiable features from clinical images before submission
- Prepare high-resolution originals early: Although peer review accepts lower resolution, having 300+ DPI originals ready prevents delays during final production
- Use Univers or Helvetica fonts: NEJM's house font is Univers; Helvetica and Arial are acceptable alternatives. Ensure all text is legible at final reduction size
- Respect word limits for Images in Clinical Medicine: Titles must be 8 words or fewer; legends must be 150 words or fewer
- Maintain original unprocessed files: NEJM may request raw data at any point during review or after publication
Common Pitfalls
- Submitting finished medical illustrations: NEJM retains copyright on illustrations they create. Submitting finished artwork creates copyright conflicts
- How to avoid: Provide reference sketches, descriptions, or rough drafts; NEJM illustrators will create the final version
- Leaving patient-identifying information in images: Any identifiable features in clinical images can result in rejection and ethical concerns
- How to avoid: Review all clinical images for faces, names, ID numbers, and other identifiers; blur or crop as needed
- Using raster formats for data visualizations: JPEG or PNG graphs lose editability and may appear pixelated at print size
- How to avoid: Export charts and diagrams as AI, EPS, or SVG from your plotting software
- Submitting low-resolution line art: Line art requires 1,200 DPI — significantly higher than the 300 DPI for photographs
- How to avoid: Export line art at 1,200 DPI or use vector formats that are resolution-independent
- Exceeding word limits for clinical image submissions: Images in Clinical Medicine has strict limits (8-word title, 150-word legend)
- How to avoid: Count words before submission; move detailed descriptions to supplementary text if needed
Pre-Submission Checklist
Before submitting figures to NEJM, verify:
- Line art at 1,200+ DPI
- Photographs at 300+ DPI (lower OK for peer review only)
- Data visualizations in editable vector format (AI, EPS, SVG)
- Photographs in TIFF format
- Medical illustrations: NOT submitted (NEJM creates these in-house)
- Sans-serif font used (Univers, Helvetica, or Arial)
- All text legible at final print size
- Images in Clinical Medicine: title ≤ 8 words, legend ≤ 150 words
- All patient-identifying information removed
- Adjustments applied uniformly to entire image
- No selective enhancement of image features
- Original unprocessed data retained
References
- NEJM Author Center: https://www.nejm.org/author-center/new-manuscripts
- NEJM Technical Guidelines for Figures: https://www.nejm.org/pb-assets/pdfs/Technical-Guidelines-for-Figures.pdf
- NEJM Editorial Policies: https://www.nejm.org/about-nejm/editorial-policies
skills/scientific-writing/openalex-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill openalex-database -g -y
SKILL.md
Frontmatter
{
"name": "openalex-database",
"license": "CC0-1.0",
"description": "Query OpenAlex REST API for 250M+ scholarly works, authors, institutions, journals, concepts. Search by keyword, author, DOI, ORCID, or ID; filter by year, OA, citations, field; retrieve citations, references, author disambiguation. Free, no auth. For PubMed use pubmed-database; preprints use biorxiv-database."
}
OpenAlex Scholarly Database
Overview
OpenAlex is a free, open-access index of 250M+ scholarly works, 90M+ authors, 110,000+ journals, and 10,000+ institutions. It succeeds Microsoft Academic Graph and provides rich metadata: abstracts, open-access URLs, citation counts, referenced works, author disambiguated IDs (ORCID), and concept tags. The REST API requires no authentication for up to 100,000 requests/day; a polite pool (email parameter) gives priority processing.
When to Use
- Building systematic literature review corpora by searching across all academic disciplines (not just biomedical)
- Retrieving citation networks for bibliometric analysis, co-citation clustering, or reference graph traversal
- Disambiguating author identities across institutions using ORCID/OpenAlex author IDs
- Finding open-access full-text URLs for a set of DOIs to build downloadable paper corpora
- Analyzing publication trends by year, institution, country, or research concept
- Enriching a paper list with metadata (citation count, abstract, venue) from DOIs or titles
- For PubMed-indexed biomedical literature use
pubmed-database; for bioRxiv preprints usebiorxiv-database
Prerequisites
- Python packages:
requests,pandas - Data requirements: DOIs, OpenAlex Work IDs (W…), author names, ORCID IDs, or search terms
- Environment: internet connection; no API key required
- Rate limits: 10 req/s anonymous; add
mailto=your@email.comquery param to join polite pool (higher priority, same limit)
pip install requests pandas
Quick Start
import requests
BASE = "https://api.openalex.org"
# Search for works on CRISPR
r = requests.get(f"{BASE}/works",
params={"search": "CRISPR gene editing",
"filter": "publication_year:2023",
"per_page": 5,
"mailto": "your@email.com"})
r.raise_for_status()
data = r.json()
print(f"Total results: {data['meta']['count']}")
for work in data["results"][:3]:
print(f" {work['title'][:80]} ({work['publication_year']}) cites={work['cited_by_count']}")
Core API
Query 1: Works Search
Search works by title/abstract keywords with filters.
import requests, pandas as pd
BASE = "https://api.openalex.org"
def search_works(query, filters=None, per_page=25, mailto="your@email.com"):
params = {"search": query, "per_page": per_page, "mailto": mailto}
if filters:
params["filter"] = ",".join(f"{k}:{v}" for k, v in filters.items())
r = requests.get(f"{BASE}/works", params=params)
r.raise_for_status()
return r.json()
# Search with filters
data = search_works("single-cell RNA sequencing",
filters={"publication_year": "2020-2024",
"open_access.is_oa": "true"},
per_page=10)
print(f"Open-access scRNA-seq papers 2020-2024: {data['meta']['count']}")
rows = []
for w in data["results"]:
rows.append({
"title": w["title"],
"year": w["publication_year"],
"citations": w["cited_by_count"],
"doi": w.get("doi"),
"oa_url": w.get("open_access", {}).get("oa_url"),
})
df = pd.DataFrame(rows)
print(df[["title", "year", "citations"]].head())
# Paginate through all results
def paginate_works(query, filters=None, max_results=200, mailto="your@email.com"):
"""Retrieve up to max_results works, paginating automatically."""
all_results = []
cursor = "*"
while len(all_results) < max_results:
params = {"search": query, "per_page": 200,
"cursor": cursor, "mailto": mailto}
if filters:
params["filter"] = ",".join(f"{k}:{v}" for k, v in filters.items())
r = requests.get(f"{BASE}/works", params=params)
data = r.json()
all_results.extend(data["results"])
cursor = data["meta"].get("next_cursor")
if not cursor:
break
return all_results[:max_results]
papers = paginate_works("transformer protein structure", max_results=100)
print(f"Retrieved {len(papers)} papers")
Query 2: Lookup by DOI or OpenAlex ID
Retrieve a single work by DOI or OpenAlex ID.
import requests
BASE = "https://api.openalex.org"
# By DOI
doi = "10.1038/s41592-019-0458-z" # Scanpy paper
r = requests.get(f"{BASE}/works/https://doi.org/{doi}",
params={"mailto": "your@email.com"})
r.raise_for_status()
work = r.json()
print(f"Title : {work['title']}")
print(f"Year : {work['publication_year']}")
print(f"Citations: {work['cited_by_count']}")
print(f"Journal : {work.get('primary_location', {}).get('source', {}).get('display_name')}")
abstract = work.get("abstract_inverted_index")
if abstract:
# Reconstruct abstract from inverted index
words = {pos: word for word, positions in abstract.items() for pos in positions}
text = " ".join(words[i] for i in sorted(words))
print(f"Abstract (first 200): {text[:200]}")
Query 3: Author Search and ORCID Lookup
Find author records, resolve ORCID identifiers, retrieve publication lists.
import requests, pandas as pd
BASE = "https://api.openalex.org"
# Search for an author
r = requests.get(f"{BASE}/authors",
params={"search": "Jennifer Doudna",
"per_page": 5,
"mailto": "your@email.com"})
authors = r.json()["results"]
for a in authors[:3]:
print(f"Author: {a['display_name']}")
print(f" OpenAlex ID : {a['id']}")
print(f" ORCID : {a.get('orcid', 'n/a')}")
# 2024+: singular `last_known_institution` was replaced by plural list `last_known_institutions[0]`
insts = a.get("last_known_institutions") or []
print(f" Institution : {insts[0]['display_name'] if insts else 'n/a'}")
print(f" Works count : {a['works_count']}")
print(f" h-index : {a['summary_stats'].get('h_index', 'n/a')}")
print()
# Get all papers by an author (by ORCID)
orcid = "0000-0001-9161-999X" # Jennifer A. Doudna (correct ORCID; the 8742-3594 variant 404s)
r = requests.get(f"{BASE}/works",
params={"filter": f"author.orcid:{orcid}",
"sort": "cited_by_count:desc",
"per_page": 10,
"mailto": "your@email.com"})
papers = r.json()["results"]
for p in papers[:5]:
print(f" [{p['publication_year']}] {p['title'][:70]} (cites: {p['cited_by_count']})")
Query 4: Citation Network Retrieval
Get referenced works and citing works for a paper.
import requests, pandas as pd
BASE = "https://api.openalex.org"
work_id = "W2018426904" # CRISPR paper
# Get what this paper references
r = requests.get(f"{BASE}/works/{work_id}",
params={"select": "referenced_works,cited_by_count,title",
"mailto": "your@email.com"})
work = r.json()
ref_ids = work.get("referenced_works", [])
print(f"'{work['title']}' cites {len(ref_ids)} papers")
print(f"Total citations: {work['cited_by_count']}")
# Fetch metadata for references (batch)
if ref_ids:
ids_str = "|".join(id.split("/")[-1] for id in ref_ids[:10])
r2 = requests.get(f"{BASE}/works",
params={"filter": f"openalex_id:{ids_str}",
"per_page": 10,
"mailto": "your@email.com"})
refs = r2.json()["results"]
for ref in refs[:5]:
print(f" [{ref['publication_year']}] {ref['title'][:70]}")
Query 5: Concept/Topic Filtering and Trend Analysis
Filter by research concepts and analyze publication trends.
import requests, pandas as pd
BASE = "https://api.openalex.org"
# Get concept ID for "Machine Learning". OpenAlex concept search is brittle for
# multi-word phrases ("machine learning biology" returns 0); use the single core term.
r = requests.get(f"{BASE}/concepts",
params={"search": "machine learning",
"per_page": 3,
"mailto": "your@email.com"})
concepts = r.json()["results"]
for c in concepts[:3]:
print(f"Concept: {c['display_name']} (ID: {c['id']}, level: {c['level']})")
# Count papers per year for a concept
concept_id = "C154945302" # Machine learning (OpenAlex ID)
r2 = requests.get(f"{BASE}/works",
params={"filter": f"concepts.id:{concept_id},publication_year:2015-2024",
"group_by": "publication_year",
"per_page": 200,
"mailto": "your@email.com"})
groups = r2.json()["group_by"]
df = pd.DataFrame(groups).rename(columns={"key": "year", "count": "papers"})
df = df.sort_values("year")
print(df.tail(5).to_string(index=False))
Query 6: Institution and Venue Queries
Retrieve papers from a specific institution, journal, or conference.
import requests, pandas as pd
BASE = "https://api.openalex.org"
# Papers from a specific journal in the last year
r = requests.get(f"{BASE}/works",
params={
"filter": "primary_location.source.issn:0028-0836,publication_year:2023",
"per_page": 10,
"sort": "cited_by_count:desc",
"mailto": "your@email.com"
})
data = r.json()
print(f"Nature papers 2023: {data['meta']['count']}")
for w in data["results"][:5]:
print(f" [{w['cited_by_count']} cites] {w['title'][:70]}")
Key Concepts
Inverted Index Abstracts
OpenAlex stores abstracts as inverted indexes (word → list of positions) rather than plain text due to copyright restrictions. Reconstruct with: " ".join(words[i] for i in sorted({pos: w for w, ps in inv.items() for pos in ps})).
Cursor-Based Pagination
OpenAlex uses cursor-based pagination (cursor parameter) instead of offset. Start with cursor="*" and use the next_cursor from each response. Maximum 200 results per page; cursor pagination supports up to 10,000 results.
Common Workflows
Workflow 1: Systematic Literature Search
Goal: Download all papers matching a topic query with metadata for systematic review.
import requests, time, pandas as pd
BASE = "https://api.openalex.org"
MAILTO = "your@email.com"
def systematic_search(query, year_from, year_to, max_results=500):
"""Paginate through results and return a DataFrame."""
all_results = []
cursor = "*"
filters = f"publication_year:{year_from}-{year_to}"
while len(all_results) < max_results:
r = requests.get(f"{BASE}/works",
params={"search": query, "filter": filters,
"per_page": 200, "cursor": cursor,
"mailto": MAILTO,
"select": "id,doi,title,publication_year,cited_by_count,open_access"})
r.raise_for_status()
data = r.json()
all_results.extend(data["results"])
cursor = data["meta"].get("next_cursor")
if not cursor:
break
time.sleep(0.1)
rows = []
for w in all_results[:max_results]:
rows.append({
"openalex_id": w["id"],
"doi": w.get("doi"),
"title": w.get("title"),
"year": w.get("publication_year"),
"citations": w.get("cited_by_count"),
"is_oa": w.get("open_access", {}).get("is_oa"),
"oa_url": w.get("open_access", {}).get("oa_url"),
})
return pd.DataFrame(rows)
# Example: papers on drug repurposing 2019-2024
df = systematic_search("drug repurposing machine learning", 2019, 2024, max_results=200)
df.to_csv("drug_repurposing_literature.csv", index=False)
print(f"Retrieved {len(df)} papers")
print(df[["title", "year", "citations", "is_oa"]].head(5).to_string(index=False))
Workflow 2: Author Collaboration Network
Goal: Map co-authors for a researcher to analyze their collaboration network.
import requests, time, pandas as pd
from collections import defaultdict
BASE = "https://api.openalex.org"
MAILTO = "your@email.com"
def get_author_works(orcid, max_papers=50):
r = requests.get(f"{BASE}/works",
params={"filter": f"author.orcid:{orcid}",
"sort": "cited_by_count:desc",
"per_page": min(max_papers, 200),
"mailto": MAILTO})
r.raise_for_status()
return r.json()["results"]
def extract_collaborators(works):
collab_count = defaultdict(int)
for work in works:
for authorship in work.get("authorships", []):
author = authorship.get("author", {})
name = author.get("display_name")
if name:
collab_count[name] += 1
return collab_count
# Map collaborators for a researcher
orcid = "0000-0001-9161-999X" # Jennifer A. Doudna
works = get_author_works(orcid, max_papers=50)
collabs = extract_collaborators(works)
top_collabs = sorted(collabs.items(), key=lambda x: -x[1])
df = pd.DataFrame(top_collabs, columns=["collaborator", "papers_together"])
df = df[df["collaborator"] != "Jennifer A. Doudna"] # exclude self
print("Top collaborators:")
print(df.head(10).to_string(index=False))
df.to_csv("collaboration_network.csv", index=False)
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
search |
All | — | text string | Full-text search across title+abstract |
filter |
All | — | field:value,field:value |
Structured filters (AND logic) |
per_page |
All | 25 |
1–200 |
Results per page |
cursor |
Pagination | "*" |
cursor string | Cursor for pagination |
sort |
Works | relevance |
cited_by_count:desc, publication_year:desc |
Result ordering |
select |
All | all fields | comma-separated field names | Limit response fields (faster) |
group_by |
Works | — | field name | Aggregate counts by field |
mailto |
All | — | email address | Polite pool access (prioritized) |
Best Practices
-
Always include
mailto: Addmailto=your@email.comto all requests to join the polite pool and receive priority processing without rate throttling. -
Use
selectfor large paginations: When paginating through thousands of results, specify only needed fields (select=id,doi,title,cited_by_count) to reduce response size and speed up parsing. -
Use cursor pagination, not offset: OpenAlex does not support offset pagination beyond 10,000 results. Use cursor-based pagination (
cursorparameter) for deep traversals. -
Reconstruct abstracts from inverted index: Not all works have abstracts; check
abstract_inverted_index is not Nonebefore reconstructing to avoid KeyError. -
Cache by work ID: OpenAlex Work IDs (W…) are stable identifiers. Cache retrieved work metadata to avoid re-fetching within a project.
Common Recipes
Recipe: DOI to Metadata Batch Lookup
When to use: Enrich a list of DOIs with citation counts, open-access URLs, and abstracts.
import requests, pandas as pd, time
BASE = "https://api.openalex.org"
dois = [
"10.1038/s41592-019-0458-z",
"10.1186/s13059-021-02519-4",
"10.1038/s41587-019-0071-9",
]
rows = []
for doi in dois:
r = requests.get(f"{BASE}/works/https://doi.org/{doi}",
params={"select": "title,publication_year,cited_by_count,open_access",
"mailto": "your@email.com"})
if r.ok:
w = r.json()
rows.append({
"doi": doi, "title": w.get("title"),
"year": w.get("publication_year"),
"citations": w.get("cited_by_count"),
"is_oa": w.get("open_access", {}).get("is_oa"),
})
time.sleep(0.1)
df = pd.DataFrame(rows)
print(df.to_string(index=False))
Recipe: Count Papers by Country
When to use: Geographic analysis of research output on a topic.
import requests, pandas as pd
r = requests.get(
"https://api.openalex.org/works",
params={"search": "CRISPR therapeutics",
"filter": "publication_year:2023",
"group_by": "authorships.institutions.country_code",
"per_page": 200,
"mailto": "your@email.com"}
)
df = pd.DataFrame(r.json()["group_by"]).rename(columns={"key": "country", "count": "papers"})
print(df.sort_values("papers", ascending=False).head(10).to_string(index=False))
Recipe: Find Most-Cited Papers in a Field
When to use: Identify landmark papers on a topic for background reading.
import requests, pandas as pd
r = requests.get(
"https://api.openalex.org/works",
params={"search": "protein language model",
"sort": "cited_by_count:desc",
"per_page": 10,
"mailto": "your@email.com"}
)
for w in r.json()["results"]:
print(f"[{w['cited_by_count']:5d} cites] ({w['publication_year']}) {w['title'][:70]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 429 Too Many Requests |
Rate limit exceeded | Add time.sleep(0.15) between requests; use polite pool (mailto) |
Empty abstract_inverted_index |
No abstract available | Check for None before reconstructing; not all works have abstracts |
| Cursor pagination returns duplicates | Cursor expired | Re-start pagination with cursor="*" |
| DOI lookup returns 404 | DOI not indexed in OpenAlex | Try title search instead; OpenAlex indexes 250M+ but not 100% of literature |
| Filter returns 0 results | Field name wrong or filter syntax error | Check filter syntax: field:value with no spaces; verify field names in API docs |
cited_by_count is stale |
Citation counts update periodically | Counts are refreshed regularly but may lag by days; use for trends not exact figures |
Related Skills
pubmed-database— Biomedical literature with MeSH controlled vocabulary; better for clinical and life sciencesbiorxiv-database— Biomedical preprints not yet indexed in OpenAlexscientific-brainstorming— Hypothesis generation workflows using literature as inputliterature-review— Guide for designing systematic literature reviews using OpenAlex
References
- OpenAlex documentation — Full API reference and data model
- OpenAlex API endpoint — Interactive API explorer
- OpenAlex paper (Priem et al. 2022) — Description of the OpenAlex data system
- OpenAlex entity types — Works, Authors, Sources, Institutions, Concepts documentation
skills/scientific-writing/peer-review-methodology/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill peer-review-methodology -g -y
SKILL.md
Frontmatter
{
"name": "peer-review-methodology",
"license": "CC-BY-4.0",
"description": "Structured peer review of manuscripts and grants. 7-stage evaluation: initial assessment, section review, statistical rigor, reproducibility, figure integrity, ethics, writing. Covers CONSORT\/STROBE\/PRISMA and report structure. For evidence quality see scientific-critical-thinking; scoring see scholar-evaluation."
}
Scientific Peer Review
Overview
Peer review is a systematic process for evaluating scientific manuscripts and grant proposals. This knowhow covers the complete review cycle: from initial assessment through detailed section-by-section evaluation, statistical rigor checks, reproducibility assessment, figure integrity verification, ethical considerations, and writing quality — culminating in a structured review report with actionable feedback.
Key Concepts
1. Review Types and Expectations
| Review Type | Scope | Typical Length | Key Focus |
|---|---|---|---|
| Original research | Full evaluation | 1–3 pages | Rigor, novelty, reproducibility |
| Review/Meta-analysis | Coverage and bias | 1–2 pages | Completeness, search strategy, systematic approach |
| Methods paper | Validation | 1–2 pages | Comparison to existing methods, reproducibility |
| Short communication | Proportional | 0.5–1 page | Core findings rigor despite brevity |
| Grant proposal | Feasibility + significance | 1–3 pages | Innovation, approach, team, budget justification |
2. Comment Severity Levels
- Major comments: Issues that significantly impact validity, interpretability, or significance. Must be addressed for publication. Examples: fundamental design flaws, unsupported conclusions, missing controls.
- Minor comments: Issues that improve clarity or completeness but don't affect core validity. Examples: unclear labels, missing methods details, grammatical errors.
- Questions for authors: Requests for clarification where the reviewer cannot evaluate without additional information.
3. Reporting Standards Quick Reference
| Standard | Applies to | Key Check |
|---|---|---|
| CONSORT | Randomized controlled trials | Flow diagram, randomization, blinding, ITT analysis |
| STROBE | Observational studies | Study design, setting, participants, variables, bias |
| PRISMA | Systematic reviews / meta-analyses | Search strategy, PICO, risk of bias, forest plots |
| ARRIVE | Animal research | Species, sample size, randomization, 3Rs |
| MIAME | Microarray experiments | Platform, normalization, data deposit (GEO/ArrayExpress) |
| MINSEQE | Sequencing experiments | Read counts, mapping, QC metrics, data deposit (SRA) |
Decision Framework
What are you reviewing?
├── Scientific manuscript
│ ├── Original research → Full 7-stage workflow
│ ├── Review / meta-analysis → Emphasize Stage 2 (Introduction + Methods) + Stage 4
│ ├── Methods paper → Emphasize Stage 3 (rigor) + Stage 4 (reproducibility)
│ └── Short communication → Abbreviated workflow (Stages 1, 2, 3, 7)
├── Grant proposal
│ └── Focus on: significance, innovation, approach feasibility, team qualifications
└── Not a document review
└── Use scientific-critical-thinking for evidence evaluation
| Reviewer Situation | Focus Areas | Time Budget |
|---|---|---|
| First-round journal review | All 7 stages, full detail | 4–8 hours |
| Revision re-review | Only check if previous concerns addressed | 1–2 hours |
| Internal lab feedback | Stages 1–3, 7 (skip ethics, reporting standards) | 2–4 hours |
| Conference abstract | Stage 1 only + brief methods check | 30 min |
| Grant review | Significance + innovation + approach + team | 3–6 hours |
Best Practices
-
Read the whole manuscript once before writing any comments: Resist the urge to annotate during first reading. Your initial impression informs the summary statement and helps distinguish major from minor issues.
-
Separate description from judgment: For each concern, first state what you observed ("The authors report p=0.04 without correction for 12 comparisons"), then state why it's problematic ("This inflates the false positive rate"), then suggest a fix ("Apply Bonferroni or FDR correction and re-evaluate significance").
-
Number every comment for easy reference: Both major and minor comments should be sequentially numbered so authors can address each one explicitly in their response letter.
-
State your confidence level on statistical concerns: If you are uncertain about a statistical issue, say so explicitly ("I am not certain whether the normality assumption holds here — the authors should verify with a Shapiro-Wilk test or use a non-parametric alternative").
-
Check reporting standards compliance: Before writing the review, identify which reporting standard applies (CONSORT, STROBE, PRISMA, etc.) and use its checklist to verify completeness. Missing checklist items are legitimate major comments.
-
Acknowledge strengths explicitly: Every review should include 2–3 specific strengths. This is not politeness — it tells editors which aspects are sound and helps authors understand what NOT to change during revision.
-
Never request experiments beyond the study's scope: A reviewer should point out limitations, not redesign the study. "This limitation should be acknowledged in the Discussion" is appropriate. "The authors should run a new cohort with 500 patients" is not.
-
Be concrete about figure quality: Instead of "figures need improvement," say "Figure 3A: y-axis label missing units; error bars are not defined in the legend (SD vs SEM?); color coding is not colorblind-accessible."
-
Maintain anonymity and professionalism: In single/double-blind review, avoid self-referential comments ("In our previous work..."). Never use dismissive or condescending language. Frame all criticism constructively.
-
Match recommendation to evidence: If your comments are all minor, don't recommend rejection. If you have unresolvable major concerns about study design, don't recommend minor revision. The recommendation must be consistent with the severity of identified issues.
Common Pitfalls
-
Reviewing what the paper should be instead of what it is: Judging the paper against your preferred experimental approach rather than evaluating whether the authors' approach is valid. How to avoid: Focus on whether the methods answer the stated research question, not on whether you would have used different methods.
-
Conflating statistical significance with scientific importance: A paper with p=0.001 can be trivial; a paper with p=0.06 can be groundbreaking. How to avoid: Evaluate effect sizes and practical significance separately from p-values. Ask "Does this matter?" not just "Is this significant?"
-
Scope creep in revision requests: Requesting additional experiments that constitute a new study rather than validation of the current one. How to avoid: Before writing a revision request, ask "Is this necessary to support the paper's current conclusions?" If it addresses a new question, suggest it as future work instead.
-
Overlooking positive controls: Checking for negative controls while ignoring whether the experiment demonstrates it CAN detect the claimed effect. How to avoid: For every experiment, check both "What would happen if the effect doesn't exist?" (negative control) AND "What demonstrates the assay actually works?" (positive control).
-
Copy-paste reviewing: Using the same boilerplate concerns across different manuscripts without engaging with the specific content. How to avoid: Every comment should reference a specific section, figure, or claim in THIS manuscript. Generic advice ("improve writing quality") is unhelpful.
-
Ignoring supplementary materials: Many critical methods details and validation data are in supplements. How to avoid: Always read supplementary materials, especially methods and figures referenced from the main text.
-
Asymmetric skepticism: Being more critical of findings that contradict your expectations while accepting confirmatory findings uncritically. How to avoid: Apply the same methodological scrutiny to all results regardless of whether you agree with the conclusions.
Workflow
7-Stage Peer Review Process
-
Initial assessment (read full manuscript without annotation): Identify central question, main findings, overall soundness, venue appropriateness. Write 2-3 sentence summary capturing the manuscript's essence
-
Section-by-section review: Evaluate each section (Abstract, Introduction, Methods, Results, Discussion, References) against specific criteria:
- Abstract: accuracy, completeness, clarity
- Introduction: context, rationale, novelty, literature coverage
- Methods: reproducibility, rigor, detail, statistical justification
- Results: presentation logic, completeness, objectivity
- Discussion: interpretation support, limitations, context, speculation
-
Statistical and methodological rigor: Check statistical assumptions, effect sizes, multiple testing correction, sample size justification, appropriate test selection, control adequacy, randomization, blinding
-
Reproducibility and transparency: Verify data availability (repositories, accession numbers), code availability, reporting standards compliance (CONSORT/STROBE/PRISMA/ARRIVE), protocol detail
-
Figure and data integrity: Check resolution, labeling, error bar definitions, color accessibility, signs of image manipulation, scale bars, data type appropriateness
-
Ethical considerations: Verify IRB/IACUC approval, informed consent, 3Rs compliance, conflicts of interest disclosure, funding disclosure, data privacy
-
Writing quality: Assess organization, language clarity, jargon use, narrative flow, accessibility to broad audience
Review Report Structure
After completing all stages, organize feedback into:
- Summary statement (1-2 paragraphs): Synopsis, overall recommendation, 2-3 strengths, 2-3 weaknesses
- Major comments (numbered): Critical validity/interpretability issues with specific suggestions
- Minor comments (numbered): Clarity/completeness improvements with locations
- Questions for authors: Specific clarification requests
- Recommendation: Accept / Minor revision / Major revision / Reject (consistent with comments)
Further Reading
- COPE Ethical Guidelines for Peer Reviewers — Committee on Publication Ethics
- EQUATOR Network — reporting guidelines for all study types
- Stahel & Moore (2014) "Peer review for biomedical publications: we can improve the system" — BMC Medicine 12:179
- Lovejoy et al. (2011) "An introduction to systematic reviewing" — BMJ Open
- How to peer review — Wiley reviewer guide
Related Skills
- scientific-critical-thinking — evaluating evidence quality and logical reasoning (broader than manuscript review)
- scientific-writing — manuscript authoring; understanding the author's perspective helps write better reviews
- literature-review — systematic evidence gathering; complements Stage 4 (reproducibility) assessment
- scholar-evaluation — quantitative scoring frameworks for automated manuscript assessment
skills/scientific-writing/pnas-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pnas-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "pnas-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "PNAS figure preparation: resolution (300-1000 PPI), formats (TIFF\/EPS\/PDF), strict RGB-only color, Arial\/Helvetica fonts, italicized uppercase panel labels, automated image screening.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
PNAS Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to PNAS (Proceedings of the National Academy of Sciences). PNAS is notable for its strict RGB-only policy (CMYK submissions are returned), italicized uppercase panel labels, and automated image screening software.
Official reference: https://www.pnas.org/author-center/submitting-your-manuscript
Resolution Requirements
| Image Type | Minimum Resolution | Notes |
|---|---|---|
| Halftones (color/grayscale photos) | 300 PPI | At publication size |
| Combination artwork (MS Office) | 600-900 DPI | Mixed text and images |
| Line art (bitmap text/thin lines) | 1,000 PPI | At publication size |
| LaTeX figures | — | High-quality PDF or EPS |
from PIL import Image
def check_pnas_resolution(image_path, image_type='halftone'):
"""Check if image meets PNAS resolution requirements.
Args:
image_type: 'halftone' (300), 'combination' (600), or 'lineart' (1000)
"""
min_ppi = {'halftone': 300, 'combination': 600, 'lineart': 1000}
required = min_ppi.get(image_type, 300)
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
print(f"Type: {image_type} | Required: {required} PPI | Actual: {dpi[0]} PPI")
if dpi[0] >= required:
print("PASS")
else:
print(f"FAIL: Need {required} PPI minimum")
return dpi[0] >= required
File Format
| Format | Accepted |
|---|---|
| TIFF | Yes (preferred for raster) |
| EPS | Yes (fonts must be embedded) |
| Yes (fonts must be embedded) | |
| PPT | Yes |
| 3D images | PRC or U3D with 2D representation (TIFF/EPS/PDF) |
Submission Stages
- Initial submission: Format-neutral — single PDF containing full manuscript, figures, and SI. High-resolution files not required.
- Production phase: Separate high-resolution figure uploads required.
Figure Size and Dimensions
IMPORTANT: Provide images at final publication size, not full-page size.
| Layout | Width |
|---|---|
| 1 column | 8.7 cm (3.43 in) |
| 1.5 columns | 11.4 cm (4.5 in / 27 picas) |
| 2 columns | 17.8 cm (7.0 in / 42.125 picas) |
| Maximum height | 22.5 cm (9 in / 54 picas) |
import matplotlib.pyplot as plt
PNAS_WIDTHS = {
'single': 8.7 / 2.54, # 3.43 inches
'middle': 11.4 / 2.54, # 4.49 inches
'double': 17.8 / 2.54, # 7.01 inches
}
PNAS_MAX_HEIGHT = 22.5 / 2.54 # 8.86 inches
def create_pnas_figure(layout='single', aspect_ratio=0.75):
"""Create a Matplotlib figure sized for PNAS."""
width = PNAS_WIDTHS[layout]
height = min(width * aspect_ratio, PNAS_MAX_HEIGHT)
fig, ax = plt.subplots(figsize=(width, height))
fig.set_dpi(300)
return fig, ax
Color Mode
CRITICAL: RGB Only
- Submit in RGB color mode ONLY
- CMYK submissions will be returned for correction
- Tag RGB images with originating ICC profile for accurate RGB-to-CMYK conversion
- PNAS manages print conversion using calibrated profiles
from PIL import Image
def validate_pnas_color_mode(image_path):
"""PNAS strictly requires RGB. CMYK will be rejected."""
img = Image.open(image_path)
if img.mode == 'CMYK':
print("REJECTED: PNAS does not accept CMYK images")
print("Action: Convert to RGB before submission")
return False
elif img.mode in ('RGB', 'RGBA'):
print("PASS: Image is in RGB mode")
return True
else:
print(f"WARNING: Unexpected mode '{img.mode}'; convert to RGB")
return False
def convert_to_rgb(image_path, output_path):
"""Convert any image to RGB for PNAS submission."""
img = Image.open(image_path)
if img.mode != 'RGB':
img = img.convert('RGB')
print(f"Converted from {img.mode} to RGB")
img.save(output_path)
Font Requirements
| Element | Specification |
|---|---|
| Approved fonts | Arial, Helvetica, Times, Symbol, Mathematical Pi, European Pi |
| Font size | 6-8 pt at final publication size (minimum 2 mm when printed) |
| Consistency | Same font for all figures in manuscript |
| Text type | Vector text preferred (scales cleanly) |
| Embedding | All fonts must be embedded in EPS and PDF files |
Panel Labels (PNAS-Specific Convention)
- Italicized uppercase letters: A, B, C, D, ...
- This is a distinctive PNAS convention — different from most other journals
import matplotlib.pyplot as plt
def set_pnas_fonts():
"""Configure Matplotlib for PNAS figure fonts."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Arial', 'Helvetica'],
'font.size': 7,
'axes.labelsize': 7,
'axes.titlesize': 7,
'xtick.labelsize': 6,
'ytick.labelsize': 6,
'legend.fontsize': 6,
})
def add_pnas_panel_labels(fig, axes):
"""Add PNAS-style italicized uppercase panel labels."""
import string
if not hasattr(axes, '__iter__'):
axes = [axes]
for i, ax in enumerate(axes):
label = string.ascii_uppercase[i]
ax.text(-0.1, 1.1, label,
transform=ax.transAxes,
fontsize=8,
fontstyle='italic',
fontweight='bold',
va='top',
ha='right',
fontfamily='Arial')
Labeling Conventions
- Panel labels: Italicized uppercase (A, B, C)
- All text, numbers, letters, symbols: 6-12 pt (2-6 mm) after reduction
- Text sizing must be consistent within each graphic
- Labels should match body text font conventions
Image Integrity and Manipulation Policy
PROHIBITED
- Enhancing, obscuring, moving, removing, or introducing any specific feature within an image
PERMITTED
- Brightness, contrast, color balance adjustments applied to the whole image without obscuring or eliminating original information (including backgrounds)
REQUIRED
- When combining images from multiple sources: make explicit by arrangement and figure legend text
- Name processing software in Methods section
- Indicate all manipulations in figure legends
- Original data must be available on request (missing data may result in rejection)
Automated Screening
PNAS uses screening software to detect:
- Cloning and pasting artifacts
- Suspicious contrast adjustments that obscure data
- Inconsistent background pixelation
- Other signs of inappropriate image manipulation
Accessibility
- Beginning with Volume 123, PNAS includes alt text for all journal figures to improve digital accessibility
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_pnas_figure(image_path, image_type='halftone', layout='single'):
"""Full validation of a figure against PNAS requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
min_ppi = {'halftone': 300, 'combination': 600, 'lineart': 1000}
required = min_ppi.get(image_type, 300)
dpi = img.info.get('dpi', (72, 72))
if dpi[0] < required:
issues.append(f"Resolution {dpi[0]} PPI below {required} PPI for {image_type}")
# 2. STRICT RGB check
if img.mode == 'CMYK':
issues.append("REJECTED: CMYK not accepted. Must convert to RGB")
elif img.mode not in ('RGB', 'RGBA'):
issues.append(f"Color mode '{img.mode}' not standard; use RGB")
# 3. Dimension check at publication size
widths_cm = {'single': 8.7, 'middle': 11.4, 'double': 17.8}
max_height_cm = 22.5
if layout in widths_cm and dpi[0] > 0:
actual_w_cm = (img.size[0] / dpi[0]) * 2.54
actual_h_cm = (img.size[1] / dpi[1]) * 2.54
target_w = widths_cm[layout]
if actual_h_cm > max_height_cm:
issues.append(f"Height {actual_h_cm:.1f} cm exceeds max {max_height_cm} cm")
print(f"Print size: {actual_w_cm:.1f} x {actual_h_cm:.1f} cm (target width: {target_w} cm)")
# 4. Format check
fmt = img.format
accepted = ['TIFF', 'EPS', 'PDF', 'PNG', 'JPEG']
if fmt and fmt.upper() not in accepted:
issues.append(f"Format '{fmt}' not in preferred list (TIFF, EPS, PDF)")
# Report
print(f"=== PNAS Figure Validation ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
return len(issues) == 0
Key Concepts
Strict RGB-Only Policy
PNAS enforces one of the strictest color mode policies in scientific publishing. CMYK submissions are returned without review. Authors must submit all figures in RGB color mode and tag images with their originating ICC profile to ensure accurate RGB-to-CMYK conversion by the journal's production team.
Three-Tier Resolution System
PNAS uses PPI (pixels per inch) rather than DPI and defines three tiers: 300 PPI for halftones (photographs), 600-900 DPI for combination artwork (mixed text and images from MS Office), and 1,000 PPI for line art (bitmap text and thin lines). Images must meet these thresholds at final publication size.
Automated Image Screening
PNAS employs screening software that automatically detects signs of inappropriate image manipulation including cloning/pasting artifacts, suspicious contrast adjustments, and inconsistent background pixelation. This automated screening supplements editorial review and can flag issues that manual inspection might miss.
Decision Framework
What color mode is your image?
├── CMYK → REJECTED (convert to RGB before submission)
├── RGB → Accepted
│ └── ICC profile tagged? → Recommended for accurate print conversion
└── Grayscale → Convert to RGB
What type of image?
├── Halftone (photo/micrograph) → 300 PPI minimum
├── Combination (MS Office mixed) → 600-900 DPI
├── Line art (bitmap text/lines) → 1,000 PPI
└── LaTeX figure → High-quality PDF or EPS
What submission stage?
├── Initial → Single PDF with all content (format-neutral)
└── Production → Separate high-resolution figure uploads
| Scenario | Format | Resolution | Color Mode |
|---|---|---|---|
| Micrograph | TIFF | 300 PPI | RGB (ICC tagged) |
| PowerPoint chart | PPT or TIFF | 600-900 DPI | RGB |
| Schematic diagram | EPS or PDF | Vector or 1,000 PPI | RGB |
| Gel/blot image | TIFF | 300 PPI | RGB |
| 3D molecular model | PRC/U3D + TIFF | 300 PPI (2D fallback) | RGB |
Best Practices
- Convert CMYK to RGB before submission: PNAS returns CMYK files without review. Always verify color mode before upload using image metadata tools
- Tag RGB images with ICC profiles: Embedding the originating ICC profile ensures accurate color conversion during PNAS's print production pipeline
- Provide images at final publication size: PNAS requires figures sized to column widths (8.7/11.4/17.8 cm). Do not submit oversized images expecting the journal to resize
- Use italicized uppercase panel labels: PNAS uses a distinctive convention — A, B, C (italic, uppercase) — that differs from most other journals
- Embed all fonts in EPS and PDF files: Missing fonts cause rendering failures. Use vector text rather than rasterized text for clean scaling
- Prepare alt text for accessibility: Since Volume 123, PNAS includes alt text for all figures. Drafting alt text during figure preparation saves revision time
- Name processing software in Methods: PNAS requires explicit mention of all image processing software used, not just a general description of adjustments
Common Pitfalls
- Submitting figures in CMYK color mode: PNAS strictly rejects CMYK. This is the most common preventable rejection reason for figures
- How to avoid: Run the color mode validation script on every figure before submission; convert CMYK to RGB
- Using 300 PPI for line art: Line art with bitmap text requires 1,000 PPI. At 300 PPI, thin lines and small text appear jagged
- How to avoid: Classify each figure by type (halftone vs. line art vs. combination) and apply the correct resolution tier
- Submitting figures larger than publication size: PNAS requires images at final size, not oversized. Oversized figures may be rescaled, degrading quality
- How to avoid: Set canvas size to the target column width before exporting
- Using non-italic panel labels: PNAS's italic uppercase convention (A, B, C) is unique. Non-italic labels signal unfamiliarity with the journal
- How to avoid: Use
fontstyle='italic'in matplotlib or the italic setting in Illustrator for panel labels
- How to avoid: Use
- Omitting multi-source composite indicators: When combining images from different experiments, PNAS requires explicit visual and textual indication
- How to avoid: Use clear borders or spacing between composite elements and describe the composition in the figure legend
Pre-Submission Checklist
Before submitting figures to PNAS, verify:
- Halftones at 300+ PPI; line art at 1,000+ PPI
- File format is TIFF, EPS, or PDF
- Color mode is RGB (NOT CMYK — will be rejected)
- ICC profile tagged for accurate color conversion
- Figure width matches column layout (8.7 / 11.4 / 17.8 cm)
- Figure height does not exceed 22.5 cm
- Images provided at final publication size
- Font is Arial, Helvetica, or Times at 6-8 pt
- All fonts embedded in EPS/PDF
- Panel labels are italicized uppercase (A, B, C)
- Text sizing consistent within each figure
- All adjustments applied to entire image uniformly
- Multi-source composites explicitly indicated in legend
- Processing software named in Methods
- Original unprocessed data available for review
- Alt text prepared for accessibility
References
- PNAS Submission Guidelines: https://www.pnas.org/author-center/submitting-your-manuscript
- PNAS Digital Art Guidelines: https://www.pnas.org/pb-assets/authors/digitalart.pdf
- PNAS Editorial Policies: https://www.pnas.org/author-center/editorial-and-journal-policies
skills/scientific-writing/pubmed-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pubmed-database -g -y
SKILL.md
Frontmatter
{
"name": "pubmed-database",
"license": "CC-BY-4.0",
"description": "Programmatic PubMed access via NCBI E-utilities REST API. Covers Boolean\/MeSH queries, field-tagged search, endpoints (ESearch, EFetch, ESummary, EPost, ELink), history server for batches, citation matching, systematic review strategies. Use for biomedical literature search or automated pipelines."
}
PubMed Database
Overview
PubMed is the U.S. National Library of Medicine's database providing free access to 36M+ biomedical citations from MEDLINE and life sciences journals. This skill covers programmatic access via the E-utilities REST API and advanced search query construction using Boolean operators, MeSH terms, and field tags.
When to Use
- Searching biomedical literature with structured Boolean/MeSH queries
- Building automated literature monitoring or extraction pipelines
- Conducting systematic literature reviews or meta-analyses
- Retrieving article metadata, abstracts, or citation information by PMID/DOI
- Finding related articles or exploring citation networks programmatically
- Batch processing large sets of PubMed records
- For Python-native PubMed access, prefer BioPython (
Bio.Entrez) — this skill covers direct REST API usage - For broader academic search (non-biomedical), use OpenAlex or Semantic Scholar APIs
Prerequisites
pip install requests # HTTP client for E-utilities API
# Optional: pip install biopython — Bio.Entrez wrapper (higher-level API)
API Rate Limits:
- Without API key: 3 requests/second
- With API key: 10 requests/second (register at https://www.ncbi.nlm.nih.gov/account/)
- Always include
User-Agentheader with contact email
Quick Start
import requests
import time
BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/"
API_KEY = "YOUR_API_KEY" # Optional but recommended
def pubmed_request(endpoint, params):
"""Reusable helper for E-utilities API calls with rate limiting."""
params.setdefault("api_key", API_KEY)
response = requests.get(f"{BASE_URL}{endpoint}", params=params)
response.raise_for_status()
time.sleep(0.1 if API_KEY != "YOUR_API_KEY" else 0.34) # Rate limit
return response
# Search → Fetch workflow
search_resp = pubmed_request("esearch.fcgi", {
"db": "pubmed", "term": "CRISPR[tiab] AND 2024[dp]",
"retmax": 5, "retmode": "json"
})
pmids = search_resp.json()["esearchresult"]["idlist"]
print(f"Found {len(pmids)} articles: {pmids}")
fetch_resp = pubmed_request("efetch.fcgi", {
"db": "pubmed", "id": ",".join(pmids),
"rettype": "abstract", "retmode": "text"
})
print(fetch_resp.text[:500])
Core API
1. Search Query Construction
Build PubMed queries using Boolean operators, field tags, and MeSH terms.
# Boolean operators: AND, OR, NOT (must be uppercase)
queries = {
"basic": "diabetes AND treatment AND 2024[dp]",
"synonyms": "(metformin OR insulin) AND type 2 diabetes",
"exclude": "cancer NOT review[pt]",
"phrase": '"gene expression" AND RNA-seq',
"field_tags": "smith ja[au] AND cancer[tiab] AND 2023[dp]",
}
# Common field tags:
# [tiab] = title/abstract [au] = author [mh] = MeSH term
# [pt] = publication type [dp] = date [ta] = journal
# [1au] = first author [lastau] = last author
# [affil] = affiliation [doi] = DOI [pmid] = PubMed ID
# Date filtering
date_queries = {
"single_year": "cancer AND 2024[dp]",
"range": "cancer AND 2020:2024[dp]",
"specific": "cancer AND 2024/03/15[dp]",
}
# MeSH terms — controlled vocabulary for precise searching
mesh_queries = {
# [mh] includes narrower terms automatically
"broad": "diabetes mellitus[mh]",
# [majr] limits to major topic focus
"focused": "diabetes mellitus[majr]",
# MeSH + subheading
"therapy": "diabetes mellitus, type 2[mh]/drug therapy",
# Substance name
"drug": "metformin[nm] AND diabetes mellitus[mh]",
}
# Common MeSH subheadings:
# /diagnosis /drug therapy /epidemiology /etiology
# /prevention & control /therapy /genetics
2. ESearch — Search and Retrieve PMIDs
# Basic search
resp = pubmed_request("esearch.fcgi", {
"db": "pubmed",
"term": "CRISPR[tiab] AND genome editing[tiab] AND 2024[dp]",
"retmax": 100,
"retmode": "json",
"sort": "relevance", # or "pub_date", "first_author"
})
result = resp.json()["esearchresult"]
pmids = result["idlist"]
total = result["count"]
print(f"Total hits: {total}, Retrieved: {len(pmids)}")
# With history server (for large result sets > 500)
resp = pubmed_request("esearch.fcgi", {
"db": "pubmed",
"term": "cancer AND 2024[dp]",
"usehistory": "y",
"retmode": "json",
})
result = resp.json()["esearchresult"]
webenv = result["webenv"]
query_key = result["querykey"]
total = int(result["count"])
print(f"Stored {total} results on history server")
3. EFetch — Download Full Records
# Fetch abstracts as text
resp = pubmed_request("efetch.fcgi", {
"db": "pubmed",
"id": ",".join(pmids[:10]),
"rettype": "abstract",
"retmode": "text",
})
print(resp.text[:500])
# Fetch XML for structured parsing
resp = pubmed_request("efetch.fcgi", {
"db": "pubmed",
"id": ",".join(pmids[:10]),
"rettype": "xml",
"retmode": "xml",
})
# Fetch from history server (batch processing)
batch_size = 500
for start in range(0, total, batch_size):
resp = pubmed_request("efetch.fcgi", {
"db": "pubmed",
"query_key": query_key,
"WebEnv": webenv,
"retstart": start,
"retmax": batch_size,
"rettype": "xml",
"retmode": "xml",
})
print(f"Fetched records {start}–{start + batch_size}")
time.sleep(0.5) # Extra delay for large batches
4. ESummary and ELink — Summaries and Related Articles
# ESummary — lightweight document summaries
resp = pubmed_request("esummary.fcgi", {
"db": "pubmed",
"id": ",".join(pmids[:5]),
"retmode": "json",
})
for uid, data in resp.json()["result"].items():
if uid == "uids":
continue
print(f"PMID {uid}: {data.get('title', '')[:80]}")
print(f" Journal: {data.get('fulljournalname', '')}, "
f"Date: {data.get('pubdate', '')}")
# ELink — find related articles
resp = pubmed_request("elink.fcgi", {
"dbfrom": "pubmed",
"db": "pubmed",
"id": pmids[0],
"cmd": "neighbor",
"retmode": "json",
})
# Links to other NCBI databases
resp = pubmed_request("elink.fcgi", {
"dbfrom": "pubmed",
"db": "pmc", # PubMed Central
"id": pmids[0],
"retmode": "json",
})
5. Citation Matching and Identifier Lookup
# Search by identifiers
id_queries = {
"pmid": "12345678[pmid]",
"doi": "10.1056/NEJMoa123456[doi]",
"pmc": "PMC123456[pmc]",
}
# ECitMatch — match partial citations to PMIDs
# Format: journal|year|volume|first_page|author_name|key|
citation = "Science|2008|320|5880|1185|key1|"
resp = pubmed_request("ecitmatch.cgi", {
"db": "pubmed",
"rettype": "xml",
"bdata": citation,
})
print(f"Matched PMID: {resp.text.strip()}")
# Batch citation matching
citations = [
"Nature|2020|580|7801|71|ref1|",
"Science|2019|366|6463|347|ref2|",
]
resp = pubmed_request("ecitmatch.cgi", {
"db": "pubmed",
"rettype": "xml",
"bdata": "\r".join(citations),
})
6. Publication Filtering
# Filter by publication type
type_filters = {
"rcts": "randomized controlled trial[pt]",
"reviews": "systematic review[pt]",
"meta": "meta-analysis[pt]",
"guidelines": "guideline[pt]",
"case_reports": "case reports[pt]",
}
# Filter by text availability
availability = {
"free_text": "free full text[sb]",
"has_abstract": "hasabstract[text]",
}
# Combine filters
query = (
"diabetes mellitus[mh] AND "
"randomized controlled trial[pt] AND "
"2023:2024[dp] AND "
"free full text[sb] AND "
"english[la]"
)
resp = pubmed_request("esearch.fcgi", {
"db": "pubmed", "term": query, "retmax": 100, "retmode": "json"
})
print(f"Free RCTs on diabetes (2023-2024): {resp.json()['esearchresult']['count']}")
Key Concepts
E-utilities Endpoint Summary
| Endpoint | Purpose | Key Parameters |
|---|---|---|
esearch.fcgi |
Search, return PMIDs | term, retmax, sort, usehistory |
efetch.fcgi |
Download full records | id/query_key+WebEnv, rettype, retmode |
esummary.fcgi |
Lightweight summaries | id, retmode |
epost.fcgi |
Upload UIDs to server | id (comma-separated) |
elink.fcgi |
Related articles, cross-DB | id, dbfrom, db, cmd |
einfo.fcgi |
List databases/fields | db (optional) |
egquery.fcgi |
Count hits across DBs | term |
espell.fcgi |
Spelling suggestions | term |
ecitmatch.cgi |
Match citations to PMIDs | bdata |
History Server Pattern
For result sets >500 articles, use the history server to avoid URL length limits:
- ESearch with
usehistory=y→ returnsWebEnv+QueryKey - EFetch in batches using
WebEnv+QueryKey+retstart/retmax - EPost to upload additional PMIDs to the same
WebEnv
Automatic Term Mapping (ATM)
When no field tag is specified, PubMed maps terms through: MeSH Translation Table → Journals Translation Table → Author Index → Full Text. Bypass ATM with explicit field tags or quoted phrases.
Common MeSH Subheadings
| Subheading | Abbreviation | Use |
|---|---|---|
| /diagnosis | /DI | Diagnostic methods |
| /drug therapy | /DT | Pharmaceutical treatment |
| /epidemiology | /EP | Disease patterns |
| /etiology | /ET | Disease causes |
| /genetics | /GE | Genetic aspects |
| /prevention & control | /PC | Preventive measures |
| /therapy | /TH | Treatment approaches |
Common Workflows
Workflow 1: Systematic Review Search
import requests, time, json
# 1. Define PICO-structured query
query = (
# Population
"(diabetes mellitus, type 2[mh] OR type 2 diabetes[tiab]) AND "
# Intervention + Comparison
"(metformin[nm] OR lifestyle modification[tiab]) AND "
# Outcome
"(glycemic control[tiab] OR HbA1c[tiab]) AND "
# Study design filter
"(randomized controlled trial[pt] OR systematic review[pt]) AND "
# Date range
"2020:2024[dp]"
)
# 2. Search with history server
resp = pubmed_request("esearch.fcgi", {
"db": "pubmed", "term": query,
"usehistory": "y", "retmode": "json"
})
result = resp.json()["esearchresult"]
total = int(result["count"])
print(f"Systematic review hits: {total}")
# 3. Batch fetch all results as XML
import xml.etree.ElementTree as ET
articles = []
for start in range(0, total, 200):
resp = pubmed_request("efetch.fcgi", {
"db": "pubmed", "query_key": result["querykey"],
"WebEnv": result["webenv"],
"retstart": start, "retmax": 200,
"rettype": "xml", "retmode": "xml"
})
root = ET.fromstring(resp.text)
for article in root.findall('.//PubmedArticle'):
pmid = article.findtext('.//PMID')
title = article.findtext('.//ArticleTitle')
articles.append({"pmid": pmid, "title": title})
time.sleep(0.5)
print(f"Retrieved {len(articles)} articles for screening")
Workflow 2: Literature Monitoring Pipeline
import json, datetime
# 1. Construct monitoring query
topic_query = (
"(CRISPR[tiab] OR gene editing[tiab]) AND "
"(therapeutics[tiab] OR clinical trial[pt])"
)
# 2. Search recent publications (last 30 days)
today = datetime.date.today()
start_date = today - datetime.timedelta(days=30)
query = f"{topic_query} AND {start_date.strftime('%Y/%m/%d')}:{today.strftime('%Y/%m/%d')}[dp]"
resp = pubmed_request("esearch.fcgi", {
"db": "pubmed", "term": query,
"retmax": 100, "retmode": "json", "sort": "pub_date"
})
pmids = resp.json()["esearchresult"]["idlist"]
# 3. Get summaries for new articles
if pmids:
resp = pubmed_request("esummary.fcgi", {
"db": "pubmed", "id": ",".join(pmids), "retmode": "json"
})
for uid in pmids:
info = resp.json()["result"].get(uid, {})
print(f"[{uid}] {info.get('title', 'N/A')[:80]}")
print(f" {info.get('fulljournalname', '')} — {info.get('pubdate', '')}")
Key Parameters
| Parameter | Endpoint | Default | Effect |
|---|---|---|---|
term |
ESearch | Required | Search query with Boolean/field tags |
retmax |
ESearch/EFetch | 20 | Max records returned (up to 10,000) |
retstart |
ESearch/EFetch | 0 | Offset for pagination |
rettype |
EFetch | full |
Output type: abstract, medline, xml, uilist |
retmode |
All | xml |
Output format: xml, json, text |
sort |
ESearch | relevance |
Sort order: relevance, pub_date, first_author |
usehistory |
ESearch | n |
Enable history server: y for large result sets |
api_key |
All | None | NCBI API key for 10 req/sec (vs 3 without) |
cmd |
ELink | neighbor |
Link type: neighbor, neighbor_score, prlinks |
datetype |
ESearch | pdat |
Date field: pdat (publication), edat (entrez) |
Best Practices
- Always use an API key — register at NCBI for 10 req/sec instead of 3
- Use history server for >500 results — avoids URL length limits and enables batch fetching
- Include rate limiting —
time.sleep(0.1)with API key,time.sleep(0.34)without - Cache results locally — PubMed data changes slowly; cache responses to minimize API calls
- Use MeSH terms + free text — combine
[mh]and[tiab]for comprehensive coverage:(diabetes mellitus[mh] OR diabetes[tiab]) - Document search strategies — for systematic reviews, record exact queries, dates, and result counts
- Parse XML for structured data — text output is human-readable but XML preserves field structure for automated extraction
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| HTTP 429 (Too Many Requests) | Exceeding rate limit | Add time.sleep(); use API key for higher limit |
| HTTP 414 (URI Too Long) | Too many PMIDs in URL | Use history server (usehistory=y) or EPost |
| Empty result set | Overly restrictive query | Remove filters one at a time; check ATM with EInfo |
| Unexpected MeSH mapping | Automatic Term Mapping | Use explicit field tags: term[tiab] instead of bare term |
| Missing abstracts | Pre-1975 articles or certain types | Filter: hasabstract[text] |
| XML parsing errors | Malformed response | Check retmode=xml and rettype=xml; handle encoding |
| Stale history server | Session expired (8h inactivity) | Re-run ESearch with usehistory=y to get new WebEnv |
| Truncated results | Default retmax=20 |
Set retmax=100 or higher (max 10,000) |
Common Recipes
Recipe: Download Abstracts for a Gene Set
import requests
import time
def fetch_abstracts(gene_list, max_per_gene=5):
"""Retrieve PubMed abstracts for each gene in a list."""
base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
records = []
for gene in gene_list:
r = requests.get(f"{base}/esearch.fcgi",
params={"db": "pubmed", "term": f"{gene}[gene] AND Homo sapiens[orgn]",
"retmax": max_per_gene, "retmode": "json"})
ids = r.json()["esearchresult"]["idlist"]
if ids:
fetch = requests.get(f"{base}/efetch.fcgi",
params={"db": "pubmed", "id": ",".join(ids), "rettype": "abstract"})
records.append({"gene": gene, "pmids": ids, "text": fetch.text[:500]})
time.sleep(0.34)
return records
results = fetch_abstracts(["BRCA1", "TP53", "EGFR"])
for r in results:
print(f"{r['gene']}: {r['pmids']}")
Recipe: Track New Publications via Date Filter
import requests
from datetime import date, timedelta
# Find papers published in the last 7 days on a topic
week_ago = (date.today() - timedelta(days=7)).strftime("%Y/%m/%d")
today = date.today().strftime("%Y/%m/%d")
resp = requests.get("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
params={"db": "pubmed", "term": "CRISPR AND cancer",
"datetype": "pdat", "mindate": week_ago, "maxdate": today,
"retmax": 20, "retmode": "json"})
data = resp.json()["esearchresult"]
print(f"New CRISPR+cancer papers this week: {data['count']}")
print("PMIDs:", data["idlist"])
Bundled Resources
references/search_syntax.md— Complete field tag reference, Boolean/wildcard/proximity syntax, automatic term mapping rules, all filter types (age groups, species, languages), and clinical query filtersreferences/common_queries.md— Ready-to-use query templates organized by domain (disease-specific, population-specific, methodology, drug research, epidemiology) with ~40 example patterns
Not migrated from original: references/api_reference.md (298 lines) — endpoint parameter details are consolidated into Core API sections 2-5 and the E-utilities Endpoint Summary table in Key Concepts.
References
- PubMed Help: https://pubmed.ncbi.nlm.nih.gov/help/
- E-utilities Documentation: https://www.ncbi.nlm.nih.gov/books/NBK25501/
- NCBI API Key Registration: https://www.ncbi.nlm.nih.gov/account/
Related Skills
- biopython — higher-level Python wrapper (
Bio.Entrez) for E-utilities - openalex-database — broader academic literature beyond biomedical
- literature-review — systematic review methodology and PRISMA framework
skills/scientific-writing/science-figure-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill science-figure-guide -g -y
SKILL.md
Frontmatter
{
"name": "science-figure-guide",
"license": "CC-BY-4.0",
"metadata": {
"authors": "HITS",
"version": "1.0"
},
"description": "Science (AAAS) figure preparation: resolution (150-300+ DPI), formats (PDF\/EPS\/TIFF), RGB color, Myriad\/Helvetica fonts, strict image manipulation policies including gamma adjustment disclosure.",
"compatibility": "Python 3.10+, Pillow, Matplotlib"
}
Science (AAAS) Figure Preparation Guide
Overview
This guide provides the complete specifications for preparing figures for submission to Science journal (published by AAAS). Science has different requirements for initial vs. revised manuscript submissions and requires explicit disclosure of nonlinear image adjustments.
Official reference: https://www.science.org/content/page/instructions-preparing-initial-manuscript
Resolution Requirements
Initial Submission
| Image Type | Resolution |
|---|---|
| All figures | 150-300 DPI |
Revised Manuscript (Higher Standards)
| Image Type | Minimum Resolution | Notes |
|---|---|---|
| Line art | 300 DPI+ | At final print size |
| Grayscale and color artwork | 300 DPI+ | Higher resolution preferred |
CRITICAL: Upsampling (artificially increasing resolution) is prohibited. The resolution must be native to the image.
from PIL import Image
def check_science_resolution(image_path, stage='initial'):
"""Check image resolution for Science submission.
Args:
image_path: Path to image file
stage: 'initial' (150-300 DPI) or 'revised' (300+ DPI)
"""
img = Image.open(image_path)
dpi = img.info.get('dpi', (72, 72))
min_dpi = 150 if stage == 'initial' else 300
print(f"Stage: {stage} submission")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Minimum required: {min_dpi} DPI")
if dpi[0] >= min_dpi:
print("PASS: Resolution meets Science requirements")
else:
print(f"FAIL: Need at least {min_dpi} DPI")
return dpi[0] >= min_dpi
File Format
Revised Manuscript Formats (in order of preference)
| Figure Type | Preferred Formats |
|---|---|
| Vector illustrations/diagrams | PDF, EPS, Adobe Illustrator (AI) |
| Raster illustrations/diagrams | TIFF (300+ DPI) |
| Photography/microscopy | TIFF, JPEG, PNG, PSD, EPS, PDF |
Initial Submission
- Figures can be embedded directly in
.docxor PDF manuscript files
Figure Size and Dimensions
| Layout | Width |
|---|---|
| 1 column | 3.4 inches (86 mm / 20.38 picas) |
| 1.5 columns | 5.0 inches |
| 2 columns | 7.0-7.3 inches |
| Maximum | 6.5 x 8 inches (16.5 x 20 cm) |
Figures should be sized to fit 8.5" x 11" or A4 paper.
import matplotlib.pyplot as plt
SCIENCE_WIDTHS = {
'single': 3.4, # inches
'middle': 5.0,
'double': 7.3,
}
def create_science_figure(layout='single', aspect_ratio=0.75):
"""Create a Matplotlib figure sized for Science journal."""
width = SCIENCE_WIDTHS[layout]
height = min(width * aspect_ratio, 8.0) # max height 8 inches
fig, ax = plt.subplots(figsize=(width, height))
fig.set_dpi(300)
return fig, ax
Color Mode
- RGB is the standard practice
- Journal handles CMYK conversion for print
- No explicit color mode mandate, but RGB is recommended
Font Requirements
| Element | Font | Size | Style |
|---|---|---|---|
| Preferred font | Myriad | — | Sans-serif |
| Alternative fonts | Helvetica, Arial | — | Sans-serif |
| Panel labels | — | 8 pt | Bold |
| Other figure text | — | 5-7 pt | Max 7 pt, min 5 pt |
Critical Rules
- All fonts must be embedded
- Use sans-serif fonts whenever possible
- Simple solid or open symbols reduce well at small sizes
import matplotlib.pyplot as plt
def set_science_fonts():
"""Configure Matplotlib for Science journal figure fonts."""
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Myriad Pro', 'Helvetica', 'Arial'],
'font.size': 7,
'axes.labelsize': 7,
'axes.titlesize': 7,
'xtick.labelsize': 6,
'ytick.labelsize': 6,
'legend.fontsize': 5,
})
Labeling Conventions
Figure Numbering
- Cite figures in numerical order in the text
Titles and Captions
- Figure titles go at the beginning of the caption, NOT inside the figure
- No single caption should exceed approximately 200 words
Axes
- Label ordinate and abscissa with: parameter/variable, units, and scale
- Use sans-serif fonts for all labels
def format_science_axis(ax, xlabel, ylabel, x_unit='', y_unit=''):
"""Format axes following Science journal conventions."""
if x_unit:
ax.set_xlabel(f"{xlabel} ({x_unit})", fontsize=7)
else:
ax.set_xlabel(xlabel, fontsize=7)
if y_unit:
ax.set_ylabel(f"{ylabel} ({y_unit})", fontsize=7)
else:
ax.set_ylabel(ylabel, fontsize=7)
ax.tick_params(labelsize=6)
Image Integrity and Manipulation Policy
PERMITTED
- Linear adjustments: Brightness, contrast, color — applied uniformly to the entire image
REQUIRES DISCLOSURE
- Nonlinear adjustments (e.g., gamma corrections) must be specified in the figure caption
PROHIBITED
- Selective enhancement: Altering one part of an image while leaving other parts unchanged
- Adjustments that cause changes in data interpretation
- Manipulations applied to local areas only
Best Practices
- Perform all manipulations only on copies of unprocessed image data
- Adjustments must be moderate and not misrepresent the data
- All changes must be applied to the whole image
Python Quick Start: Full Validation
from PIL import Image
import os
def validate_science_figure(image_path, stage='initial'):
"""Full validation of a figure against Science requirements."""
img = Image.open(image_path)
issues = []
# 1. Resolution check
dpi = img.info.get('dpi', (72, 72))
min_dpi = 150 if stage == 'initial' else 300
if dpi[0] < min_dpi:
issues.append(f"Resolution {dpi[0]} DPI below minimum {min_dpi} DPI for {stage} submission")
# 2. Color mode check
if img.mode not in ('RGB', 'RGBA'):
issues.append(f"Color mode is {img.mode}; RGB recommended")
# 3. Dimension check
if dpi[0] > 0:
width_in = img.size[0] / dpi[0]
height_in = img.size[1] / dpi[1]
if width_in > 7.3:
issues.append(f"Width {width_in:.1f}\" exceeds maximum 7.3\"")
if height_in > 8.0:
issues.append(f"Height {height_in:.1f}\" exceeds maximum 8.0\"")
# Report
print(f"=== Science Figure Validation ({stage}) ===")
print(f"Dimensions: {img.size[0]} x {img.size[1]} px")
print(f"DPI: {dpi[0]} x {dpi[1]}")
print(f"Color mode: {img.mode}")
if issues:
print(f"\nISSUES FOUND ({len(issues)}):")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll checks PASSED")
return len(issues) == 0
Key Concepts
Two-Stage Submission Process
Science uses different figure requirements for initial vs. revised manuscripts. Initial submissions accept 150-300 DPI figures embedded in the manuscript. Revised manuscripts require separate high-resolution files (300+ DPI) in publication-ready formats. This two-stage approach reduces upfront preparation burden while ensuring final quality.
Nonlinear Adjustment Disclosure
Science uniquely requires explicit disclosure of nonlinear image adjustments (e.g., gamma corrections) in the figure caption. Linear adjustments (brightness, contrast) applied uniformly need no special disclosure. This distinction is critical — failure to disclose gamma changes can be flagged as data manipulation.
Figure Sizing Constraints
Science enforces strict maximum dimensions: 6.5 x 8 inches (16.5 x 20 cm). Single-column figures are 3.4 inches wide; double-column figures are 7.0-7.3 inches. All figures must fit on standard letter (8.5 x 11 inch) or A4 paper.
Decision Framework
What submission stage are you at?
├── Initial submission
│ ├── Embed figures in manuscript (.docx or PDF)
│ └── 150-300 DPI acceptable
└── Revised manuscript
├── Upload separate high-resolution files
├── Vector figures (graphs, diagrams)
│ └── PDF, EPS, or AI format
└── Raster figures (photos, micrographs)
└── TIFF at 300+ DPI
| Scenario | Format | Resolution | Notes |
|---|---|---|---|
| Initial submission graph | Embedded in .docx | 150-300 DPI | Low bar for initial review |
| Revised manuscript graph | PDF or EPS | Vector | Separate upload required |
| Photograph (revised) | TIFF | 300+ DPI | Native resolution only |
| Micrograph with gamma adjustment | TIFF | 300+ DPI | Disclose gamma in caption |
| Diagram or schematic | AI or EPS | Vector | Embed all fonts |
Best Practices
- Prepare high-resolution files from the start: Even though initial submissions accept 150 DPI, creating figures at 300+ DPI from the beginning avoids rework during revision
- Disclose all nonlinear adjustments in captions: Gamma corrections and other nonlinear transforms must be explicitly noted in the figure caption, not just in Methods
- Use Myriad Pro as primary font: Science's preferred font is Myriad; Helvetica and Arial are acceptable alternatives. Consistent font usage across all figures is expected
- Keep captions under 200 words: Science enforces a ~200-word limit on individual figure captions. Move detailed methodology to the Methods section
- Place titles in captions, not figures: Figure titles belong at the beginning of the caption text, not rendered inside the figure itself
- Label axes with parameter, units, and scale: Science requires all axes to include the measured parameter, units in parentheses, and scale information
- Use simple solid or open symbols: Science recommends symbols that reproduce well at small sizes. Avoid complex or filled symbols that become indistinguishable when reduced
Common Pitfalls
- Submitting low-resolution figures for revised manuscripts: The 150 DPI floor only applies to initial submissions. Revised manuscripts require 300+ DPI
- How to avoid: Re-export all figures at 300+ DPI before submitting the revision
- Failing to disclose gamma adjustments: Nonlinear corrections not mentioned in the caption can trigger an image integrity investigation
- How to avoid: Add a sentence to the caption for any figure with gamma or nonlinear adjustments (e.g., "Gamma correction of 0.8 applied uniformly")
- Exceeding figure dimension limits: Figures wider than 7.3 inches or taller than 8 inches will be rejected
- How to avoid: Check dimensions before export; use the validation script to verify
- Upsampling images to meet resolution requirements: Artificially increasing DPI adds no real detail and may introduce artifacts
- How to avoid: Re-export from the original source at native high resolution
- Using serif fonts in figures: Science requires sans-serif fonts (Myriad, Helvetica, Arial). Serif fonts like Times New Roman are not acceptable
- How to avoid: Set sans-serif as the default in your plotting software before generating figures
Pre-Submission Checklist
Before submitting figures to Science, verify:
- Initial submission: 150-300 DPI minimum
- Revised submission: 300+ DPI minimum (no upsampling)
- Vector figures in PDF, EPS, or AI format
- Raster figures in TIFF at 300+ DPI
- Font is Myriad, Helvetica, or Arial (sans-serif)
- Panel labels at 8 pt bold; text between 5-7 pt
- All fonts embedded
- Figure titles in caption, not inside figure
- Captions under 200 words
- Axes labeled with parameter, units, and scale
- All adjustments applied uniformly to entire image
- Gamma/nonlinear adjustments noted in caption
- No selective enhancement of image regions
- Original unprocessed data preserved
References
- Science Initial Manuscript Instructions: https://www.science.org/content/page/instructions-preparing-initial-manuscript
- Science Revised Article Instructions: https://www.science.org/content/page/instructions-authors-revised-research-articles
- Science Editorial Policies: https://www.science.org/content/page/science-journals-editorial-policies
skills/scientific-writing/scientific-brainstorming/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-brainstorming -g -y
SKILL.md
Frontmatter
{
"name": "scientific-brainstorming",
"license": "CC-BY-4.0",
"description": "Structured ideation methods: SCAMPER, Six Thinking Hats, Morphological Analysis, TRIZ, Biomimicry, plus more. Decision framework for picking methods by challenge type (stuck, improving, systematic exploration, contradiction). Use when generating research ideas or exploring interdisciplinary connections."
}
Scientific Brainstorming
Overview
Scientific brainstorming is a structured ideation process for generating, connecting, and evaluating research ideas. Unlike casual brainstorming, scientific brainstorming applies formal methodologies (SCAMPER, TRIZ, Morphological Analysis, etc.) matched to the specific creative challenge. The process moves through divergent exploration, connection-making, critical evaluation, and synthesis to produce actionable research directions with testable hypotheses.
Key Concepts
Core Principles
Five principles guide effective scientific brainstorming sessions:
-
Collaborative: Brainstorming works best as dialogue, not monologue. Build on each other's ideas rather than presenting finished thoughts. Use "Yes, and..." framing to extend ideas before evaluating them. In AI-assisted sessions, the scientist contributes domain expertise and the AI contributes breadth and pattern-matching across disciplines.
-
Curious: Approach the problem space with genuine curiosity. Ask "what if" and "why not" before "why." Suspend expertise-driven assumptions temporarily to allow unexpected connections. Experts often dismiss novel directions because they conflict with established mental models -- curiosity counteracts this.
-
Domain-Aware: Ground brainstorming in real scientific constraints. Ideas must eventually connect to testable hypotheses, available methods, and feasible experiments. Domain knowledge channels creativity productively. Pure creativity without domain grounding produces ideas that cannot be tested; pure domain expertise without creativity produces incremental work.
-
Structured: Use formal ideation methods rather than unguided free association. Structure prevents cognitive fixation (repeatedly returning to the same idea space) and ensures systematic coverage of the possibility space. Unstructured brainstorming sessions typically explore less than 20% of the available idea space.
-
Challenging: Actively seek ideas that feel uncomfortable or counterintuitive. The most productive brainstorming sessions push past obvious solutions into territory that requires deeper analysis. If every idea generated feels reasonable and safe, the session is not pushing hard enough.
Brainstorming Methods Catalog
Nine structured methodologies, each suited to different creative challenges:
| Method | When to Use | Key Technique | Scientific Example |
|---|---|---|---|
| SCAMPER | Improving or extending an existing method/system | Systematically apply 7 operators (Substitute, Combine, Adapt, Modify, Put to use, Eliminate, Reverse) to the current approach | Substitute fluorescence for radioactive labeling in an assay; Combine two biomarker panels into a multiplex panel |
| Six Thinking Hats | Need multiple perspectives on a research question | Assign structured roles: White (data/facts), Red (intuition/feelings), Black (critical/risks), Yellow (benefits/optimism), Green (creative alternatives), Blue (process management) | Evaluate a proposed clinical trial: White examines prior data, Black identifies ethical risks, Green suggests novel endpoints |
| Morphological Analysis | Exploring all combinations within a design space | Define dimensions of the problem, list options per dimension, systematically explore combinations | Drug delivery: dimensions = carrier (liposome, nanoparticle, hydrogel) x targeting (passive, active, magnetic) x release (pH, thermal, enzymatic) |
| TRIZ | Resolving technical contradictions | Identify the contradiction (improving X worsens Y), apply inventive principles, envision the ideal final result | Increasing drug potency (desired) increases toxicity (undesired) -- apply separation principle: target only affected tissue |
| Biomimicry | Seeking nature-inspired solutions | Define function, biologize the question, discover natural models, abstract the principle, apply to problem | "How does nature filter particles?" leads to studying kidney nephrons for microfluidic filter design |
| Provocation (Po) | Breaking out of fixed thinking patterns | State an impossible or absurd premise ("Po: cells never divide"), then extract useful principles from the provocation | "Po: proteins fold instantly" -- what if we engineered ultrafast folding domains? Leads to intrinsically disordered protein research |
| Random Input | Need fresh connections when stuck in a rut | Select a random stimulus (word, image, object from nature), force connections to the research problem | Random word "bridge" + enzyme kinetics = bridging molecules that connect substrate to enzyme active site |
| Reverse Assumptions | Questioning fundamental assumptions | List all assumptions about the problem, flip each one, explore consequences of each reversal | Assumption: "higher purity improves results" -- reverse: what if impurities are functional? Leads to studying beneficial contaminants |
| Future Backwards | Envisioning long-term research directions | Start from a solved future state, work backwards to identify necessary intermediate steps and breakthroughs | "Cancer is cured in 2050" -- what needed to happen in 2040? 2030? What research today enables the 2030 milestone? |
SCAMPER Operators in Detail
The seven SCAMPER operators applied to scientific contexts:
-
Substitute: Replace one component with another. What material, reagent, model organism, or technique could replace the current one? Example: replace mouse models with organoids; substitute CRISPR for siRNA knockdown.
-
Combine: Merge two approaches, techniques, or datasets. What happens if you combine two assays into one? Two datasets from different modalities? Example: combine proteomics and metabolomics in a single sample preparation.
-
Adapt: Borrow a technique from another field. What methods from physics, engineering, or computer science could solve this biological problem? Example: adapt semiconductor lithography for tissue engineering scaffolds.
-
Modify: Change the scale, frequency, intensity, or duration. What if you ran the experiment 10x faster, at 100x concentration, or at a different temperature? Example: single-molecule resolution instead of bulk measurement.
-
Put to other use: Apply an existing tool or finding to a different purpose. What other questions could this dataset answer? What other diseases could this drug treat? Example: repurpose failed drug candidates for new indications.
-
Eliminate: Remove a step, component, or constraint. What if you eliminated the purification step? The control group? The assumption of linearity? Example: label-free detection instead of fluorescent tagging.
-
Reverse: Invert the order, direction, or perspective. What if you worked backwards from the output? Reversed the cause-effect relationship? Example: start from the phenotype and work backwards to the genotype.
TRIZ Core Concepts
TRIZ (Theory of Inventive Problem Solving) provides three key concepts for scientific brainstorming:
-
Technical Contradiction: Improving one parameter worsens another. Example: increasing drug selectivity (desired) reduces potency (undesired). TRIZ provides 40 inventive principles organized in a contradiction matrix to resolve such trade-offs systematically rather than by trial and error.
-
Ideal Final Result (IFR): Envision the perfect solution where the desired function is achieved with zero cost, zero harm, and zero complexity. Working backwards from the IFR reveals which constraints are real and which are assumed. Example: the ideal drug delivers itself precisely to the target, requires no administration, and has no side effects -- what existing technology gets closest?
-
Inventive Principles: The most commonly applicable principles in scientific research include:
- Segmentation: divide a system into independent parts
- Extraction: separate an interfering component or property
- Local Quality: vary conditions locally rather than globally
- Asymmetry: introduce useful asymmetry into a symmetric system
- Nesting: place one system inside another (e.g., nanoparticle within liposome)
- Prior Action: perform required changes in advance (e.g., pre-functionalization)
- Dynamization: allow a system to change to achieve optimal conditions at each stage
Biomimicry Process Steps
The Biomimicry design spiral follows five steps:
- Define: What function do you need? Frame the problem as a verb, not a noun. "How does nature filter?" not "How does nature make a filter?"
- Biologize: Translate the function into biological terms. "What organisms need to separate particles from fluid?"
- Discover: Search for organisms that have solved this problem. Use resources like AskNature.org or consult biological literature.
- Abstract: Extract the design principle from the biological model. Not "copy the kidney" but "use countercurrent flow with selective permeability membranes."
- Apply: Translate the abstracted principle back to the research problem. Design the experiment or technology using the biological principle as inspiration.
Method Categories
Brainstorming methods serve three distinct cognitive functions. Effective sessions use methods from multiple categories:
- Divergent methods expand the idea space: SCAMPER, Provocation, Random Input, Future Backwards. Use these when you need more options or feel stuck with too few ideas. These methods deliberately break existing thought patterns.
- Connecting methods find relationships between ideas: Morphological Analysis, Biomimicry, Reverse Assumptions. Use these when you have elements that might relate but have not identified how. These methods build structure across disparate concepts.
- Convergent methods evaluate and select: Six Thinking Hats, TRIZ. Use these when you have many ideas and need to identify which are most promising or how to resolve contradictions. These methods apply systematic judgment.
A complete brainstorming session should use at least one method from each category, typically in the order: divergent first, connecting second, convergent third.
Method Combinations
Certain methods pair well for deeper exploration. The key principle is to combine methods from different cognitive categories (divergent + connecting, or connecting + convergent):
-
SCAMPER + Six Hats: Generate modifications with SCAMPER (divergent), then evaluate each modification from six perspectives (convergent). Particularly effective for experimental protocol refinement.
-
Morphological Analysis + TRIZ: Map the design space with Morphological Analysis (connecting), then use TRIZ (convergent) to resolve contradictions that emerge in promising but conflicting combinations.
-
Biomimicry + Provocation: Use Biomimicry (connecting) to find natural solutions, then apply Provocation (divergent) to push beyond biological constraints into engineered solutions that nature has not explored.
-
Reverse Assumptions + Future Backwards: Challenge current assumptions first (connecting), then project forward from those reversed assumptions to envision alternative research trajectories (divergent).
-
Random Input + Morphological Analysis: Use Random Input (divergent) to discover a new dimension for the morphological matrix (connecting) that was not previously considered. This often reveals blind spots in the design space.
-
Three-method sequence (recommended for full sessions): Start with a divergent method (SCAMPER or Provocation, 15 min), then a connecting method (Morphological Analysis or Biomimicry, 15 min), then a convergent method (Six Hats or TRIZ, 15 min). This ensures all cognitive modes are exercised.
Decision Framework
Select a brainstorming method based on your primary creative challenge:
What is your brainstorming challenge?
├── Stuck / no new ideas coming
│ ├── Need a completely fresh perspective → Provocation Technique
│ └── Need external stimulus → Random Input
├── Improving an existing method or system
│ ├── Incremental improvements → SCAMPER
│ └── Hit a technical contradiction → TRIZ
├── Exploring a design space systematically
│ ├── Known dimensions, many options → Morphological Analysis
│ └── Unknown dimensions, need inspiration → Biomimicry
├── Need multiple perspectives on a decision
│ └── → Six Thinking Hats
├── Questioning fundamental assumptions
│ └── → Reverse Assumptions
└── Planning long-term research direction
└── → Future Backwards
| Challenge | Primary Method | Secondary Method | Rationale |
|---|---|---|---|
| No new ideas, feeling stuck | Provocation | Random Input | Break fixation with impossible premises or external stimuli |
| Ideas feel too safe or obvious | Reverse Assumptions | Biomimicry | Challenge defaults; look outside the discipline for solutions |
| Too many ideas, need to select | Six Thinking Hats | TRIZ | Structured multi-perspective evaluation; contradiction resolution |
| Improving an existing protocol | SCAMPER | Morphological Analysis | Systematic modification operators; design space mapping |
| Exploring interdisciplinary connections | Biomimicry | Random Input | Nature-inspired patterns; forced associations across domains |
| Resolving "improve X but Y worsens" | TRIZ | Six Thinking Hats | Contradiction resolution principles; multi-angle evaluation |
| Mapping all possible combinations | Morphological Analysis | SCAMPER | Dimension-option matrices; systematic variation |
| Long-term vision and roadmapping | Future Backwards | Reverse Assumptions | Work from solved future; challenge what seems fixed |
| Energy or motivation lagging | Random Input | Provocation | Novel stimuli re-engage creative thinking |
Best Practices
-
Start with context understanding before diverging: Spend adequate time understanding the problem space, existing literature, and constraints before generating ideas. Ideas generated without context tend to reinvent existing solutions or violate known constraints. Allocate at least 20% of session time to context mapping.
-
Use "Yes, and..." to build on ideas: When an idea is proposed, extend it before evaluating it. "Yes, and we could also..." keeps the creative momentum. Premature "but" statements shut down exploration before the idea space is fully mapped.
-
Balance divergent and convergent phases: Alternate between generating ideas (divergent) and evaluating them (convergent). Do not mix the two simultaneously -- generating and judging at the same time reduces both quantity and quality of ideas. Explicitly signal phase transitions: "We are now switching from idea generation to evaluation."
-
Cross-domain analogies unlock novel connections: The most innovative scientific ideas often come from applying principles from one domain to another. Deliberately seek analogies from biology, engineering, physics, mathematics, or even social systems. Biomimicry and Random Input methods formalize this practice. Keep a personal catalog of interesting mechanisms from outside your field.
-
Combine methods for deeper exploration: No single method covers all cognitive modes. Use a divergent method (SCAMPER, Provocation) followed by a connecting method (Morphological Analysis) followed by a convergent method (Six Hats, TRIZ). Three-method sequences produce richer results than single-method sessions.
-
Document ideas immediately: Capture every idea during the session, even ones that seem weak. Ideas that appear marginal during brainstorming often become valuable when revisited with fresh perspective. Use structured formats (idea + rationale + potential test) rather than bare lists. A structured record also prevents repeating the same brainstorming ground in future sessions.
-
Know when to switch methods: If a method stops producing new ideas after 10-15 minutes, switch to a different method rather than forcing it. Method fatigue is real -- the same cognitive frame produces diminishing returns. Signs to switch: repeated similar ideas, long silences, circling back to previously stated ideas, or frustration with the method's constraints.
-
End with synthesis, not just a list: Every brainstorming session should conclude with clustering related ideas, identifying the top 3-5 most promising directions, and defining concrete next steps (literature search, feasibility check, preliminary experiment). An unsynthesized idea list rarely leads to action.
Common Pitfalls
-
Evaluating too early: Critiquing ideas during the divergent phase kills creative momentum. Participants self-censor to avoid criticism, reducing both the quantity and novelty of ideas generated.
- How to avoid: Explicitly separate divergent (generation) and convergent (evaluation) phases. During divergent phases, the only permitted response is "Yes, and..." or "What if we also..." Record all ideas without judgment.
-
Staying in the comfort zone: Defaulting to familiar methods and familiar idea spaces. Scientists tend to brainstorm within their own domain using their established mental models, which produces incremental rather than transformative ideas.
- How to avoid: Deliberately use at least one unfamiliar method per session. If you always use SCAMPER, try Biomimicry or Provocation. Include at least one cross-domain analogy in every session. Read outside your field regularly.
-
Skipping the context phase: Jumping directly into idea generation without understanding the problem space, existing solutions, and constraints. This wastes time rediscovering known solutions and produces ideas that have already been tried.
- How to avoid: Always begin with Phase 1 (Understand Context) from the Workflow below. Summarize the current state of knowledge, key constraints, and what has already been tried before generating new ideas.
-
Forcing a method when it is not working: Persisting with a method that is not generating results because of sunk-cost fallacy or belief that the method should work for this problem type.
- How to avoid: Set a time limit (15-20 minutes) per method. If ideas are not flowing, switch methods without guilt. The Decision Framework above helps select an alternative method based on the specific challenge type.
-
Focusing on quantity without connection-making: Generating a long list of disconnected ideas without identifying patterns, themes, or combinations. A list of 50 unrelated ideas is less useful than 15 ideas organized into 3 coherent research directions.
- How to avoid: After each divergent burst, spend time on connection-making (Phase 3 in the Workflow). Use affinity mapping: group related ideas, name the clusters, look for bridges between clusters.
-
Treating brainstorming as a monologue: One person (or one AI) generating ideas while others passively listen. This misses the combinatorial power of multiple perspectives interacting and building on each other.
- How to avoid: Structure the session for dialogue. Alternate who proposes and who extends. Use Six Thinking Hats to ensure everyone contributes from different angles. In AI-assisted brainstorming, provide your own ideas and domain expertise rather than only receiving suggestions.
-
No follow-through after the session: Generating exciting ideas but never converting them into concrete research actions. Brainstorming without follow-through is intellectual entertainment, not research methodology.
- How to avoid: Always complete Phase 5 (Synthesis and Next Steps). Assign specific follow-up actions (literature search, feasibility analysis, preliminary experiment design) with deadlines. Revisit the brainstorming output within one week.
Workflow
Phase 1: Understand Context (15-20% of session time)
- Map the problem space: Define the research question, current state of knowledge, key constraints (time, budget, equipment, expertise), and what solutions have already been attempted. Identify the specific gap or challenge that brainstorming aims to address.
- Review prior work: Briefly summarize what is already known. What approaches have been tried? What failed and why? What are the current leading hypotheses? This prevents reinventing the wheel during divergent exploration.
- Identify the creative challenge type: Is the problem about being stuck, improving something existing, exploring systematically, resolving contradictions, or something else? Use the Decision Framework to classify.
- Select initial method(s): Choose 1-2 brainstorming methods based on the creative challenge type. Have a backup method ready in case the first does not produce results.
- Transition criterion: Proceed to Phase 2 when the problem is clearly defined, the knowledge landscape is mapped, and at least one method is selected.
Phase 2: Divergent Exploration (30-40% of session time)
- Apply the selected method systematically: Work through the chosen brainstorming method completely. For SCAMPER, apply all 7 operators to the problem. For Morphological Analysis, define all dimensions before exploring combinations. For Biomimicry, complete the full define-biologize-discover-abstract-apply cycle. Do not shortcut the method.
- Capture all ideas: Document every idea with a brief rationale, even seemingly weak ones. Use structured format: idea statement, connection to the problem, potential testability. Quantity matters in this phase -- aim for 15-30 raw ideas per session.
- Switch methods if needed: If ideas plateau after 15 minutes, switch to the backup method or select a new one from the Decision Framework. Plateaus are normal and expected -- they signal that one cognitive frame has been exhausted, not that the problem lacks solutions.
- Transition criterion: Proceed to Phase 3 when you have at least 10-15 distinct ideas, or when two methods have been exhausted.
Phase 3: Connection Making (15-20% of session time)
- Cluster related ideas: Group ideas by theme, mechanism, or approach. Name each cluster with a descriptive label. Typical sessions produce 3-6 clusters. Ideas that do not fit any cluster may represent novel directions worth exploring.
- Identify bridges between clusters: Look for ideas that connect two or more clusters. These bridging ideas often represent the most innovative directions because they combine multiple insights from different cognitive frames.
- Generate hybrid ideas: Deliberately combine elements from different clusters. Use Morphological Analysis on the clusters themselves: pick one element from cluster A, one from cluster B, and explore the combination. Often the most promising research direction is not any single idea but a synthesis of ideas from multiple clusters.
- Transition criterion: Proceed to Phase 4 when ideas are organized into clusters with identified connections and at least 2-3 hybrid ideas generated.
Phase 4: Critical Evaluation (15-20% of session time)
- Apply feasibility filters: For each promising idea or cluster, assess: Is it testable with available methods? Are the required resources accessible? What is the approximate timeline? What is the risk of failure? Rank ideas by feasibility and potential impact.
- Use Six Thinking Hats for top candidates: For the 3-5 most promising directions, systematically evaluate from all six perspectives. White: what data exists? Red: what feels right or wrong about this? Black: what could go wrong? Yellow: what is the best outcome? Green: what variations exist? Blue: how should we proceed?
- Identify contradictions and resolve with TRIZ: If promising ideas have inherent contradictions (improving one aspect worsens another), apply TRIZ inventive principles to find resolution strategies. Many apparently impossible ideas become feasible when the underlying contradiction is explicitly identified and resolved.
- Transition criterion: Proceed to Phase 5 when top 3-5 ideas are evaluated and ranked by feasibility and potential impact.
Phase 5: Synthesis and Next Steps (10-15% of session time)
- Prioritize directions: Select 2-3 research directions from the evaluated ideas. For each, articulate: the core idea, why it is promising, what would need to be true for it to work, and what the first experiment or investigation would be.
- Define concrete follow-up actions: For each prioritized direction, specify: (a) literature search terms to check novelty and prior work, (b) preliminary feasibility experiments or pilot studies, (c) key assumptions that need validation before full commitment, and (d) timeline for initial investigation.
- Document the session: Record the full brainstorming output: all ideas generated (including discarded ones), clusters identified, evaluation results, and prioritized directions with next steps. This document serves as a reference for future sessions and prevents re-exploration of already-considered ideas.
- Completion criterion: Session is complete when 2-3 prioritized directions have defined next steps with owners and timelines.
Adaptive Strategies
When the session is not producing results, apply these interventions:
-
If stuck with no ideas: Switch from analytical methods (SCAMPER, Morphological Analysis) to provocative methods (Provocation, Random Input). The block is usually caused by analytical thinking inhibiting creative thinking. A single absurd provocation often breaks the logjam.
-
If ideas feel too safe or incremental: Increase the challenge level. Use Reverse Assumptions to flip the most fundamental assumption in the problem. Ask "what would a Nobel Prize-winning solution look like?" to raise the aspiration level. Apply TRIZ Ideal Final Result to envision the perfect outcome unconstrained by current limitations.
-
If energy or engagement is dropping: Switch to a more interactive method. Random Input is particularly effective because each random stimulus creates a fresh starting point. Alternatively, take a brief break and return with Biomimicry -- exploring nature's solutions is inherently engaging.
-
If generating many ideas but they feel disconnected: Pause divergent generation and spend time in Phase 3 (Connection Making). Map the ideas generated so far, look for clusters and bridges. Often the best idea is a combination of two mediocre ideas from different clusters.
-
If one person is dominating the session: Impose structure with Six Thinking Hats, which forces rotation through perspectives. Alternatively, use silent brainstorming: each participant writes ideas independently for 5 minutes, then shares and builds on each other's written ideas.
Further Reading
- Lateral Thinking by Edward de Bono -- foundational work on deliberate creativity techniques including the Provocation (Po) method and Six Thinking Hats
- TRIZ: Theory of Inventive Problem Solving -- comprehensive resource for contradiction matrices, inventive principles, and ideal final result methodology
- Biomimicry Institute -- resources for nature-inspired design including the AskNature database of biological strategies
- Morphological Analysis (Fritz Zwicky) -- systematic exploration of multi-dimensional solution spaces, originally developed for astrophysics and engineering
- SCAMPER technique (Bob Eberle) -- structured checklist for creative modification of existing products, processes, or methods
Related Skills
- hypothesis-generation -- downstream from brainstorming; converts prioritized ideas into formal, testable hypotheses with experimental designs
- scientific-manuscript-writing -- downstream from ideation; structures brainstorming outcomes into research proposals and manuscripts
- peer-review-methodology -- provides critical evaluation frameworks that complement the Critical Evaluation phase of brainstorming
skills/scientific-writing/scientific-critical-thinking/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-critical-thinking -g -y
SKILL.md
Frontmatter
{
"name": "scientific-critical-thinking",
"license": "CC-BY-4.0",
"description": "Evaluating scientific evidence and claims. Covers study design hierarchy (RCT to expert opinion), effect sizes (OR, RR, NNT, Cohen's d), confounding, p-value vs clinical significance, GRADE quality assessment, reproducibility, and bias types (selection, information, confounding, reporting). Use when reading a paper or assessing claims."
}
Scientific Critical Thinking: Evaluating Evidence and Claims
Overview
Scientific critical thinking is the disciplined application of logical and methodological standards to evaluate whether a study's design, analysis, and interpretation support its conclusions. It is the skill that separates a researcher who synthesizes evidence from one who accumulates it. This guide covers the hierarchy of evidence, the mechanics of common biases, effect size interpretation, the p-value controversy, GRADE evidence grading, and common logical fallacies in the interpretation of scientific literature.
Key Concepts
1. Study Design Hierarchy
Study designs vary in their ability to support causal inference. The hierarchy below applies to questions about the effect of an intervention or exposure on an outcome:
Systematic reviews and meta-analyses of RCTs (highest causal certainty)
↓
Randomized Controlled Trials (RCTs)
↓
Non-randomized controlled trials / cluster-randomized trials
↓
Prospective cohort studies (follow exposure → outcome forward in time)
↓
Retrospective cohort studies
↓
Case-control studies (compare exposed vs. unexposed given outcome)
↓
Cross-sectional studies (measure exposure and outcome simultaneously)
↓
Case series and case reports
↓
Expert opinion, mechanistic reasoning, animal models (lowest causal certainty)
Important exceptions: For questions about rare outcomes, case-control designs are often more efficient than cohort studies. For questions about diagnostic accuracy, randomized designs are usually inappropriate — cross-sectional or cohort designs with verified reference standards are preferred. For harm questions, RCTs are often infeasible (ethical constraints), making large cohort studies the best available evidence.
2. Effect Measures and Their Interpretation
Effect measures quantify the relationship between an exposure/intervention and an outcome. Confusing them is a leading source of misinterpretation.
| Measure | Formula | Use case | Key interpretation |
|---|---|---|---|
| Risk Ratio (RR) | Risk in exposed / Risk in unexposed | Cohort studies, RCTs | RR = 2.0: exposed group has twice the risk |
| Odds Ratio (OR) | Odds in exposed / Odds in unexposed | Case-control studies, logistic regression | Approximates RR when outcome is rare (<10%); overestimates RR for common outcomes |
| Hazard Ratio (HR) | Instantaneous event rate ratio | Survival analysis (Cox regression) | HR = 0.7: 30% lower hazard of event per time unit in treated group |
| Number Needed to Treat (NNT) | 1 / Absolute Risk Reduction | Clinical decision-making | NNT = 20: treat 20 patients to prevent 1 event |
| Absolute Risk Reduction (ARR) | Risk_control − Risk_treated | Clinical impact | ARR = 2%: intervention reduces absolute event rate by 2 percentage points |
| Cohen's d | (μ₁ − μ₂) / σ_pooled | Continuous outcomes, psychology | d = 0.2 small; 0.5 medium; 0.8 large |
| Pearson r | Correlation coefficient | Association, not causal | r = 0.1 small; 0.3 medium; 0.5 large (Cohen 1988) |
Common error: Reporting only the relative risk reduction (e.g., "50% reduction in risk") without the absolute risk reduction. A treatment that reduces risk from 2% to 1% has a 50% relative reduction but only 1% absolute reduction (NNT = 100). The relative measure appears more impressive but the absolute measure is clinically relevant.
3. Bias Types
Bias is systematic deviation of results or inferences from the truth. Unlike random error (reduced by larger samples), bias is directional and not correctable by increasing sample size.
Selection bias: Systematic difference in characteristics between those selected and not selected for study.
- Examples: Healthy worker effect (workers healthier than general population), loss to follow-up bias (sicker patients drop out), volunteer bias
- Detection: Compare baseline characteristics of included vs. excluded participants; assess attrition patterns
Information bias: Systematic error in measuring exposure or outcome.
- Recall bias: Cases remember exposure better than controls (especially case-control studies for rare diseases)
- Observer bias: Assessors aware of exposure status rate outcomes differently
- Detection: Look for blinding of outcome assessors; validated measurement instruments; objective vs. self-reported outcomes
Confounding: A variable associated with both the exposure and the outcome, creating a spurious or masked association.
- Positive confounding: Confounder inflates the observed association
- Negative confounding: Confounder masks a true association
- Detection: Compare crude and adjusted effect estimates; large change (>10%) indicates confounding
- Control methods: Randomization (RCTs), multivariable adjustment, propensity score methods, restriction, matching
Reporting bias: Selective reporting of outcomes or results based on their statistical significance or direction.
- Publication bias: Positive results are published; null results are not
- Outcome reporting bias: Predefined outcomes not reported if non-significant; unplanned analyses reported if significant
- Detection: Compare registered protocol vs. published outcomes; funnel plot asymmetry for meta-analyses
4. The P-value and Statistical vs. Clinical Significance
A p-value is the probability of observing results at least as extreme as those obtained, under the null hypothesis. It is NOT the probability that the null hypothesis is true, nor the probability that the finding is a false positive.
Correct interpretation: p < 0.05 means that, if the null hypothesis were true, fewer than 5% of equally designed studies would produce results this extreme or more. It does NOT indicate the effect is large, clinically meaningful, or replicable.
Statistical significance ≠ clinical significance: With large enough samples, even trivially small effects become statistically significant. A blood pressure drug that reduces systolic BP by 0.8 mmHg (95% CI 0.3–1.3, p = 0.001) is statistically significant but clinically irrelevant.
Confidence intervals are more informative than p-values: A 95% CI of [0.5 kg, 35 kg weight loss] and a 95% CI of [0.5 kg, 1.5 kg weight loss] can both have p < 0.05, but the clinical implications are vastly different. Always focus on CI width and range, not just whether it excludes the null.
5. GRADE Evidence Certainty
GRADE classifies the certainty of evidence across four levels:
| GRADE level | Meaning | Typical starting point |
|---|---|---|
| High | Further research very unlikely to change confidence | Consistent RCTs, large effect, no bias |
| Moderate | Further research likely to have important impact | RCTs with limitations, or strong consistent observational |
| Low | Further research very likely to have important impact | Observational studies, or RCTs with serious limitations |
| Very low | Any estimate is very uncertain | Case series, expert opinion, very inconsistent results |
GRADE certainty can be downgraded for: risk of bias, inconsistency (heterogeneity), indirectness (different population/outcome), imprecision (wide CI), and publication bias. It can be upgraded for: large effect (OR > 5), dose-response relationship, or all plausible confounders would reduce the effect.
Decision Framework
How should I evaluate this study?
│
├── Step 1: What question is being answered?
│ ├── Intervention effectiveness → Need RCT or high-quality cohort
│ ├── Diagnostic accuracy → Need cross-sectional vs reference standard
│ ├── Prognosis → Need prospective cohort
│ └── Harm / rare exposure → Case-control or large cohort acceptable
│
├── Step 2: Is the study design appropriate?
│ ├── Design matches question → Proceed
│ └── Mismatch → Major limitation (flag)
│
├── Step 3: What are the key threats to validity?
│ ├── Selection bias → Who was included/excluded? Loss to follow-up?
│ ├── Information bias → Blinding? Validated instruments?
│ └── Confounding → What was adjusted for? Residual confounders?
│
├── Step 4: Are the effect estimates clinically meaningful?
│ ├── Effect size large enough to matter clinically?
│ ├── CI narrow enough to be informative?
│ └── Absolute vs relative risk reported?
│
└── Step 5: How certain is the evidence overall? (GRADE)
├── High certainty → Confident conclusion
├── Moderate certainty → Likely true; note limitations
├── Low certainty → Uncertain; more research needed
└── Very low certainty → Cannot draw conclusions
| Claim type | Appropriate response | Red flags requiring skepticism |
|---|---|---|
| "Drug X reduces mortality by 50%" | Ask: 50% relative or absolute? What was baseline risk? | Only relative risk reported; no CI provided |
| "Observational study shows cause" | Downgrade to "association"; list plausible confounders | Authors use "causes" without adjustment |
| "Significant p-value proves effect" | Check effect size and CI; assess clinical relevance | p = 0.04 with N = 50,000 and tiny effect |
| "Single RCT is definitive" | Check for replication; assess risk of bias | Funded by manufacturer; no blinding |
| "Preprint shows breakthrough" | Await peer review; check for reproducibility | No data/code sharing; sensational press release |
| "N-of-1 case report demonstrates treatment" | Note limited generalizability; no control | Used to support policy without cohort evidence |
Best Practices
-
Read the Methods before the Results: The Discussion section is written by the authors to support their conclusions. The Methods section is where you independently assess whether the data can support those conclusions. Specifically: what were the pre-specified primary outcomes? Do the reported outcomes match those in the Methods or the registered protocol?
-
Always seek the pre-registration record: ClinicalTrials.gov, PROSPERO, and OSF registrations contain the original protocol. Comparing the pre-specified primary outcome to what was reported in the abstract is the single most efficient check for outcome reporting bias. A change in primary outcome without explanation is a major red flag.
-
Distinguish statistical and clinical significance explicitly: For every effect estimate, ask: if this effect is real, would it matter to a patient or a biological system? A genomic variant with OR = 1.05 (p = 1e-12) in a GWAS of 500,000 people is a genuine association but contributes negligibly to disease risk prediction.
-
Identify the funding source and conflicts of interest: Industry-funded trials are not automatically invalid, but industry funding is associated with more favorable outcomes for the sponsor's product. Assess whether conflicts are disclosed, whether the funder had access to data or participated in analysis, and whether an independent statistician reviewed the data.
-
Check for multiple testing without correction: When a paper tests 20 outcomes, 1 will be statistically significant at p < 0.05 by chance alone. Look for corrections (Bonferroni, Benjamini-Hochberg FDR) in genomics, proteomics, and other high-throughput studies. Absence of correction in a paper reporting 50 comparisons invalidates the significance claims.
-
Require absolute risk data before accepting clinical conclusions: For any binary outcome, request (or calculate) the absolute risk reduction (ARR) and number needed to treat (NNT) in addition to the relative risk. This applies to both journal articles and news coverage of medical research. ARR = Control_rate − Treatment_rate; NNT = 1/ARR.
-
Apply structured critical appraisal checklists appropriate to study design: CONSORT for RCTs, STROBE for observational studies, TRIPOD for prediction models, STARD for diagnostic accuracy, GRADE for certainty assessment. These checklists are available free from EQUATOR Network and identify every element required for a complete report.
Common Pitfalls
-
Confusing association with causation in observational studies: Observational studies identify associations, not causes. Coffee drinking is associated with reduced colorectal cancer risk — but coffee drinkers differ from non-drinkers in dozens of ways (diet, activity, socioeconomic status), any of which could explain the association.
- How to avoid: Apply Bradford Hill criteria (strength, consistency, specificity, temporality, biological gradient, plausibility, coherence, experiment, analogy) to assess whether an observed association is likely causal. No single criterion is sufficient.
-
Over-interpreting subgroup analyses: Subgroup analyses in RCTs are almost always exploratory. A trial powered to detect an overall treatment effect cannot reliably detect subgroup-specific effects. Subgroup results are hypothesis-generating, not confirmatory.
- How to avoid: Only trust pre-specified subgroup analyses conducted with formal tests of interaction (not just reporting significance within each subgroup separately). Post-hoc subgroup analyses reported without interaction tests should be treated as hypothesis-generating only.
-
Accepting surrogate endpoints as equivalent to clinical endpoints: A drug that improves an imaging biomarker (e.g., amyloid PET) does not necessarily improve clinical outcomes (e.g., cognitive function). The history of medicine is filled with interventions that improved surrogate markers and worsened or did not affect hard endpoints.
- How to avoid: Ask whether the surrogate endpoint has been validated as a reliable predictor of the clinical endpoint in prior trials. If not, treat it as preliminary evidence only.
-
Ignoring the healthy survivor / healthy adherer bias: Patients who adhere to a treatment regimen tend to be healthier, more health-conscious, and have better outcomes even in the absence of a real treatment effect. This creates a spurious association between adherence and outcomes in observational data.
- How to avoid: When evaluating observational studies of drug adherence or screening behavior, look for analyses comparing adherent groups across different interventions or using intention-to-treat principles.
-
Anchoring on statistical significance and ignoring effect precision: A single small study with p = 0.03 and a wide 95% CI (OR: 0.5 to 5.0) provides essentially no information — the true effect could be large harm or large benefit. The CI incompleteness makes the result uninterpretable.
- How to avoid: Evaluate CI width alongside the p-value. A result is informative only when the CI excludes both no effect and clinically irrelevant effects. Very wide CIs signal underpowered studies.
-
Treating p = 0.049 and p = 0.051 as fundamentally different: The binary significant/non-significant threshold creates a cliff where results just below 0.05 are published and celebrated, while results just above are buried. This contributes to the replication crisis.
- How to avoid: Interpret continuous p-values with context — p = 0.049 and p = 0.051 provide nearly identical evidence. Focus on effect size, CI, and consistency with prior evidence. Report exact p-values; avoid "statistically significant" language where possible.
-
Dismissing animal and in vitro studies entirely, or accepting them uncritically: Mechanistic studies in cells and animals are necessary for discovery but have high rates of failure in human translation. Approximately 85% of findings that replicate in animals fail in human clinical trials.
- How to avoid: Weight animal/in vitro evidence as hypothesis-generating only. Ask whether the animal model is validated for the specific human condition; whether pharmacokinetic properties translate; and whether multiple independent labs have replicated the finding.
Workflow
-
Initial orientation (5 minutes)
- Read abstract; identify the study design
- Check correspondence between Methods and Results sections for outcome consistency
- Check for pre-registration (ClinicalTrials.gov, PROSPERO, OSF) and compare registered outcomes to reported outcomes
-
Methods evaluation
- Assess appropriateness of study design for the research question
- Identify inclusion/exclusion criteria and assess representativeness of the sample
- Evaluate exposure/outcome measurement: objective vs. self-reported; validated instruments; blinding
-
Results evaluation
- Extract primary outcome effect estimates with 95% CI and p-value
- Calculate or request absolute risk data (ARR, NNT) if only relative measures reported
- Assess whether confidence intervals are narrow enough to draw conclusions
- Review confounding adjustment: were relevant confounders measured and included?
-
Bias and quality assessment
- Apply appropriate structured tool (RoB 2, ROBINS-I, QUADAS-2, NOS)
- Check for multiple testing; count the number of comparisons in the paper
- Review funding source and conflicts of interest disclosures
-
Conclusion and certainty rating
- Assign GRADE certainty (high/moderate/low/very low)
- Write a 2–3 sentence summary: what the study found, how certain, what limitations apply
Further Reading
- EQUATOR Network reporting guidelines — all major reporting checklists (CONSORT, STROBE, PRISMA, STARD, TRIPOD)
- Cochrane Handbook — bias assessment tools — RoB 2, ROBINS-I, and GRADE methods
- GRADE Working Group — evidence certainty framework and training materials
- Ioannidis JPA (2005) Why most published research findings are false. PLoS Med 2:e124 — foundational paper on false discovery rates in research
- Greenland S et al. (2016) Statistical tests, P values, confidence intervals, and power. Eur J Epidemiol 31:337-350 — authoritative guide to p-value interpretation
Related Skills
literature-review— applying critical appraisal systematically across a body of evidencebiostatistics— quantitative tools for calculating and interpreting effect measurespeer-review-methodology— structured application of critical thinking to manuscript review
skills/scientific-writing/scientific-literature-search/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-literature-search -g -y
SKILL.md
Frontmatter
{
"name": "scientific-literature-search",
"license": "CC-BY-4.0",
"description": "Systematic strategies for searching scientific literature across PubMed, arXiv, Google Scholar, and AI-assisted tools. Covers PICO framework for clinical questions, three-tiered search (database-specific, AI-assisted, content extraction), PubMed field tags and MeSH, boolean query construction, and full-text extraction. Use when planning a literature search or choosing a search tier."
}
Scientific Literature Search
Overview
Scientific literature search is the foundation of evidence-based research. A well-executed search maximizes recall (finding all relevant papers) while maintaining precision (avoiding irrelevant results). This guide provides a systematic approach that combines database-specific query strategies, AI-assisted synthesis, and direct content extraction, organized into a three-tiered framework that scales from targeted lookups to comprehensive landscape reviews.
Key Concepts
The PICO Framework
For clinical and biomedical questions, structure queries using the PICO framework:
- P (Population): Who are you studying? (e.g., "Diabetes Mellitus"[MeSH])
- I (Intervention): What treatment or exposure? (e.g., "Metformin"[MeSH])
- C (Comparison): What is the alternative? (e.g., placebo, standard care)
- O (Outcome): What result are you measuring? (e.g., "Cardiovascular Diseases"[MeSH])
PICO queries can be combined with publication type filters to target specific evidence levels:
"Diabetes Mellitus"[MeSH] AND "Metformin"[MeSH] AND "Cardiovascular Diseases"[MeSH] AND ("clinical trial"[Publication Type] OR "meta-analysis"[Publication Type])
Three-Tiered Search Strategy
Literature search is most effective when approached in tiers of increasing breadth:
Tier 1 -- Database-Specific Searches (Most Reliable)
Query established academic databases (PubMed, arXiv, Google Scholar) for peer-reviewed, indexed content. This is the most reliable tier and should always be the starting point.
- PubMed (via Biopython
Bio.Entrez): Primary database for biomedical and life science literature. Supports MeSH controlled vocabulary and advanced field tags. - arXiv (via the
arxivpackage): Preprint server for physics, mathematics, computer science, and quantitative biology. Results appear faster than peer-reviewed journals. - Google Scholar (via the
scholarlypackage): Broadest coverage across all academic disciplines. Note: has aggressive rate limits on automated queries.
Best for: finding specific papers, systematic reviews, clinical evidence, preprints.
Tier 2 -- AI-Assisted Web Search (Comprehensive)
Use the Claude API with the web_search_20250305 server-side tool to synthesize broader context, identify research trends, and surface recent developments not yet indexed in databases. Also use general web search (e.g. via the duckduckgo-search package) for protocols, tutorials, and software documentation.
Best for: understanding the research landscape, complex multi-faceted questions, finding recent developments, identifying key researchers.
Avoid for: specific paper lookups (use Tier 1), citation counts (use Google Scholar), systematic reviews requiring reproducibility, searches where exact query terms must be documented.
Tier 3 -- Direct Content Extraction (Deep Dive)
Extract and analyze full-text content, PDFs, and supplementary materials from identified papers using trafilatura (HTML article extraction), pypdf (PDF text), and the Crossref API (DOI → supplementary file URLs).
Best for: detailed methodology extraction, data retrieval, protocol identification, supplementary data access.
PubMed Field Tags
PubMed supports field-specific searching to improve precision:
| Tag | Description | Example |
|---|---|---|
[MeSH] |
Medical Subject Heading (controlled vocabulary) | "Neoplasms"[MeSH] |
[Title] |
Title field only | "CRISPR"[Title] |
[Title/Abstract] |
Title or abstract | "gene therapy"[Title/Abstract] |
[Author] |
Author name | "Zhang F"[Author] |
[Journal] |
Journal name | "Nature"[Journal] |
[Publication Type] |
Article type filter | "Review"[Publication Type] |
[Date - Publication] |
Publication date range | "2020/01/01"[Date - Publication]:"2024/12/31"[Date - Publication] |
[MeSH Major Topic] |
MeSH term as major focus of the article | "CRISPR-Cas Systems"[MeSH Major Topic] |
Boolean Operators
Boolean operators control how search terms combine:
# AND: All terms must be present -- narrows results
results = query_pubmed("CRISPR AND cancer AND therapy")
# OR: Any term can be present -- broadens results (use for synonyms)
results = query_pubmed("(tumor OR tumour OR neoplasm) AND immunotherapy")
# NOT: Exclude terms -- use sparingly to avoid losing relevant papers
results = query_pubmed("cancer immunotherapy NOT review")
Use parentheses to group OR terms together before combining with AND.
arXiv Subject Categories
arXiv organizes preprints by subject category. Biology-related categories include:
| Category | Description |
|---|---|
q-bio.BM |
Biomolecules |
q-bio.CB |
Cell Behavior |
q-bio.GN |
Genomics |
q-bio.MN |
Molecular Networks |
q-bio.NC |
Neurons and Cognition |
q-bio.QM |
Quantitative Methods |
cs.AI |
Artificial Intelligence |
cs.LG |
Machine Learning |
Decision Framework
Use this tree to determine which search tier and database to start with:
What type of question are you answering?
├── Clinical / biomedical question
│ ├── Specific drug or treatment → Tier 1: PubMed with PICO query
│ ├── Disease mechanism → Tier 1: PubMed with MeSH terms
│ └── Clinical trial evidence → Tier 1: PubMed filtered by Publication Type
├── Computational / quantitative methods
│ ├── ML model or algorithm → Tier 1: arXiv (cs.LG, cs.AI)
│ ├── Computational biology method → Tier 1: arXiv (q-bio.*) + PubMed
│ └── Software tool or pipeline → Tier 2: AI-assisted web search
├── Broad research landscape
│ ├── Current state of a field → Tier 2: AI-assisted web search
│ ├── Recent developments (last 6 months) → Tier 2: AI-assisted web search
│ └── Cross-disciplinary question → Tier 1: Google Scholar + Tier 2
├── Specific paper or data
│ ├── Known paper details → Tier 1: any database by title/author/DOI
│ ├── Methodology or protocol → Tier 3: full-text extraction
│ └── Supplementary data → Tier 3: DOI-based supplementary fetch
└── Protocols / reagents
├── Lab protocol → Tier 2: web search for protocols.io, etc.
└── Validated reagents → Tier 2: AI-assisted web search
| Scenario | Recommended Tier and Database | Rationale |
|---|---|---|
| Systematic review of clinical evidence | Tier 1: PubMed with MeSH + publication type filters | Reproducible, documented search strategy required |
| Finding a preprint on a new ML method | Tier 1: arXiv with category and keyword search | Preprints appear on arXiv before journals |
| Understanding the research landscape | Tier 2: AI-assisted web search | Requires synthesis across many sources |
| Extracting a specific protocol from a paper | Tier 3: PDF content extraction | Need full-text access to methods section |
| Finding papers across disciplines | Tier 1: Google Scholar | Broadest coverage across fields |
| Identifying key researchers in a niche area | Tier 2: AI-assisted web search | Requires contextual synthesis |
| Downloading supplementary data tables | Tier 3: DOI-based supplementary fetch | Direct access to supplementary files |
Best Practices
-
Use controlled vocabulary (MeSH) for PubMed searches: Free-text searches miss papers that use different terminology. MeSH terms map synonyms to a single concept, improving recall without sacrificing precision.
# Free text misses synonyms query_pubmed("heart attack treatment") # MeSH captures all synonyms query_pubmed('"Myocardial Infarction"[MeSH] AND "Drug Therapy"[MeSH]') -
Include synonyms and alternative terms with OR: Scientific concepts often have multiple names (e.g., tumor/tumour/neoplasm). Group synonyms with OR inside parentheses to avoid missing relevant papers.
query_pubmed("(myocardial infarction OR heart attack) AND (treatment OR therapy)") -
Use phrase searching for multi-word concepts: Quoting exact phrases prevents the search engine from splitting terms and matching them independently.
query_pubmed('"single cell RNA sequencing" AND methods') -
Filter by publication type when seeking specific evidence: Clinical trials, systematic reviews, and meta-analyses each answer different questions. Use
[Publication Type]to target the evidence level you need.query_pubmed("COVID-19 vaccine efficacy AND clinical trial[Publication Type]") -
Start broad, then narrow iteratively: Begin with core concepts (2-3 terms) and review initial results. Add specificity based on what you find -- more terms, date ranges, field tags, or publication types.
# Step 1: Broad results = query_pubmed("CRISPR base editing iPSC", max_papers=20) # Step 2: Add MeSH and specificity results = query_pubmed( '"CRISPR-Cas Systems"[MeSH] AND "base editing" AND "induced pluripotent stem cells" AND efficiency', max_papers=20 ) # Step 3: Filter by date results = query_pubmed( '"CRISPR-Cas Systems"[MeSH] AND "base editing" AND "induced pluripotent stem cells" AND efficiency AND ("2022"[Date - Publication]:"2024"[Date - Publication])', max_papers=20 ) -
Cross-reference multiple databases: No single database covers all literature. Use PubMed for biomedical content, arXiv for computational preprints, and Google Scholar for cross-disciplinary coverage.
-
Assess result quality systematically: Evaluate papers for source reliability (peer-reviewed journal), author credentials, recency, study design appropriateness, sample size adequacy, reproducibility, declared conflicts of interest, and citation count.
Common Pitfalls
-
Overly long and specific queries: Packing too many terms into a single query causes missed results because all terms must match simultaneously.
- How to avoid: Limit queries to core concepts (3-5 terms). Run separate searches for sub-topics and combine results manually.
# Too specific -- misses relevant papers query_pubmed("CRISPR Cas9 gene editing HEK293T cells 2024 efficiency optimization delivery") # Better -- core concepts only query_pubmed("CRISPR Cas9 gene editing optimization efficiency") -
Relying on a single database: PubMed has biomedical focus, arXiv covers preprints, Google Scholar spans disciplines. Using only one database guarantees blind spots.
- How to avoid: Always search at least two databases. For computational biology, combine PubMed and arXiv. For cross-disciplinary topics, include Google Scholar.
-
Ignoring publication dates: Scientific knowledge evolves rapidly. Foundational papers remain relevant, but methods and clinical evidence may be superseded.
- How to avoid: Check publication dates in all results. For methods papers, prefer the last 3-5 years. For foundational concepts, older papers are acceptable but verify with recent reviews.
-
Skipping title and abstract review before deep-diving: Not all search results that match keywords are actually relevant. Downloading and reading full texts without screening wastes time.
- How to avoid: Always screen titles and abstracts first. Only extract full text (Tier 3) for papers that pass screening.
-
Using NOT operators too aggressively: The NOT operator can inadvertently exclude relevant papers that mention the excluded term in a different context.
- How to avoid: Use NOT sparingly. Prefer adding positive terms to narrow results rather than excluding terms. When you must use NOT, verify that excluded results are genuinely irrelevant.
-
Ignoring Google Scholar rate limits: Google Scholar aggressively rate-limits automated queries, which can block further searches.
- How to avoid: Use Google Scholar sparingly. Add delays between requests. Prefer PubMed or arXiv for bulk searching and reserve Google Scholar for cross-disciplinary checks.
-
Not documenting the search strategy: For systematic reviews and reproducible research, an undocumented search cannot be verified or reproduced.
- How to avoid: Record your search terms, databases queried, date ranges, and number of results at each stage. This is essential for systematic reviews and good practice for all searches.
Workflow
-
Step 1: Define the research question
- Identify the main concept, population/model, intervention/method, desired outcome, and time frame
- For clinical questions, map to the PICO framework
- Example: "Find recent papers on CRISPR base editing efficiency in human iPSCs" decomposes to: main concept = CRISPR base editing, model = human iPSCs, outcome = efficiency, time frame = last 3 years
-
Step 2: Construct and execute database queries (Tier 1)
- Start with PubMed for biomedical topics, arXiv for computational topics
- Begin with a broad query using 2-3 core terms
- Refine with MeSH terms, field tags, date filters, and publication type filters
from Bio import Entrez import arxiv from scholarly import scholarly Entrez.email = "your.email@example.com" # NCBI requires a contact email # PubMed: biomedical literature handle = Entrez.esearch( db="pubmed", term='"CRISPR-Cas Systems"[MeSH] AND "Gene Editing"[MeSH]', retmax=20, ) pubmed_ids = Entrez.read(handle)["IdList"] handle.close() # arXiv: computational biology preprints arxiv_results = list( arxiv.Search(query="protein structure prediction", max_results=10).results() ) # Google Scholar: broad cross-disciplinary coverage scholar_results = scholarly.search_pubs("single cell RNA sequencing analysis methods") -
Step 3: Supplement with AI-assisted search (Tier 2)
- Use AI-assisted web search for landscape overviews and recent developments
- Use general web search for protocols, tutorials, and documentation
from anthropic import Anthropic client = Anthropic() response = client.messages.create( model="claude-opus-4-7", max_tokens=4096, tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}], messages=[{ "role": "user", "content": "What are the latest developments in CAR-T cell therapy for solid tumors in 2024?", }], ) print(response.content) -
Step 4: Evaluate and filter results
- Screen titles and abstracts for relevance
- Prioritize by recency, journal quality, citation count, and study design
- For clinical evidence, prioritize RCTs, systematic reviews, and meta-analyses
- For methods, prioritize protocol papers and method comparisons
- Decision point: If too many results, add more specific terms or filters. If too few, broaden terms and add synonyms.
-
Step 5: Deep dive into key papers (Tier 3)
- Extract full text from high-priority papers
- Download supplementary materials for data and protocols
- Check reference lists for additional relevant papers
import io import os from pathlib import Path from urllib.parse import urlparse import requests import trafilatura from pypdf import PdfReader # Extract article content from URL (clean main text, drops nav/ads) downloaded = trafilatura.fetch_url("https://www.nature.com/articles/nature12373") article_text = trafilatura.extract(downloaded) # Extract text from a PDF pdf_bytes = requests.get("https://arxiv.org/pdf/1706.03762.pdf", timeout=30).content reader = PdfReader(io.BytesIO(pdf_bytes)) pdf_text = "\n".join(page.extract_text() or "" for page in reader.pages) # Download supplementary files via Crossref DOI metadata doi = "10.1038/nature12373" meta = requests.get(f"https://api.crossref.org/works/{doi}", timeout=30).json() out_dir = Path("./supplementary_materials") out_dir.mkdir(exist_ok=True) for link in meta.get("message", {}).get("link", []): url = link.get("URL") if not url: continue fname = os.path.basename(urlparse(url).path) or "supplement.bin" (out_dir / fname).write_bytes(requests.get(url, timeout=60).content) -
Step 6: Document and iterate
- Record all search terms, databases, filters, and result counts
- If gaps remain, revisit Steps 2-3 with refined queries
- For systematic reviews, follow PRISMA guidelines for reporting
Common Search Scenarios
The following scenarios illustrate how to combine the three tiers for typical research questions.
Finding Methods and Protocols
Start with PubMed for published methodology papers, then supplement with web search for step-by-step protocols from resources like protocols.io.
from Bio import Entrez
from duckduckgo_search import DDGS
Entrez.email = "your.email@example.com"
# Search for methodology papers in PubMed
handle = Entrez.esearch(
db="pubmed",
term='"Western Blotting"[MeSH] AND (protocol OR method OR technique)',
retmax=10,
)
pubmed_ids = Entrez.read(handle)["IdList"]
handle.close()
# Check web for step-by-step protocols
web_hits = DDGS().text("Western blot protocol for membrane proteins", max_results=5)
Understanding Disease Mechanisms
Begin with review articles for a broad overview, then drill into specific mechanistic studies.
# Find review articles first for an overview
results = query_pubmed(
'"Alzheimer Disease"[MeSH] AND pathophysiology AND review[Publication Type]',
max_papers=10
)
# Then find specific mechanistic studies
results = query_pubmed(
'"Alzheimer Disease"[MeSH] AND ("amyloid beta"[MeSH] OR tau) AND mechanism',
max_papers=20
)
Finding Drug and Treatment Information
Use publication type filters to separate clinical trial evidence from systematic reviews.
# Clinical trials for a specific drug-condition pair
results = query_pubmed(
'"Drug Name"[Substance Name] AND "Condition"[MeSH] AND clinical trial[Publication Type]',
max_papers=20
)
# Systematic reviews and meta-analyses
results = query_pubmed(
'"Drug Name" AND "Condition" AND (systematic review[Publication Type] OR meta-analysis[Publication Type])',
max_papers=10
)
Tracking Latest Developments
Combine AI-assisted search for synthesis with database searches for recent indexed publications.
from anthropic import Anthropic
from Bio import Entrez
client = Anthropic()
Entrez.email = "your.email@example.com"
# AI-assisted synthesis of recent advances (Claude API web search tool)
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=4096,
tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}],
messages=[{
"role": "user",
"content": "What are the most significant advances in CAR-T cell therapy in 2024?",
}],
)
# Supplement with recent PubMed results
handle = Entrez.esearch(
db="pubmed",
term='"Chimeric Antigen Receptor T-Cell Therapy"[MeSH] AND "2024"[Date - Publication]',
retmax=20,
)
pubmed_ids = Entrez.read(handle)["IdList"]
handle.close()
Finding Specific Reagents and Materials
Use AI-assisted search for validated reagent recommendations, supplemented by general web search.
from anthropic import Anthropic
from duckduckgo_search import DDGS
client = Anthropic()
# Search for validated reagents (Claude API + web search tool)
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=4096,
tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 2}],
messages=[{
"role": "user",
"content": "validated antibodies for Western blot detection of p53 protein",
}],
)
# Search supplier databases
supplier_hits = DDGS().text("p53 antibody Western blot validated", max_results=5)
Comparative Analysis Across Methods
Use AI-assisted search for synthesized comparisons of techniques or tools.
from anthropic import Anthropic
client = Anthropic()
# Compare approaches with AI synthesis (Claude API web search tool)
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=4096,
tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
messages=[{
"role": "user",
"content": "Compare different CRISPR delivery methods for in vivo gene editing: viral vectors vs lipid nanoparticles",
}],
)
print(response.content)
Quality Assessment Checklist
When evaluating search results, apply these criteria:
- Source reliability: Is the paper from a peer-reviewed journal?
- Author credentials: Are the authors established experts in the field?
- Recency: Is the information current enough for your purpose?
- Study design: Is the design appropriate for the question (e.g., RCT for efficacy, cohort for risk)?
- Sample size: Is it adequate for the conclusions drawn?
- Reproducibility: Are methods described clearly enough to replicate?
- Conflicts of interest: Are any conflicts declared?
- Citation count: Has the paper been well-cited by subsequent work?
Further Reading
- PubMed Help -- Official guide to PubMed search syntax, field tags, filters, and advanced features
- arXiv Help Pages -- Documentation on arXiv search, subject categories, and submission process
- MeSH Browser -- NLM tool for browsing and searching the Medical Subject Headings controlled vocabulary
- PRISMA Statement -- Guidelines for transparent reporting of systematic reviews and meta-analyses
- Cochrane Handbook for Systematic Reviews -- Gold-standard methodology for systematic literature reviews
Related Skills
pubmed-database-- Direct PubMed API access for programmatic literature retrievalscientific-manuscript-writing-- Structuring literature review sections within manuscriptsresearch-question-formulation-- Frameworks for defining answerable research questions
skills/scientific-writing/scientific-manuscript-writing/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-manuscript-writing -g -y
SKILL.md
Frontmatter
{
"name": "scientific-manuscript-writing",
"license": "CC-BY-4.0",
"description": "Scientific manuscript writing: IMRAD, citation styles (APA\/AMA\/Vancouver\/IEEE), figures\/tables, reporting guidelines (CONSORT\/STROBE\/PRISMA\/ARRIVE), writing principles (clarity\/conciseness\/accuracy), venue-specific style. For LaTeX see companion assets."
}
Scientific Manuscript Writing
Overview
Scientific manuscript writing is the discipline of communicating research findings with precision, clarity, and reproducibility. This knowhow covers the complete lifecycle of manuscript preparation: from planning and structuring a paper using IMRAD format, through applying correct citation styles and designing effective figures, to ensuring compliance with study-specific reporting guidelines. It applies across biomedical, social science, engineering, and computational fields.
This entry consolidates writing principles, manuscript structure, citation systems, figure/table design, and reporting standards into a unified reference for producing publication-ready scientific documents.
Key Concepts
Writing Principles: Clarity, Conciseness, Accuracy
The three pillars of scientific writing govern all manuscript text:
- Clarity: Use precise, unambiguous language. Define technical terms at first use. Maintain logical flow within and between paragraphs. Use active voice when it improves understanding; passive voice is acceptable in Methods when the action matters more than the actor.
- Conciseness: Express ideas in the fewest words necessary. Eliminate redundant phrases ("due to the fact that" becomes "because"; "in order to" becomes "to"). Favor shorter sentences (15-20 words average). Use strong verbs instead of noun-verb combinations ("analyze" not "perform an analysis").
- Accuracy: Report exact values with appropriate precision matched to measurement capability. Use consistent terminology throughout. Distinguish observations from interpretations. Verify that numbers in text match tables and figures.
Additional principles include objectivity (present results without bias, acknowledge conflicting evidence), consistency (same term for same concept, uniform notation), and logical organization (clear "red thread" connecting sections). See references/writing_principles_style.md for detailed guidance with examples.
IMRAD Structure
IMRAD (Introduction, Methods, Results, And Discussion) is the standard organizational structure for original research articles, adopted across most scientific disciplines since the 1970s. It mirrors the scientific method:
| Section | Question Answered | Primary Tense | Typical Length |
|---|---|---|---|
| Title | What is this about? | N/A | 10-15 words |
| Abstract | Complete summary | Mixed | 100-250 words |
| Introduction | Why did you study this? | Present/past | 4-5 paragraphs |
| Methods | How did you do it? | Past | 2-4 pages |
| Results | What did you find? | Past | 2-4 pages |
| Discussion | What does it mean? | Past/present | 3-5 pages |
| Conclusion | Take-home message | Present | 1-2 paragraphs |
The Introduction follows a funnel structure: broad context, narrowing literature review, gap identification, and study objectives. Methods must provide sufficient detail for replication. Results present findings objectively without interpretation. Discussion interprets findings, compares with prior work, acknowledges limitations, and proposes future directions.
Venue variations: Nature/Science use modified IMRAD with methods in supplement. ML conferences (NeurIPS/ICML) use Introduction-Method-Experiments-Conclusion with numbered contributions and ablation studies. See references/manuscript_structure.md for section-by-section guidance.
Citation Systems
Five major citation styles serve different disciplines:
| Style | Format | Primary Disciplines |
|---|---|---|
| AMA | Superscript numbers | Medicine, health sciences |
| Vancouver | Numbers in brackets [1] | Biomedical sciences |
| APA | Author-date (Smith, 2023) | Psychology, social sciences |
| Chicago | Notes-bibliography or author-date | Humanities, some sciences |
| IEEE | Numbers in brackets [1] | Engineering, computer science |
Best practices across all styles: Cite primary sources when possible. Include recent literature (within 5-10 years for active fields, 2-3 years for ML). Balance citation distribution across Introduction and Discussion. Verify all citations against original sources. Keep self-citations below 20%. Always check the target journal's author guidelines for the required style.
See references/citation_guide.md for complete format specifications, journal-specific requirements, and DOI formatting rules.
Figure and Table Design Principles
Figures and tables are the backbone of a scientific paper; many readers examine them before reading the text.
Decision rule: Can the information be conveyed in 1-2 sentences? If yes, use text only. If no and precise values are needed, use a table. If no and patterns/trends matter most, use a figure.
Core design principles:
- Self-explanatory: Each display item must stand alone with a complete caption defining all abbreviations, units, sample sizes, and statistical annotations
- No redundancy: Text highlights key findings from displays; it does not repeat all data
- Consistency: Uniform formatting, color schemes, and terminology across all display items
- Optimal quantity: Approximately one display item per 1,000 words of manuscript
- Accessibility: Color-blind safe palettes (blue-orange, viridis); designs that work in grayscale
Error bar rule: Always state which measure is shown (SD for data spread, SEM for measurement precision, 95% CI for significance assessment). 95% CI is generally preferred because non-overlapping CIs indicate significant differences.
See references/figures_tables_guide.md for figure type selection, table formatting, statistical presentation, and journal-specific requirements.
Decision Framework
What type of scientific document are you writing?
|
+-- Original research article
| |
| +-- Clinical trial --> IMRAD + CONSORT reporting guideline
| +-- Observational study --> IMRAD + STROBE reporting guideline
| +-- Animal study --> IMRAD + ARRIVE reporting guideline
| +-- Diagnostic accuracy --> IMRAD + STARD reporting guideline
| +-- Prediction model --> IMRAD + TRIPOD reporting guideline
| +-- ML/CS research --> Intro-Method-Experiments-Conclusion
|
+-- Review / synthesis
| +-- Systematic review --> PRISMA reporting guideline
| +-- Narrative review --> Thematic structure
| +-- Meta-analysis --> PRISMA + forest plots
|
+-- Case report --> CARE guideline
|
+-- Study protocol --> SPIRIT guideline
|
+-- Quality improvement --> SQUIRE guideline
|
+-- Economic evaluation --> CHEERS guideline
|
+-- Professional report / white paper --> scientific_report.sty (see assets/)
| Venue Type | Structure | Citation Style | Key Adaptation |
|---|---|---|---|
| Nature/Science | Modified IMRAD, methods in supplement | Numbered superscript | Accessible language, broad significance, story-driven |
| Medical journals (NEJM, JAMA) | Strict IMRAD | Vancouver/AMA | Structured abstracts, clinical focus, CONSORT/STROBE |
| Field-specific journals | Standard IMRAD | Varies by field | Full technical detail, field terminology |
| ML conferences (NeurIPS, ICML) | Intro-Method-Experiments-Conclusion | Numbered or author-year | Numbered contributions, ablations, pseudocode |
| Professional reports | Chapter-based | N/A | Use scientific_report.sty for formatting |
Best Practices
- Write the abstract last: After completing all sections, synthesize the key message into 100-250 words. Include quantitative results with statistical measures.
- Plan figures before writing: Design figures and tables as the data story backbone. Write Methods first, then Results describing the displays, then Discussion interpreting them, then Introduction framing the question.
- One idea per paragraph: Start with a topic sentence, develop with supporting evidence, end with a transition to the next idea. Target 3-7 sentences per paragraph.
- Define abbreviations strategically: Define at first use in both abstract and main text. Only abbreviate terms used 3+ times. Limit to 3-4 new abbreviations per paper. Standard abbreviations (DNA, RNA, PCR) need no definition.
- Use verb tense correctly: Past tense for completed actions (Methods, Results, prior studies). Present tense for established facts and your interpretations. Present perfect for recent developments with current relevance ("Recent studies have demonstrated...").
- Report statistics completely: For each comparison, include point estimate, variability measure (SD/SEM/CI), sample size, test statistic, exact p-value (not just "p < 0.05"), and effect size with confidence interval.
- Follow reporting guidelines from the start: Identify the appropriate guideline during study design, not after manuscript completion. Use the checklist during drafting to ensure all required elements are captured.
- Match writing style to venue: Review 3-5 recent papers from your target journal. Adapt sentence length, vocabulary level, hedging style, and section proportions to match venue expectations.
- Verify citation-reference correspondence: Every in-text citation must have a reference list entry and vice versa. Check author names, years, titles, and DOIs before submission.
- Design for accessibility: Use color-blind safe palettes. Ensure figures work in grayscale. Maintain minimum 8-10 pt font at final print size. Use vector formats (PDF, EPS) for graphs.
Common Pitfalls
-
Overstating conclusions beyond the evidence: Claiming causation from observational data or generalizing beyond the study population.
- How to avoid: Use appropriate hedging language ("suggests," "is associated with"). Match claim strength to study design.
-
Inconsistent terminology: Using different words for the same concept (switching between "medication," "drug," and "pharmaceutical").
- How to avoid: Choose one term per concept and use it throughout. Create a terminology list before drafting.
-
Mixing verb tenses inappropriately: Using present tense for your specific results or past tense for established facts.
- How to avoid: Follow the section-specific tense rules. Review each section for tense consistency during revision.
-
Insufficient methods detail for reproducibility: Omitting sample sizes, software versions, statistical test justifications, or ethical approval.
- How to avoid: Use the relevant reporting guideline checklist. Have a colleague assess whether they could replicate the study from your Methods section alone.
-
Redundant data presentation: Repeating all table values in the text, or duplicating information between figures and tables.
- How to avoid: Text should highlight and interpret key findings from displays, not restate all numbers. Each data point appears in only one format.
-
Missing or poorly designed error bars: Omitting error bars entirely, or not specifying whether they represent SD, SEM, or CI.
- How to avoid: Include error bars on all quantitative figures. Define the measure in every caption ("Error bars represent 95% CI").
-
Abbreviation overload: Defining too many abbreviations, or abbreviating terms used only once or twice.
- How to avoid: Only abbreviate terms used 3+ times. Limit new abbreviations to 3-4 per paper. Never abbreviate in the title.
-
Ignoring journal-specific requirements: Submitting with wrong citation style, exceeding word limits, or using incorrect figure formats.
- How to avoid: Read author guidelines before starting the manuscript. Create a checklist of journal requirements and verify compliance before submission.
Workflow
-
Planning Phase
- Identify target journal and review author guidelines (word limits, citation style, figure specs)
- Determine the applicable reporting guideline (CONSORT, STROBE, PRISMA, etc.)
- Download the reporting checklist and review items that require prospective planning
- Outline manuscript structure (usually IMRAD unless venue dictates otherwise)
-
Literature Review
- Search for relevant prior work using systematic database queries
- Organize references in a reference manager (Zotero, Mendeley, EndNote)
- Identify the knowledge gap your study addresses
- Note key studies for Introduction and Discussion contexts
-
Outline Construction
- Create section outlines with bullet points noting main arguments, key citations, data points, and logical flow
- Plan figures and tables as the core data story
- Map reporting checklist items to manuscript sections
-
Drafting (section order: Methods, Results, Discussion, Introduction, Abstract, Title)
- Convert each outline section into flowing prose paragraphs
- Methods: describe what was done with sufficient detail for replication
- Results: present findings objectively, referencing figures and tables
- Discussion: interpret findings, compare with literature, acknowledge limitations
- Introduction: establish context, identify gap, state objectives
- Abstract: synthesize the complete story in 100-250 words
- Decision point: If writing for a specific venue, consult venue-specific style guides
-
Figure and Table Finalization
- Create publication-quality figures at final size and resolution (300+ dpi)
- Write self-explanatory captions with all abbreviations, units, sample sizes, and statistical annotations
- Number displays consecutively in order of first mention
- Verify that no data is duplicated between text, figures, and tables
-
Revision and Quality Control
- Check logical flow and the "red thread" throughout the manuscript
- Verify terminology and notation consistency
- Walk through the reporting guideline checklist item by item
- Confirm all citations are accurate and properly formatted
- Proofread for grammar, spelling, and clarity
- Check word/page counts against journal limits
-
Submission Preparation
- Format according to journal requirements (template, citation style, figure formats)
- Prepare supplementary materials
- Complete and upload reporting guideline checklists
- Write cover letter highlighting significance and fit
- Gather required statements (funding, conflicts of interest, data availability, ethical approval)
- Final verification: every in-text citation has a reference entry and vice versa
Bundled Resources
Reference Files
| File | Content | Original Source |
|---|---|---|
references/writing_principles_style.md |
Clarity/conciseness/accuracy strategies, verb tense rules, word choice, paragraph structure, revision checklist, venue-specific writing styles | Condensed from writing_principles.md (825 lines) |
references/manuscript_structure.md |
Complete IMRAD guide, section-by-section content and common mistakes, venue variations, ML conference structure | Condensed from imrad_structure.md (659 lines) |
references/citation_guide.md |
AMA, Vancouver, APA, Chicago, IEEE format specifications, journal-specific styles, citation best practices, DOI formatting | Condensed from citation_styles.md (721 lines) |
references/figures_tables_guide.md |
Figure type selection, table formatting, statistical presentation, accessibility, journal-specific requirements, submission checklist | Condensed from figures_tables.md (807 lines) |
references/reporting_guidelines.md |
CONSORT, STROBE, PRISMA, SPIRIT, STARD, TRIPOD, ARRIVE, CARE, SQUIRE, CHEERS checklist summaries and usage workflow | Condensed from reporting_guidelines.md (749 lines) |
Omitted: professional_report_formatting.md (665 lines) -- LaTeX-specific formatting details are covered directly by the scientific_report.sty template and its companion REPORT_FORMATTING_GUIDE.md in the assets/ directory. Key concepts (box environments, scientific notation commands) are noted in the assets section below.
Asset Files
| File | Description |
|---|---|
assets/scientific_report.sty |
LaTeX style package for professional reports (not journal manuscripts). Provides Helvetica typography, colored box environments (keyfindings, methodology, limitations, etc.), alternating-row tables, and scientific notation commands (\pvalue, \effectsize, \CI, \meansd). Compile with XeLaTeX or LuaLaTeX. |
assets/scientific_report_template.tex |
Complete LaTeX template demonstrating all style features: title page, executive summary, chapters with box environments, statistical tables, figure formatting, appendices. |
assets/REPORT_FORMATTING_GUIDE.md |
Quick reference guide for the style package: color palette (hex values), box environment syntax, scientific notation command reference, table/figure formatting patterns, compilation instructions. |
Further Reading
- EQUATOR Network -- Comprehensive library of reporting guidelines for health research
- ICMJE Recommendations -- Uniform requirements for manuscripts submitted to biomedical journals
- APA Style Guide -- Official APA Publication Manual resources
- Nature Masterclasses -- Scientific writing courses from Nature
- Academic Phrasebank -- Common academic phrases organized by function (University of Manchester)
- Strunk & White, The Elements of Style -- Classic guide to clear writing
- Lebrun, Scientific Writing: A Reader and Writer's Guide -- Practical guide for scientific authors
Related Skills
matplotlib-scientific-plotting-- Create publication-quality figures for manuscriptsseaborn-statistical-plots-- Statistical visualizations with automatic CI and aggregationplotly-interactive-plots-- Interactive figures for supplementary materialsstatsmodels-statistical-modeling-- Statistical models whose outputs you report in manuscriptspymc-bayesian-modeling-- Bayesian analysis results for reportingpeer-review-methodology-- Structured peer review of manuscripts (complements this entry)latex-research-posters-- Poster preparation using LaTeXscientific-slides-- Conference presentation preparationscientific-brainstorming-- Ideation methods for framing research questions
Companion Assets
assets/scientific_report.sty-- LaTeX style package for professional scientific reportsassets/scientific_report_template.tex-- Complete report template with all style features demonstratedassets/REPORT_FORMATTING_GUIDE.md-- Quick reference card for box environments, color palette, and scientific notation commands
skills/scientific-writing/scientific-schematics/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-schematics -g -y
SKILL.md
Frontmatter
{
"name": "scientific-schematics",
"license": "CC-BY-4.0",
"description": "Designing scientific schematics, diagrams, and graphical abstracts. Covers tool selection (BioRender, Inkscape, Affinity, PowerPoint), design principles for pathway diagrams, mechanism schematics, experimental workflows, and journal graphical abstracts. Includes composition, icon sourcing, color for biological entities, and accessibility. Use when creating illustrative (not data-driven) scientific figures."
}
Scientific Schematics
Overview
Scientific schematics are illustrative figures that communicate biological mechanisms, experimental designs, or conceptual frameworks — as distinct from data-driven figures (graphs, heatmaps). A well-designed schematic makes a manuscript's key concept immediately comprehensible to a broad scientific audience, while a poorly designed one confuses reviewers and reduces the paper's impact. This guide covers the design decisions, tool selection, composition principles, and accessibility standards specific to biological schematics in peer-reviewed publications.
Key Concepts
1. Schematic Types and Their Purpose
Not all schematics serve the same function. Choosing the wrong type leads to overloaded or underspecified figures.
| Type | Purpose | Key Elements | Examples |
|---|---|---|---|
| Mechanism diagram | Show step-by-step molecular events | Proteins, DNA, membranes, arrows indicating causality | CRISPR cleavage, signaling cascade, transcription factor binding |
| Pathway diagram | Show relationships in a network | Nodes (proteins/metabolites), edges (activation/inhibition), direction | MAPK signaling, metabolic flux, gene regulatory network |
| Experimental workflow | Show experimental protocol as a figure | Numbered steps, icons for equipment/samples, time arrows | Single-cell sequencing pipeline, drug treatment protocol |
| Graphical abstract | 1-panel summary of entire paper | 2–4 key findings, minimal text, journal-specified dimensions | Cell, Nature, PNAS graphical abstracts |
| Structural diagram | Show molecular structure/conformation | Ribbon structure, surface representation, active site | Protein domain schematic, ligand binding pocket |
Decision rule: If the reader needs to understand what happened step-by-step, use a workflow. If they need to understand why (cause and effect), use a mechanism/pathway diagram. If they need a 30-second paper summary, use a graphical abstract.
2. Tool Comparison
| Tool | Best For | Learning Curve | Cost | File Output |
|---|---|---|---|---|
| BioRender | Quick biological schematics; built-in icon library | Low | Subscription (~$99/month academic; free for draft) | PNG, SVG, PDF (license required for publication) |
| Inkscape | Full vector editing; open-source | Medium–High | Free | SVG, PDF, PNG, EMF |
| Affinity Designer | Professional vector + raster hybrid | Medium | ~$55 one-time | SVG, PDF, AFDESIGN |
| Adobe Illustrator | Industry standard; full features | High | ~$55/month | SVG, PDF, AI, EPS |
| PowerPoint / Keynote | Quick drafts; non-scientists | Low | Included in MS/Apple | PNG (low resolution), PDF |
| draw.io / Lucidchart | Flowcharts, pathway diagrams | Low | Free/subscription | SVG, PNG, XML |
| ChimeraX / PyMOL | Molecular structure renders | Medium | Free (academic) | PNG, TIFF, movie |
Key difference: BioRender provides curated biological icons (cells, proteins, instruments) that are publication-quality and royalty-free under the publication license. For complex custom graphics, Inkscape (free) or Affinity Designer (paid) offer more flexibility.
3. Color Usage in Biological Schematics
Color in biological schematics carries semantic meaning and must be used consistently.
Conventional biological color assignments:
- DNA: double helix colored in blue/navy or gray
- mRNA: orange or yellow
- Protein: varying; use consistent color per protein family across all figures in a paper
- Cell nucleus: light gray or blue fill
- Cell membrane: tan/beige bilayer
- Mitochondria: orange
- Lysosomes: red/purple
Signal direction conventions:
- Arrows: activation (→ solid arrowhead), inhibition (⊣ flat bar), indirect (- - →)
- Color coding arrows: green = activating, red = inhibiting, gray = neutral/context
Accessibility:
- Never use red–green encoding alone; add shape cues (✓/✗, solid/dashed)
- Test with CVD simulator before submission
- Use at least 3:1 contrast ratio for text on colored backgrounds
4. Composition and Layout Principles
Visual hierarchy: The most important element should be the largest and most central. Support elements (labels, arrows, scale bars) should be visually subordinate.
Element spacing: Maintain consistent padding between grouped elements. Crowded diagrams cause readers to misread association between elements.
Arrow semantics: Every arrow must convey exactly one relationship. Do not use arrows interchangeably for "leads to," "causes," "physically binds," and "is required for" — use distinct arrow styles for each.
Text minimalism: Labels should be noun phrases only (not full sentences). Move explanatory text to the figure caption, not into the diagram itself.
Decision Framework
What is this schematic trying to communicate?
│
├── A sequence of experimental steps
│ └── → Workflow diagram
│ ├── Numbered panels or numbered arrows
│ ├── Icons for equipment (instrument, pipette, plate, animal)
│ └── Time axis or step axis
│
├── How a molecule works mechanistically
│ └── → Mechanism diagram
│ ├── Protein shapes (surface or cartoon)
│ ├── Conformational change arrows
│ └── Before/after state panels
│
├── How multiple components interact in a network
│ └── → Pathway diagram
│ ├── Nodes = proteins/metabolites (circles, rectangles)
│ ├── Edges = arrows (activation = →, inhibition = ⊣)
│ └── Compartment boxes (nucleus, cytoplasm, extracellular)
│
├── A single-panel paper summary
│ └── → Graphical abstract
│ ├── Check journal specifications (size, text policy)
│ ├── Show: question → approach → key finding → implication
│ └── Minimal text; max 3–4 panels
│
└── A 3D molecular structure
└── → Structural diagram
├── Use ChimeraX, PyMOL, or RCSB PDB viewer for render
├── Annotate active site/binding pocket
└── Add scale bar (Å or nm)
| Scenario | Tool | Format | Key Consideration |
|---|---|---|---|
| First-author manuscript, fast turnaround | BioRender | PDF/SVG | Requires publication license for final submission |
| Lab with ongoing schematic needs | Inkscape + master template | SVG | Reusable elements; free; no vendor lock-in |
| Graphical abstract for Cell/Nature | BioRender or Illustrator | PNG at journal spec | Check exact pixel dimensions and text policy |
| Molecular mechanism with protein structure | ChimeraX + Inkscape | PNG render + SVG annotation | Use PDB structure; annotate key residues |
| Pathway diagram with many nodes | draw.io / Cytoscape | SVG/PDF | Auto-layout for networks with >20 nodes |
Best Practices
-
Start with a sketch before opening any tool: Draw the schematic on paper first. Determine what elements are needed, their hierarchy, and the spatial relationships. Opening software first leads to layout decisions driven by tool constraints, not by communication goals.
-
Define a consistent visual vocabulary before drawing the first panel: Assign colors, shapes, and arrow styles to each biological entity type at the project level. Document these assignments in a legend or lab style guide. Inconsistency across figures in a paper signals carelessness and confuses reviewers.
-
Use the journal's graphical abstract template: Every high-impact journal (Cell, Nature, PNAS, PLOS) publishes exact dimensions, resolution requirements, and font rules for graphical abstracts. Download the template before starting — resizing after the fact destroys aspect ratios and makes text too small.
-
Export at publication resolution from the vector source: Always export final figures from the original vector file (SVG, AI, AFDESIGN) at the target DPI, never by screenshot or screen capture. Screenshots are raster at screen resolution (~72 dpi); journals require 300–600 dpi.
-
Label arrows and connections in captions, not in the diagram: Arrow labels ("activates," "phosphorylates," "recruits") add clutter and reduce diagram readability. Move these relationships to the figure caption using panel reference labels (A, B, C). The diagram should be self-explanatory to someone who reads the caption.
-
Group related elements visually using proximity, enclosure, and color: Elements that belong together conceptually should be spatially close, share a background color, or be enclosed in a bounding box. Do not use arbitrary spatial arrangements — viewers interpret proximity as association.
-
Version-control your source files: Store original editable source files (
.svg,.afdesign,.ai,.brd) in version-controlled storage alongside the manuscript. Journals frequently request revised figures during revision, and re-creating from a PNG is extremely time-consuming.
Common Pitfalls
-
Using inconsistent arrow styles across a figure
- What goes wrong: Solid arrows and dashed arrows mixed arbitrarily make readers unsure whether dashed means "indirect," "hypothetical," or merely a stylistic choice.
- How to avoid: Define an arrow legend: solid arrow = direct activation, dashed = indirect, flat bar = inhibition. Use this consistently in every panel of every figure in the paper.
-
Overloading a single panel with too many elements
- What goes wrong: Pathway diagrams with 20+ nodes and 30+ arrows cannot be read in a journal figure at 170 mm width. Reviewers ask for simplification; revisions are time-consuming.
- How to avoid: Limit mechanism panels to ≤8 elements. Move complex pathway context to supplementary figures. Focus the main-text schematic on the core novel finding.
-
Using BioRender without purchasing the publication license
- What goes wrong: BioRender figures created under the free or draft license cannot be published. Submitting without proper licensing violates BioRender's terms of service and risks manuscript rejection.
- How to avoid: Purchase the publication license before creating the final figure version. BioRender generates a citation string that must be included in the figure legend.
-
Text inside the schematic at 6 pt or smaller at print size
- What goes wrong: Labels inside pathway nodes or protein icons become illegible at publication dimensions (85 mm single-column). Reviewers flag this; revision requires recreating the entire figure.
- How to avoid: Work at print size from the start. In Inkscape: set canvas to 85 mm × target height. Minimum label size: 7 pt at 100% print scale.
-
Red–green arrow encoding for activation–inhibition without shape cues
- What goes wrong: ~8% of male readers have red–green color vision deficiency and cannot distinguish activation (green) from inhibition (red) arrows.
- How to avoid: Use distinct arrow shapes: → for activation, ⊣ for inhibition. Color is redundant encoding, not the only cue.
-
Copying schematic elements from published papers without redrawing
- What goes wrong: Reproducing figures from published papers without permission violates copyright. This applies to diagrams and icons, not just photographs.
- How to avoid: Use BioRender's licensed icon library, Reactome's freely licensed pathway diagrams (CC-BY), or Bioicons (free, open icons). Always redraw; never screenshot.
-
Missing scale bars in structural diagrams
- What goes wrong: Protein structure renderings without scale bars prevent readers from assessing relative sizes of domains, ligands, or interfaces.
- How to avoid: Add a scale bar (e.g., "10 Å" or "2 nm") using ChimeraX's
scalebarcommand or annotate in Inkscape after export.
Workflow
- Define the message — write one sentence stating what the schematic must communicate
- Sketch on paper — rough layout, element list, arrow types
- Choose tool — BioRender for icon-heavy biology; Inkscape for custom vectors; ChimeraX for structures
- Set canvas to print dimensions — match journal column width before placing any element
- Build in layers — background → structural elements (membranes, organelles) → protein/molecule icons → arrows → labels
- Apply visual vocabulary — consistent colors, arrow styles, fonts per lab style guide
- Run accessibility check — CVD simulation; test grayscale version
- Export from vector source — PDF or TIFF at target DPI (≥300 dpi raster, vector for line art)
- Archive source file — commit
.svg/.afdesign/.brdto version control alongside manuscript
Protocol Guidelines
- BioRender publication workflow: Create figure → File → Publish Figure → Generate Attribution → copy citation string into figure legend. The attribution reads: "Created with BioRender.com" with a unique agreement ID. Without this, the figure cannot be legally published.
- Inkscape CMYK-safe workflow: Inkscape does not natively support CMYK. For CMYK journals: design in RGB → export to PDF → convert using Ghostscript (
gs -sDEVICE=pdfwrite -dUseCIEColor). Verify colors after conversion. - ChimeraX protein structure render:
open 6YYT→graphics silhouettes true→lighting soft→saveimage figure.png width 2400 height 2400→ annotate in Inkscape. - Multi-panel figure assembly in Inkscape: Use Inkscape extensions → Grids → set column guides at journal column widths. Import each panel as a linked SVG to enable per-panel updates without recreating the composite.
Further Reading
- BioRender scientific communication guide — tutorials for biological icon usage and layout
- Fundamentals of Data Visualization — Wilke (open access) — caption writing and figure composition principles
- Bioicons — free, CC-licensed biological icons for use in any tool
- Reactome pathway diagrams — freely reusable (CC-BY) curated pathway schematics
- NIH Figure Guidelines — formatting requirements for PMC figure submissions
- Ten Simple Rules for Better Figures (Rougier et al., PLOS Comp Biol, 2014) — peer-reviewed design guidelines
Related Skills
scientific-visualization— data-driven figures (graphs, heatmaps) as distinct from illustrative schematicsscientific-manuscript-writing— figure legend writing and integration of schematics into paperslatex-research-posters— assembling schematics into poster layouts
skills/scientific-writing/scientific-slides/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill scientific-slides -g -y
SKILL.md
Frontmatter
{
"name": "scientific-slides",
"license": "CC-BY-4.0",
"description": "Scientific presentations for conferences, seminars, thesis defenses, and grant pitches. Slide design, talk structure, timing, data viz for slides, QA. PowerPoint and LaTeX Beamer. For posters use latex-research-posters."
}
Scientific Slides — Presentation Design and Delivery
Overview
Scientific presentations are a critical medium for communicating research at conferences, seminars, defenses, and professional talks. This knowhow covers end-to-end presentation development: structure and content planning, visual design principles, data visualization adaptation, timing and pacing, and quality assurance across PowerPoint and LaTeX Beamer formats.
Key Concepts
1. Talk Types and Their Requirements
| Talk Type | Duration | Slides | Focus | Key Finding Count |
|---|---|---|---|---|
| Conference talk | 10–20 min | 12–20 | 1–2 key findings | 1–2 |
| Academic seminar | 45–60 min | 40–60 | Comprehensive coverage | 3–6 |
| Thesis defense | 45–60 min | 45–65 | Full dissertation | All studies |
| Grant pitch | 10–20 min | 12–18 | Significance + feasibility | Preliminary data |
| Journal club | 20–45 min | 20–40 | Critical analysis | Paper's findings |
2. Visual Design Principles
Visual-first approach: Start with visuals (figures, diagrams, images), then add text as support. Target 60–70% visual content, 30–40% text. Every slide should have a strong visual element.
Typography:
- Title: 36–44 pt, bold, sans-serif (Arial, Calibri, Helvetica)
- Body: 24–28 pt (not just 18 pt minimum — aim higher for readability)
- Captions/annotations: 18–20 pt
- Maximum 2–3 font families
Color:
- Select a modern palette matching your topic (biotech = vibrant, physics = sleek darks, health = warm tones)
- 3–5 colors total with high contrast (7:1 preferred)
- Color-blind safe (avoid red-green combinations)
- Do NOT use default PowerPoint/Beamer themes without customization
Layout:
- One main idea per slide
- 40–50% white space
- Vary layouts: full-figure, two-column (text + figure), visual overlay (not all bullet lists)
- Asymmetric compositions (more engaging than centered)
- Rule of thirds for focal points
3. Data Visualization for Slides
Key differences from journal figures:
- Simplify: fewer panels per slide, split complex figures across slides
- Enlarge: 18–24 pt minimum for labels (larger than journal standard)
- Direct label: put labels on the data, not in legends
- Emphasize: use color and size to highlight key findings
- Progressive disclosure: reveal data incrementally for complex figures
| Chart Type | Best For | Slide Adaptation |
|---|---|---|
| Bar chart | Category comparison | Max 6–8 bars, large labels |
| Line graph | Trends over time | Bold lines, 2–3 series max |
| Scatter plot | Correlations | Large points, trend line |
| Heatmap | Matrix patterns | High contrast, annotate key cells |
| Flowchart | Methodology | Build step-by-step with animations |
4. Universal Story Arc
Every scientific talk follows this narrative structure:
- Hook — grab attention (30–60 seconds)
- Context — establish importance (5–10% of talk)
- Problem/Gap — identify what's unknown (5–10%)
- Approach — explain your solution (15–25%)
- Results — present key findings (40–50%)
- Implications — discuss meaning (15–20%)
- Closure — memorable conclusion (1–2 minutes)
Decision Framework
Implementation Tool Selection
Start: What is your priority?
├── Mathematical content, equations, version control?
│ └── YES → LaTeX Beamer (see assets/beamer templates)
├── Editable slides, company templates, animations?
│ └── YES → PowerPoint (programmatic or template-based)
├── Fast creation, non-technical audience, visual impact?
│ └── YES → PowerPoint or image-based PDF
└── Not sure
└── PowerPoint (most flexible default)
Slide Count Decision Table
| Duration | Simple Topic | Average | Complex Topic |
|---|---|---|---|
| 5 min | 5–6 | 6–8 | 5–7 |
| 10 min | 10–12 | 12–14 | 10–12 |
| 15 min | 14–16 | 16–18 | 14–16 |
| 30 min | 25–30 | 30–35 | 25–30 |
| 45 min | 38–45 | 45–50 | 38–45 |
| 60 min | 50–55 | 55–65 | 50–60 |
General rule: ~1 slide per minute. Complex slides (results, methodology) may take 2–3 minutes; simple slides (transitions, section dividers) take 15–30 seconds.
Time Allocation
| Section | % of Time | 15-min Talk | 45-min Talk |
|---|---|---|---|
| Introduction | 15–20% | 2–3 min | 7–9 min |
| Methods | 15–20% | 2–3 min | 7–9 min |
| Results | 40–50% | 6–7 min | 18–22 min |
| Discussion | 15–20% | 2–3 min | 7–9 min |
| Conclusion | 5% | 45 sec | 2 min |
Best Practices
-
MANDATORY: Every slide must have a strong visual element — figure, chart, diagram, image, or icon. Text-only bullet list slides fail to communicate science effectively. Target minimum 2 visual elements per content slide.
-
MANDATORY: Practice with a timer at least 3 times before presenting. Set timing checkpoints: for a 15-minute talk, check at 3–4 min (finishing intro), 7–8 min (midway through results), 12–13 min (starting conclusions).
-
Use minimal text as visual support. 3–4 bullets per slide, 4–6 words per bullet. Text is the supporting role; visuals are the stars. Never put full paragraphs on slides.
-
Include proper citations. Cite 3–5 papers in the introduction (establishing context) and 3–5 in the discussion (comparison). Use author-year format (Smith et al., 2023) for readability.
-
Design section dividers with visual breaks. Insert visually distinctive slides between major sections (intro → methods → results → conclusion). These help the audience reset and follow the narrative.
-
Anti-pattern — using default templates without customization. Default PowerPoint/Beamer themes signal "minimal effort." Choose a modern color palette, customize fonts, and add visual personality matching your topic.
-
Simplify journal figures for slides. Increase all labels to 18–24 pt, remove non-essential panels, use direct labeling instead of legends, emphasize the key finding with color or annotation.
-
Anti-pattern — cramming full paper content into slides. A 15-minute talk should cover 1–2 key findings, not the entire paper. Leave details for the written paper and prepare backup slides for Q&A.
-
Prepare backup slides. Put additional data, detailed methods, alternative analyses after the "Thank You" slide. Reference them during Q&A without disrupting the main talk flow.
-
Never skip conclusions. If running behind, cut earlier content (skip a results slide or compress methods). The conclusion is the audience's take-away message — skipping it wastes the entire talk.
Common Pitfalls
-
Text-heavy, visual-poor slides. Walls of text, no images or graphics, bullet points as the only content. How to avoid: Start slide creation with visuals first (which figure/diagram?), then add minimal text as support.
-
Font sizes too small (under 24pt body text). Back-row audience can't read, slides look cramped. How to avoid: Set body text to 24–28 pt, titles to 36–44 pt. Test by viewing slides at 50% zoom — if you can't read it, the audience can't either.
-
Too many findings for the time slot. Trying to present 5 findings in a 15-minute talk rushes everything. How to avoid: Conference talks = 1–2 findings. Seminars = 3–5. Choose ruthlessly.
-
Missing research context (no citations). Claims without supporting literature undermine credibility. How to avoid: Search literature before creating slides. Cite 3–5 papers in intro and 3–5 in discussion.
-
Inconsistent formatting across slides. Different fonts, colors, and layouts from slide to slide look unprofessional. How to avoid: Use master slides/templates. Define your color palette, fonts, and layout grid before starting content.
-
Low contrast text on background. Light gray text on white, or colored text on busy images. How to avoid: Ensure text-background contrast ratio ≥ 7:1. Use solid color overlays on image backgrounds.
-
Not practicing with timer. First run-through is during the actual presentation, causing time overruns. How to avoid: Practice minimum 3 times. Mark timing checkpoints on your notes.
-
Skipping conclusions when running over time. Rushing through or omitting the take-away message. How to avoid: Cut earlier content (a results slide) rather than the conclusion. Prepare a "Plan B" with marked skip-able slides.
Workflow
Stage 1: Planning
- Define context: talk type (conference/seminar/defense), duration, audience (specialist/general/mixed), venue (room size, virtual/in-person)
- Develop content outline: identify 1–3 core messages, select 3–6 key figures, allocate time per section
- Search literature: find 8–15 relevant papers for citations (3–5 for introduction context, 3–5 for discussion comparison)
- Choose implementation tool: PowerPoint (editable, animations, company templates) vs. LaTeX Beamer (equations, version control, mathematical content)
- Plan slide-by-slide: title, key points, visual element for each slide
Stage 2: Design and Creation
- Start from template (see Companion Assets for Beamer; use master slides for PowerPoint)
- Select modern color palette: match your topic — 3–5 colors with high contrast
- Configure typography: sans-serif, 24–28 pt body, 36–44 pt titles
- Create varied layouts: mix full-figure slides, two-column (text + figure), visual overlays
- Add section dividers: visually distinctive slides between major sections
Stage 3: Content Development
- Visual backbone first: place all figures, charts, diagrams, images before adding text
- Generate figures: create presentation-appropriate plots using matplotlib, plotly, or seaborn — larger fonts (18–24 pt labels), fewer panels, direct labeling, high contrast
- Add minimal text: bullet points complement visuals, don't replace them
- Include citations: author-year format on relevant slides (small text, bottom or near data)
- Add transitions: control information flow with builds for complex slides
- Prepare presentation aids: for physical presentations, bring backup copies, adapters, handouts
Stage 4: Validation and Refinement
- Visual inspection: check each slide for text overflow, element overlap, font sizes, contrast
- Readability test: view at 50% zoom — everything should still be readable
- Content review: verify narrative flow, one idea per slide, consistent formatting
- Peer review: ask colleague for 30-second test (can they identify the main message?)
- Compilation check (Beamer): verify no LaTeX warnings, all figures render correctly
Stage 5: Practice and Delivery
- Practice 3–5 times with timer: run 1 (rough), run 2 (smooth transitions), run 3 (exact timing), run 4+ (polish)
- Set timing checkpoints: mark 3–4 points on notes (e.g., "should be here by 4 min")
- Practice transitions: connecting phrases between sections
- Prepare for Q&A: anticipate questions, prepare backup slides with additional data
- Final checks: multiple copies (laptop, cloud, USB), test on presentation computer, backup PDF
Protocol Guidelines
LaTeX Beamer Compilation
# Basic compilation
pdflatex presentation.tex
# With bibliography
pdflatex presentation.tex && bibtex presentation && pdflatex presentation.tex && pdflatex presentation.tex
# Better font support
lualatex presentation.tex
PowerPoint Quality Control
- Export to PDF and review at 100% zoom
- Check all slides in Slide Sorter view for consistency
- Test animations and builds in Slideshow mode
- Verify file size (< 50 MB for email; compress images if needed)
Bundled Resources
Detailed reference files in references/:
| File | Content |
|---|---|
talk_types_guide.md |
Detailed structure and strategy for each talk type (conference, seminar, defense, grant pitch, journal club) with example outlines |
slide_design_guide.md |
Extended design principles: color theory, typography tables, layout patterns, accessibility guidelines, Gestalt principles for visual composition |
Further Reading
- Edward Tufte, The Visual Display of Quantitative Information — foundational principles for data visualization
- Web AIM Contrast Checker — WCAG compliance for slide readability
- Jean-Luc Doumont, Trees, Maps, and Theorems — structured scientific communication
- Coblis Color Blindness Simulator — test slide accessibility
Related Skills
- matplotlib-scientific-plotting — generate publication-quality figures adapted for slide presentation (larger fonts, simplified panels)
- plotly-interactive-plots — create interactive figures exportable as static images for slides
- seaborn-statistical-plots — statistical plots with automatic aggregation for results slides
- latex-research-posters — poster-specific design guidance; shares typography and color principles with slides
Companion Assets
Templates and guides in assets/:
| File | Description |
|---|---|
beamer_template_conference.tex |
LaTeX Beamer template for 15-minute conference talks |
beamer_template_seminar.tex |
LaTeX Beamer template for 45-minute academic seminars |
beamer_template_defense.tex |
LaTeX Beamer template for thesis/dissertation defenses |
timing_guidelines.md |
Comprehensive timing and pacing strategies for all talk types |
skills/structural-biology-drug-discovery/alphafold-database-access/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill alphafold-database-access -g -y
SKILL.md
Frontmatter
{
"name": "alphafold-database-access",
"license": "CC-BY-4.0",
"description": "Access AlphaFold DB's 200M+ predicted structures by UniProt ID. Download PDB\/mmCIF, analyze pLDDT\/PAE, bulk-fetch proteomes via Google Cloud. For experimental structures use PDB; for prediction use ColabFold or ESMFold.\n"
}
AlphaFold Database Access
Overview
AlphaFold DB is a public repository of AI-predicted 3D protein structures for over 200 million proteins, maintained by DeepMind and EMBL-EBI. Access predictions via BioPython or REST API, download coordinate files in multiple formats, analyze confidence metrics, and retrieve bulk proteome datasets via Google Cloud.
When to Use
- Retrieving AI-predicted protein structures by UniProt accession
- Downloading PDB/mmCIF coordinate files for structural analysis or docking
- Analyzing prediction confidence (pLDDT per-residue, PAE domain-level)
- Bulk-downloading entire proteome predictions via Google Cloud
- Comparing predicted structures with experimental PDB structures
- Building structural models for proteins lacking experimental data
- Identifying high-confidence binding sites for drug discovery
- For experimental structures only → use PDB directly
- For running AlphaFold predictions → use ColabFold or local AlphaFold
Prerequisites
# Core (BioPython for structure access)
pip install biopython requests numpy matplotlib
# Optional: Google Cloud for bulk access
pip install google-cloud-bigquery google-cloud-storage
Quick Start
from Bio.PDB import alphafold_db, MMCIFParser
import requests, numpy as np
# 1. Get prediction for a protein
uniprot_id = "P00520" # ABL1 kinase
predictions = list(alphafold_db.get_predictions(uniprot_id))
af_id = predictions[0]['entryId'] # AF-P00520-F1
# 2. Download structure
cif_file = alphafold_db.download_cif_for(predictions[0], directory="./structures")
# 3. Check confidence
conf = requests.get(f"https://alphafold.ebi.ac.uk/files/{af_id}-confidence_v4.json").json()
scores = conf['confidenceScore']
print(f"Mean pLDDT: {np.mean(scores):.1f}, High-conf residues: {sum(1 for s in scores if s > 90)}/{len(scores)}")
Core API
1. Prediction Retrieval
BioPython (recommended for single proteins):
from Bio.PDB import alphafold_db
# Get prediction metadata
predictions = list(alphafold_db.get_predictions("P00520"))
pred = predictions[0]
print(f"AlphaFold ID: {pred['entryId']}")
print(f"Gene: {pred['gene']}, Species: {pred['organismScientificName']}")
# Get Structure objects directly
structures = list(alphafold_db.get_structural_models_for("P00520"))
REST API (for metadata or integration):
import requests
uniprot_id = "P00520"
url = f"https://alphafold.ebi.ac.uk/api/prediction/{uniprot_id}"
response = requests.get(url)
data = response.json()
# Response includes download URLs for all file types
pred = data[0]
print(f"CIF: {pred['cifUrl']}")
print(f"PDB: {pred['pdbUrl']}")
print(f"PAE: {pred['paeDocUrl']}")
3D-Beacons federated API (query multiple structure providers):
url = f"https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/api/uniprot/summary/{uniprot_id}.json"
data = requests.get(url).json()
af_structures = [s for s in data['structures'] if s['provider'] == 'AlphaFold DB']
2. Structure File Download
import requests
af_id = "AF-P00520-F1"
version = "v4"
base = "https://alphafold.ebi.ac.uk/files"
# mmCIF (recommended — full metadata, supports large structures)
cif = requests.get(f"{base}/{af_id}-model_{version}.cif")
with open(f"{af_id}.cif", "w") as f:
f.write(cif.text)
# PDB format (legacy — limited to 99,999 atoms)
pdb = requests.get(f"{base}/{af_id}-model_{version}.pdb")
with open(f"{af_id}.pdb", "wb") as f:
f.write(pdb.content)
# Confidence JSON (per-residue pLDDT scores)
conf = requests.get(f"{base}/{af_id}-confidence_{version}.json").json()
# PAE matrix JSON (inter-residue confidence)
pae = requests.get(f"{base}/{af_id}-predicted_aligned_error_{version}.json").json()
3. Confidence Metrics Analysis
pLDDT (per-residue confidence, 0–100):
import numpy as np
conf_url = f"https://alphafold.ebi.ac.uk/files/{af_id}-confidence_v4.json"
conf = requests.get(conf_url).json()
scores = conf['confidenceScore']
# Classify residues by confidence
very_high = sum(1 for s in scores if s > 90)
high = sum(1 for s in scores if 70 < s <= 90)
low = sum(1 for s in scores if 50 < s <= 70)
very_low = sum(1 for s in scores if s <= 50)
print(f"Very high (>90): {very_high}, High (70-90): {high}, Low (50-70): {low}, Very low (<50): {very_low}")
PAE (Predicted Aligned Error) visualization:
import matplotlib.pyplot as plt
pae_url = f"https://alphafold.ebi.ac.uk/files/{af_id}-predicted_aligned_error_v4.json"
pae = requests.get(pae_url).json()
pae_matrix = np.array(pae['distance'])
plt.figure(figsize=(10, 8))
plt.imshow(pae_matrix, cmap='viridis_r', vmin=0, vmax=30)
plt.colorbar(label='PAE (Å)')
plt.title(f'Predicted Aligned Error: {af_id}')
plt.xlabel('Residue')
plt.ylabel('Residue')
plt.savefig(f'{af_id}_pae.png', dpi=300, bbox_inches='tight')
# Low PAE (<5 Å) = confident relative positioning; >15 Å = uncertain domain arrangement
4. Bulk Data Access (Google Cloud)
# List available data
gsutil ls gs://public-datasets-deepmind-alphafold-v4/
# Download entire proteome by taxonomy ID
gsutil -m cp gs://public-datasets-deepmind-alphafold-v4/proteomes/proteome-tax_id-9606-*_v4.tar .
# Download accession index
gsutil cp gs://public-datasets-deepmind-alphafold-v4/accession_ids.csv .
BigQuery metadata queries:
from google.cloud import bigquery
client = bigquery.Client()
query = """
SELECT entryId, uniprotAccession, gene, organismScientificName,
globalMetricValue, fractionPlddtVeryHigh
FROM `bigquery-public-data.deepmind_alphafold.metadata`
WHERE organismScientificName = 'Homo sapiens'
AND fractionPlddtVeryHigh > 0.8
AND isReviewed = TRUE
LIMIT 100
"""
df = client.query(query).to_dataframe()
print(f"Found {len(df)} high-confidence human proteins")
5. Structure Parsing & Analysis
from Bio.PDB import MMCIFParser
import numpy as np
from scipy.spatial.distance import pdist, squareform
parser = MMCIFParser(QUIET=True)
structure = parser.get_structure("protein", f"{af_id}-model_v4.cif")
# Extract alpha-carbon coordinates
coords, plddt_scores = [], []
for model in structure:
for chain in model:
for residue in chain:
if 'CA' in residue:
coords.append(residue['CA'].get_coord())
plddt_scores.append(residue['CA'].get_bfactor()) # pLDDT stored as B-factor
coords = np.array(coords)
print(f"Residues: {len(coords)}, Mean pLDDT: {np.mean(plddt_scores):.1f}")
# Contact map (Cα-Cα < 8 Å)
dist_matrix = squareform(pdist(coords))
contacts = np.where((dist_matrix > 0) & (dist_matrix < 8))
print(f"Contacts: {len(contacts[0]) // 2}")
Key Concepts
Confidence Interpretation
| Metric | Range | Interpretation | Suitable For |
|---|---|---|---|
| pLDDT >90 | Very high | Backbone + side-chain reliable | Detailed analysis, docking |
| pLDDT 70–90 | High | Backbone generally reliable | Fold analysis, domain ID |
| pLDDT 50–70 | Low | Use with caution | May be flexible/disordered |
| pLDDT <50 | Very low | Likely disordered | Exclude from analysis |
| PAE <5 Å | Confident | Reliable relative domain positions | Multi-domain assembly |
| PAE 5–10 Å | Moderate | Uncertain arrangement | Treat domains independently |
| PAE >15 Å | Uncertain | Domains may be mobile | Do not trust orientation |
AlphaFold ID Format
Format: AF-{UniProt_accession}-F{fragment_number} (e.g., AF-P00520-F1). Large proteins may be split into fragments (F1, F2, ...). Current database version: v4 — include version suffix in all file URLs.
File Types
| File | URL Suffix | Format | Use |
|---|---|---|---|
| Model coordinates | -model_v4.cif |
mmCIF | Structural analysis (recommended) |
| Model coordinates | -model_v4.pdb |
PDB | Legacy tools (<99,999 atoms) |
| Model coordinates | -model_v4.bcif |
Binary CIF | Compressed (~70% smaller) |
| Confidence | -confidence_v4.json |
JSON | Per-residue pLDDT array |
| Aligned error | -predicted_aligned_error_v4.json |
JSON | N×N PAE matrix |
| PAE image | -predicted_aligned_error_v4.png |
PNG | Quick visual assessment |
Common Workflows
Workflow 1: Single Protein Structure Analysis
from Bio.PDB import alphafold_db, MMCIFParser
import requests, numpy as np
uniprot_id = "P04637" # p53 tumor suppressor
# Retrieve and download
predictions = list(alphafold_db.get_predictions(uniprot_id))
cif_file = alphafold_db.download_cif_for(predictions[0], directory="./structures")
af_id = predictions[0]['entryId']
# Parse structure
parser = MMCIFParser(QUIET=True)
structure = parser.get_structure("p53", cif_file)
# Extract pLDDT from B-factors
plddt = [r['CA'].get_bfactor() for m in structure for c in m for r in c if 'CA' in r]
print(f"Length: {len(plddt)}, Mean pLDDT: {np.mean(plddt):.1f}")
# Identify high-confidence regions for docking
high_conf_regions = [(i+1, s) for i, s in enumerate(plddt) if s > 90]
print(f"High-confidence residues: {len(high_conf_regions)}/{len(plddt)}")
Workflow 2: Batch Protein Processing
from Bio.PDB import alphafold_db
import requests, numpy as np, pandas as pd, time
uniprot_ids = ["P00520", "P12931", "P04637", "P38398"]
results = []
for uid in uniprot_ids:
try:
preds = list(alphafold_db.get_predictions(uid))
if not preds:
continue
af_id = preds[0]['entryId']
conf = requests.get(f"https://alphafold.ebi.ac.uk/files/{af_id}-confidence_v4.json").json()
scores = conf['confidenceScore']
results.append({
'uniprot': uid, 'alphafold_id': af_id,
'length': len(scores), 'mean_plddt': np.mean(scores),
'frac_high_conf': sum(1 for s in scores if s > 90) / len(scores)
})
time.sleep(0.2) # Rate limit: 100-200ms between requests
except Exception as e:
print(f"Error {uid}: {e}")
df = pd.DataFrame(results)
print(df.to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
uniprot_id |
All | — | UniProt accession | Primary query identifier |
version |
Download | v4 |
v1–v4 | Database version (always use latest) |
directory |
BioPython | "." |
Path | Download destination |
QUIET |
MMCIFParser | False |
bool | Suppress parser warnings |
vmin/vmax |
PAE plot | 0/30 | Å | PAE colormap range |
taxonomy_id |
GCS bulk | — | NCBI tax ID | Species for proteome download |
fractionPlddtVeryHigh |
BigQuery | — | 0.0–1.0 | Filter by high-confidence fraction |
| Concurrent requests | API | — | ≤10 | Max parallel API requests |
| Request delay | API | — | 100–200ms | Delay between sequential requests |
Best Practices
- Use BioPython for single proteins, Google Cloud for bulk — individual API downloads are slow for >100 proteins; GCS parallel download is orders of magnitude faster
- Always check pLDDT before downstream analysis — low-confidence regions (pLDDT <50) are likely disordered and should be excluded from docking, contact analysis, or binding site prediction
- Anti-pattern — trusting all regions equally: AlphaFold predictions lack ligands, PTMs, cofactors, and multi-chain context. High pLDDT does not guarantee functional accuracy
- Cache downloaded files locally — avoid re-downloading the same structures; AlphaFold files are static per version
- Use PAE for multi-domain proteins — pLDDT tells you per-residue confidence, but PAE reveals whether domain orientations are reliable. Low inter-domain PAE (<5 Å) = trust the arrangement; high PAE (>15 Å) = treat domains independently
- Pin database version in reproducible analyses — include
_v4in URLs and document which version was used
Common Recipes
Recipe 1: Proteome Download by Species
import subprocess
def download_proteome(taxonomy_id: int, output_dir: str = "./proteomes"):
"""Download all AlphaFold predictions for a species via GCS."""
if not isinstance(taxonomy_id, int):
raise ValueError("taxonomy_id must be an integer")
pattern = f"gs://public-datasets-deepmind-alphafold-v4/proteomes/proteome-tax_id-{taxonomy_id}-*_v4.tar"
subprocess.run(["gsutil", "-m", "cp", pattern, f"{output_dir}/"], check=True)
# Human (9606), E. coli (83333), Mouse (10090)
download_proteome(9606)
Recipe 2: High-Confidence Region Extraction
def extract_high_conf_residues(plddt_scores, threshold=90):
"""Extract contiguous high-confidence regions."""
regions, start = [], None
for i, score in enumerate(plddt_scores):
if score > threshold and start is None:
start = i
elif score <= threshold and start is not None:
regions.append((start + 1, i, i - start)) # 1-indexed
start = None
if start is not None:
regions.append((start + 1, len(plddt_scores), len(plddt_scores) - start))
return regions
# Usage: regions = extract_high_conf_residues(plddt_scores)
# Returns: [(start_res, end_res, length), ...]
Recipe 3: PAE-Based Domain Segmentation
import numpy as np
def segment_domains(pae_matrix, threshold=10.0):
"""Simple domain segmentation from PAE matrix."""
n = pae_matrix.shape[0]
# Average PAE for each residue pair -> symmetric
sym_pae = (pae_matrix + pae_matrix.T) / 2
# Cluster: residues with low mutual PAE are in the same domain
domains, current_domain = [0] * n, 0
for i in range(1, n):
if sym_pae[i-1, i] > threshold:
current_domain += 1
domains[i] = current_domain
return domains
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found from API |
No AlphaFold prediction for this UniProt ID | Check if protein is in AlphaFold DB; some organisms not covered |
429 Too Many Requests |
Exceeded rate limit | Add time.sleep(0.2) between requests; use GCS for bulk |
| Empty predictions list | UniProt ID not in database | Verify ID at alphafold.ebi.ac.uk; try canonical isoform |
| Large protein split into fragments | Protein >2700 residues | Check all fragments (F1, F2, ...); stitch manually if needed |
| pLDDT values all low (<50) | Intrinsically disordered protein | Expected behavior; structure prediction unreliable for IDPs |
| PAE matrix asymmetric | PAE[i][j] ≠ PAE[j][i] by design | PAE measures error when aligned on residue i; symmetrize for clustering |
ModuleNotFoundError: Bio.PDB.alphafold_db |
BioPython version too old | Upgrade: pip install --upgrade biopython>=1.80 |
| GCS download fails | gsutil not configured | Run gcloud auth login or use anonymous access for public data |
| BigQuery quota exceeded | Free tier limit (1 TB/month) | Optimize queries with LIMIT; use GCS for bulk file access instead |
Bundled Resources
references/api_schemas_reference.md
Detailed API data schemas and lookup tables: REST API response fields, mmCIF data categories, confidence JSON schema, PAE JSON schema, BigQuery metadata table fields, HTTP error codes, rate limiting guidelines, and version history (v1–v4). Consult for field-level details when parsing API responses or building custom queries. Scripts functionality (none in original). Original api_reference.md content partially relocated to Core API (endpoints, common code patterns) and Key Concepts (confidence thresholds, file types table); schemas, field catalogs, and error codes retained in this reference.
Related Skills
- autodock-vina — molecular docking using AlphaFold structures as receptor
- biopython — general protein structure parsing and analysis beyond AlphaFold
References
- AlphaFold DB: https://alphafold.ebi.ac.uk/
- API Documentation: https://alphafold.ebi.ac.uk/api-docs
- Jumper et al. (2021) Nature 596, 583–589: https://doi.org/10.1038/s41586-021-03819-2
- Varadi et al. (2024) Nucleic Acids Res. 52, D368–D375: https://doi.org/10.1093/nar/gkad1011
- BioPython AlphaFold module: https://biopython.org/docs/dev/api/Bio.PDB.alphafold_db.html
- Google Cloud AlphaFold: https://console.cloud.google.com/marketplace/product/bigquery-public-data/deepmind-alphafold
skills/structural-biology-drug-discovery/autodock-vina-docking/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill autodock-vina-docking -g -y
SKILL.md
Frontmatter
{
"name": "autodock-vina-docking",
"license": "CC-BY-4.0",
"description": "Molecular docking with AutoDock Vina (Python API). Receptor\/ligand prep (Meeko + RDKit), grid box, docking, pose and binding energy analysis, and batch virtual screening."
}
AutoDock Vina Molecular Docking
Overview
AutoDock Vina is one of the fastest and most widely used open-source molecular docking engines for predicting protein–ligand binding modes and affinities. This skill covers the full Python-based pipeline: receptor preparation from PDB, ligand preparation from SMILES/SDF via Meeko and RDKit, search box definition, docking execution, pose analysis, and batch virtual screening for hit identification.
When to Use
- Predicting binding poses of small molecules to a protein target
- Estimating relative binding affinities (kcal/mol) for ligand ranking
- Virtual screening of compound libraries against a target receptor
- Validating docking protocols by re-docking co-crystallized ligands
- Preparing docking inputs from SMILES strings without intermediate files
- Comparing binding modes of analogs in a structure-activity relationship study
- Generating starting poses for molecular dynamics simulations
- Use DiffDock instead for blind docking when the binding site is unknown; use GNINA as an alternative with CNN scoring
Prerequisites
- Python packages:
vina,meeko,rdkit,prody(for PDB fetch),py3Dmol(for visualization) - External tools:
ADFR Suite(providesprepare_receptorfor PDBQT conversion) — download from https://ccsb.scripps.edu/adfr/downloads/ - Data requirements: Protein structure (PDB file or PDB ID), ligand(s) as SMILES, SDF, or MOL2
- Environment: Python 3.8+, Linux or macOS recommended
pip install vina meeko rdkit-pypi prody py3Dmol
# ADFR Suite must be installed separately for prepare_receptor
Workflow
Step 1: Fetch and Prepare the Receptor
Download the protein structure, remove water/heteroatoms, and convert to PDBQT format.
import prody
import subprocess
from pathlib import Path
# Download PDB structure (example: HIV-1 protease, PDB 1HPV)
pdb_id = "1HPV"
pdb_file = f"{pdb_id}.pdb"
prody.fetchPDB(pdb_id, compressed=False)
# Extract protein chain only (remove water and ligands)
structure = prody.parsePDB(pdb_file)
protein = structure.select("protein")
prody.writePDB(f"{pdb_id}_protein.pdb", protein)
print(f"Protein atoms: {protein.numAtoms()}")
# Convert to PDBQT using ADFR Suite's prepare_receptor
receptor_pdbqt = f"{pdb_id}_receptor.pdbqt"
subprocess.run([
"prepare_receptor",
"-r", f"{pdb_id}_protein.pdb",
"-o", receptor_pdbqt,
"-A", "hydrogens", # add hydrogens
], check=True)
print(f"Receptor PDBQT: {receptor_pdbqt}")
Step 2: Identify the Binding Site
Define the docking search box centered on the known binding site or co-crystallized ligand.
import numpy as np
# Option A: Center on co-crystallized ligand coordinates
structure = prody.parsePDB(pdb_file)
ligand = structure.select("hetero and not water and not ion")
if ligand is not None:
center = ligand.getCoords().mean(axis=0)
# Box size = ligand extent + padding
extent = ligand.getCoords().max(axis=0) - ligand.getCoords().min(axis=0)
box_size = extent + 10.0 # 10 Å padding on each side
print(f"Binding site center: {center}")
print(f"Box size: {box_size}")
else:
# Option B: Manual coordinates (from literature or visual inspection)
center = np.array([15.0, 54.0, 17.0])
box_size = np.array([25.0, 25.0, 25.0])
print(f"Using manual box: center={center}, size={box_size}")
Step 3: Prepare the Ligand from SMILES
Use RDKit for 3D coordinate generation and Meeko for PDBQT conversion.
from rdkit import Chem
from rdkit.Chem import AllChem
from meeko import MoleculePreparation, PDBQTWriterLegacy
# Define ligand (example: Indinavir, HIV protease inhibitor)
smiles = "CC(C)(C)NC(=O)[C@@H]1CN(CCc2ccccc2)C[C@H]1O"
mol_name = "indinavir_analog"
# Generate 3D coordinates with RDKit
mol = Chem.MolFromSmiles(smiles)
mol = Chem.AddHs(mol)
AllChem.EmbedMolecule(mol, randomSeed=42)
AllChem.MMFFOptimizeMolecule(mol)
# Convert to PDBQT using Meeko
preparator = MoleculePreparation()
mol_setups = preparator.prepare(mol)
# Write PDBQT string (for Vina API) or file
pdbqt_string = PDBQTWriterLegacy.write_string(mol_setups[0])[0]
ligand_pdbqt = f"{mol_name}.pdbqt"
with open(ligand_pdbqt, "w") as f:
f.write(pdbqt_string)
print(f"Ligand PDBQT: {ligand_pdbqt} ({mol.GetNumAtoms()} atoms)")
Step 4: Run Docking
Initialize AutoDock Vina, configure the search space, and execute docking.
from vina import Vina
# Initialize Vina
v = Vina(sf_name="vina", cpu=4)
# Load receptor and ligand
v.set_receptor(receptor_pdbqt)
v.set_ligand_from_file(ligand_pdbqt)
# Define search space
v.compute_vina_maps(
center=center.tolist(),
box_size=box_size.tolist(),
)
# Run docking
v.dock(
exhaustiveness=32, # Higher = more thorough (default 8)
n_poses=10, # Number of output poses
)
# Write output poses
output_file = f"{mol_name}_docked.pdbqt"
v.write_poses(output_file, n_poses=10, overwrite=True)
print(f"Docking complete. Poses written to {output_file}")
Step 5: Analyze Docking Results
Extract binding energies and RMSD values from the docked poses using Vina's built-in API.
# Method A: Vina built-in energies (most reliable)
energies = v.energies(n_poses=10)
# Each row: [total, inter, intra, torsions, intra_best_pose]
print(f"{'Pose':<6} {'Total (kcal/mol)':<18} {'Inter':<10} {'Intra':<10}")
print("-" * 44)
for i, e in enumerate(energies):
print(f"{i+1:<6} {e[0]:<18.2f} {e[1]:<10.2f} {e[2]:<10.2f}")
print(f"\nBest pose: {energies[0][0]:.2f} kcal/mol")
# Method B: Parse PDBQT output with Meeko (for RDKit conversion)
from meeko import PDBQTMolecule, RDKitMolCreate
pdbqt_mol = PDBQTMolecule.from_file(output_file)
best_pose_rdkit = RDKitMolCreate.from_pdbqt_mol(pdbqt_mol)[0]
print(f"Best pose converted to RDKit mol: {best_pose_rdkit.GetNumAtoms()} atoms")
Step 6: Visualize Docking Results
Generate a 3D visualization of the docked complex using py3Dmol.
import py3Dmol
# Load receptor and best docked pose
with open(f"{pdb_id}_protein.pdb") as f:
receptor_pdb = f.read()
with open(output_file) as f:
docked_pdbqt = f.read()
# Create 3D viewer
view = py3Dmol.view(width=800, height=600)
view.addModel(receptor_pdb, "pdb")
view.setStyle({"model": 0}, {"cartoon": {"color": "lightgrey"}})
# Add docked ligand (first model only)
first_model = docked_pdbqt.split("ENDMDL")[0] + "ENDMDL"
view.addModel(first_model, "pdb")
view.setStyle({"model": 1}, {"stick": {"colorscheme": "greenCarbon"}})
# Zoom to ligand
view.zoomTo({"model": 1})
view.show()
# In Jupyter: displays interactive 3D view
# To save: view.png() or view.write_html("docking_result.html")
print("3D visualization rendered")
Step 7: Batch Virtual Screening
Screen a library of compounds against the same receptor.
import pandas as pd
# Define compound library
compounds = pd.DataFrame({
"name": ["cpd_001", "cpd_002", "cpd_003", "cpd_004", "cpd_005"],
"smiles": [
"CC(=O)Oc1ccccc1C(=O)O", # Aspirin
"CC(C)Cc1ccc(cc1)C(C)C(=O)O", # Ibuprofen
"OC(=O)c1ccccc1O", # Salicylic acid
"CC12CCC3C(CCC4CC(=O)CCC34C)C1CCC2O", # Testosterone
"c1ccc2c(c1)cc1ccc3cccc4ccc2c1c34", # Pyrene
],
})
# Screen all compounds
results = []
for _, row in compounds.iterrows():
try:
# Prepare ligand
mol = Chem.MolFromSmiles(row["smiles"])
mol = Chem.AddHs(mol)
AllChem.EmbedMolecule(mol, randomSeed=42)
AllChem.MMFFOptimizeMolecule(mol)
mol_setups = preparator.prepare(mol)
pdbqt_str = PDBQTWriterLegacy.write_string(mol_setups[0])[0]
# Dock
v_screen = Vina(sf_name="vina", cpu=2)
v_screen.set_receptor(receptor_pdbqt)
v_screen.set_ligand_from_string(pdbqt_str)
v_screen.compute_vina_maps(center=center.tolist(), box_size=box_size.tolist())
v_screen.dock(exhaustiveness=16, n_poses=1)
energy = v_screen.energies(n_poses=1)[0][0]
results.append({"name": row["name"], "smiles": row["smiles"], "energy_kcal": energy})
print(f" {row['name']}: {energy:.2f} kcal/mol")
except Exception as e:
results.append({"name": row["name"], "smiles": row["smiles"], "energy_kcal": None})
print(f" {row['name']}: FAILED ({e})")
# Rank by binding energy
results_df = pd.DataFrame(results).sort_values("energy_kcal")
results_df.to_csv("screening_results.csv", index=False)
print(f"\nTop hits:\n{results_df.head()}")
Step 8: Save and Export
import os
os.makedirs("results", exist_ok=True)
# Save summary
results_df.to_csv("results/screening_results.csv", index=False)
# Save best poses for top hits
for _, row in results_df.head(3).iterrows():
print(f"Top hit: {row['name']} → {row['energy_kcal']:.2f} kcal/mol")
print("Virtual screening complete. Results in results/screening_results.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
exhaustiveness |
8 |
8-128 |
Search thoroughness; 32+ recommended for publication |
n_poses |
9 |
1-20 |
Number of output binding poses |
energy_range |
3.0 |
1.0-5.0 |
Max energy difference (kcal/mol) from best pose to include |
sf_name |
"vina" |
"vina", "ad4", "vinardo" |
Scoring function choice |
cpu |
all | 1-N |
Number of CPU cores for docking |
box_size (xyz) |
— | 15-30 Å per side |
Search space dimensions; must enclose binding site + 5-10Å padding |
center (xyz) |
— | Binding site coordinates | Center of the search box |
randomSeed (RDKit) |
random | any int | Reproducible 3D conformer generation |
padding (box) |
10.0 Å |
5.0-15.0 Å |
Extra space around known ligand for box definition |
Common Recipes
Recipe: Re-docking Validation (Cognate Docking)
When to use: validating your protocol by re-docking the co-crystallized ligand and checking RMSD < 2.0 Å.
from rdkit.Chem import AllChem, rdMolAlign
# Extract co-crystallized ligand from PDB
ref_ligand = Chem.MolFromPDBFile(f"{pdb_id}_ligand.pdb", removeHs=False)
# Dock the same ligand
# ... (use steps 3-4 above with the extracted ligand)
# Calculate RMSD between docked pose and crystal structure
rmsd = AllChem.GetBestRMS(ref_ligand, best_pose_rdkit)
print(f"Re-docking RMSD: {rmsd:.2f} Å")
print(f"Validation: {'PASS' if rmsd < 2.0 else 'FAIL'} (threshold: 2.0 Å)")
Recipe: Flexible Receptor Docking
When to use: key binding-site residues need conformational freedom (e.g., induced fit).
# Prepare receptor with flexible sidechains (using ADFR Suite)
# prepare_receptor -r protein.pdb -o rigid.pdbqt -A hydrogens
# prepare_flexreceptor -r rigid.pdbqt -s "A:ARG8,A:ASP25,A:ILE50"
v_flex = Vina(sf_name="vina", cpu=4)
v_flex.set_receptor("rigid.pdbqt", "flex.pdbqt") # rigid + flexible parts
v_flex.set_ligand_from_file(ligand_pdbqt)
v_flex.compute_vina_maps(center=center.tolist(), box_size=box_size.tolist())
v_flex.dock(exhaustiveness=64, n_poses=10)
v_flex.write_poses("docked_flex.pdbqt", n_poses=5, overwrite=True)
Recipe: Scoring Only (No Docking)
When to use: evaluating the binding energy of a pre-positioned ligand without running a full search.
v_score = Vina(sf_name="vina")
v_score.set_receptor(receptor_pdbqt)
v_score.set_ligand_from_file("pre_positioned_ligand.pdbqt")
v_score.compute_vina_maps(center=center.tolist(), box_size=box_size.tolist())
# Score current pose
energy = v_score.score()
print(f"Score: {energy[0]:.2f} kcal/mol")
# Local minimization
energy_min = v_score.optimize()
print(f"After local optimization: {energy_min[0]:.2f} kcal/mol")
v_score.write_pose("minimized.pdbqt", overwrite=True)
Recipe: Multiple Scoring Functions Comparison
When to use: consensus scoring to increase confidence in docking results.
scoring_results = {}
for sf in ["vina", "vinardo", "ad4"]:
v_sf = Vina(sf_name=sf, cpu=2)
v_sf.set_receptor(receptor_pdbqt)
v_sf.set_ligand_from_file(ligand_pdbqt)
v_sf.compute_vina_maps(center=center.tolist(), box_size=box_size.tolist())
v_sf.dock(exhaustiveness=16, n_poses=1)
scoring_results[sf] = v_sf.energies(n_poses=1)[0][0]
for sf, energy in scoring_results.items():
print(f" {sf}: {energy:.2f} kcal/mol")
Expected Outputs
{name}_docked.pdbqt— Docked poses in PDBQT format with binding energies in headerresults/screening_results.csv— Virtual screening results: compound name, SMILES, binding energy (kcal/mol){pdb_id}_receptor.pdbqt— Prepared receptor in PDBQT format- Figures: 3D docking visualization (interactive py3Dmol or static image)
- Console output: ranked binding energies and RMSD values per pose
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
prepare_receptor not found |
ADFR Suite not in PATH | Add ADFR Suite bin to $PATH or use full path |
RuntimeError: receptor not set |
Forgot to call set_receptor |
Call v.set_receptor(pdbqt_file) before docking |
| Very positive docking scores (>0) | Ligand outside box or bad geometry | Check box center/size covers binding site; verify 3D coords |
| All poses identical | exhaustiveness too low |
Increase to 32-64 for reliable sampling |
Meeko MoleculePreparation error |
Missing hydrogens on input mol | Always call Chem.AddHs(mol) before Meeko |
| RMSD > 2Å in re-docking | Box too small or wrong center | Expand box by 5Å; verify center on co-crystallized ligand |
EmbedMolecule returns -1 |
RDKit failed to generate 3D coords | Use AllChem.EmbedMolecule(mol, maxAttempts=1000) or try useRandomCoords=True |
| Slow screening (>1min/compound) | High exhaustiveness + large box | Reduce exhaustiveness to 8-16 for screening; narrow box |
PDBQTWriterLegacy not found |
Old Meeko version | pip install meeko>=0.5 — API changed from write_pdbqt_string |
| Inconsistent energies across runs | Non-deterministic search | Set seed parameter in v.dock(seed=42) for reproducibility |
Bundled Resources
This skill includes reference files for deeper lookup. Read these on demand.
references/receptor_preparation_guide.md
Detailed guide for receptor preparation: handling missing residues, protonation states (pH-dependent), metal ions, cofactors, and multi-chain complexes. Decision tree for when to use PDB2PQR, PROPKA, or manual protonation.
references/scoring_functions_comparison.md
Comparison of Vina, Vinardo, and AD4 scoring functions: accuracy benchmarks, speed trade-offs, and recommendations by target class (kinase, protease, GPCR, nuclear receptor).
References
- AutoDock Vina documentation — Official docs and Python API (Apache-2.0)
- Eberhardt et al. (2021) — "AutoDock Vina 1.2.0: New Docking Methods, Expanded Force Field, and Python Bindings", J Chem Inf Model
- Meeko: Preparation of small molecules for AutoDock — Ligand PDBQT preparation from RDKit (LGPL)
- ADFR Suite — Receptor preparation tools from Scripps (free academic license)
- Forli et al. (2016) — "Computational protein-ligand docking and virtual drug screening with the AutoDock suite", Nat Protoc
- RDKit documentation — Cheminformatics toolkit for ligand handling (BSD)
skills/structural-biology-drug-discovery/chembl-database-bioactivity/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill chembl-database-bioactivity -g -y
SKILL.md
Frontmatter
{
"name": "chembl-database-bioactivity",
"license": "CC-BY-SA-3.0",
"description": "Query ChEMBL (2M+ compounds, 19M+ bioactivity measurements, 13K+ targets) via the public REST\/JSON API with plain `requests` — no SDK install required. Search compounds, retrieve IC50\/Ki\/EC50 bioactivities, find target inhibitors, run SAR, access drug mechanism\/indication data."
}
ChEMBL Database — Bioactivity Queries
Why no SDK? The
chembl_webresource_clientpackage is convenient sugar over a public, no-auth REST/JSON API athttps://www.ebi.ac.uk/chembl/api/data/. When the SDK is unavailable, every operation can be reproduced with plainrequestsand URL parameters. This SKILL.md uses the REST path throughout so the code runs in any environment withrequestsinstalled. Django-style filter syntax (field__icontains=…,field__lte=…,field__range=a,b) works as URL query parameters.
Overview
ChEMBL is EMBL-EBI's bioactive molecule database: 2M+ compounds, 19M+ bioactivity measurements (IC50, Ki, EC50, Kd, …), 13K+ targets. The REST API at https://www.ebi.ac.uk/chembl/api/data/ returns JSON (append .json) or XML/YAML, requires no authentication, and supports Django-style query filters via URL parameters plus cursor-style pagination via page_meta.next.
When to Use
- Finding compounds by name, ChEMBL ID, or physicochemical properties
- Querying bioactivity data (IC50, Ki, EC50) for specific targets
- Performing similarity or substructure searches using SMILES
- Retrieving drug mechanisms of action and clinical indications
- Identifying inhibitors, agonists, or bioactive molecules for a target
- Analyzing structure-activity relationships (SAR) across compound series
- Filtering molecules by Lipinski rule-of-5 or other drug-likeness criteria
- For general cheminformatics (SMILES manipulation, fingerprints, descriptors) use
rdkit-cheminformaticsinstead - For an alternative compound database (NIH, broader coverage) use
pubchem-compound-search
Prerequisites
- Python packages:
requests(only requirement). Optional:pandasfor tabular analysis. - No API key required: ChEMBL is freely accessible.
- Rate limits: No published hard limit. The infrastructure is shared — add
time.sleep(0.2-0.5)between requests in batch loops; back off on HTTP 429.
pip install requests
# Optional, for DataFrame work:
pip install pandas
Quick Start
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Retrieve a molecule by ChEMBL ID
r = requests.get(f"{BASE}/molecule/CHEMBL25.json", timeout=15)
r.raise_for_status()
aspirin = r.json()
print(f"{aspirin['pref_name']}: MW={aspirin['molecule_properties']['mw_freebase']}")
# ASPIRIN: MW=180.16
# Search targets by full name (acronyms like 'EGFR' don't match pref_name — use full term)
r = requests.get(
f"{BASE}/target.json",
params={"pref_name__icontains": "epidermal growth factor receptor",
"target_type": "SINGLE PROTEIN", "limit": 5},
timeout=15,
)
targets = r.json()["targets"]
print(f"EGFR-like targets: {len(targets)}, first={targets[0]['target_chembl_id']}")
# Potent bioactivities: EGFR (CHEMBL203) IC50 <= 100 nM
r = requests.get(
f"{BASE}/activity.json",
params={"target_chembl_id": "CHEMBL203",
"standard_type": "IC50",
"standard_value__lte": 100,
"standard_units": "nM",
"limit": 5},
timeout=30,
)
data = r.json()
print(f"EGFR IC50 ≤ 100 nM records: {data['page_meta']['total_count']}")
Key Concepts
Filter Operators (Django-style, as URL parameters)
The SDK's field__operator=value syntax maps 1:1 to URL query parameters. Use & to combine filters.
| Operator | URL pattern | Example URL fragment |
|---|---|---|
__exact |
field=value |
target_type=SINGLE+PROTEIN |
__iexact |
field__iexact=value |
pref_name__iexact=aspirin |
__contains / __icontains |
field__icontains=value |
pref_name__icontains=kinase |
__startswith / __endswith |
field__startswith=Epi |
pref_name__endswith=nib |
__gt / __gte / __lt / __lte |
field__lte=100 |
standard_value__lte=100 |
__range |
field__range=lo,hi |
molecule_properties__mw_freebase__range=300,500 |
__in |
field__in=a,b,c |
standard_type__in=IC50,Ki,Kd |
__isnull |
field__isnull=False (Python False/True strings) |
pchembl_value__isnull=False |
__regex |
field__regex=… |
pref_name__regex=^EGF.*kinase$ |
__search |
field__search=… |
description__search=apoptosis |
When passed via requests.get(..., params={...}), the library handles URL encoding automatically (including the commas in __range and __in).
Core Endpoints
All endpoints accept .json, .xml, or .yaml suffix. JSON is the default below.
| Endpoint URL | Returns | Key fields |
|---|---|---|
/molecule/{chembl_id}.json |
Compound by ID | pref_name, molecule_chembl_id, molecule_properties, molecule_structures |
/molecule.json?<filters> |
Compound search | paginated molecules[] |
/target/{chembl_id}.json |
Target by ID | pref_name, target_type, organism, target_components |
/target.json?<filters> |
Target search | paginated targets[] |
/activity.json?<filters> |
Bioactivity records | paginated activities[] |
/assay.json?<filters> |
Assay details | paginated assays[] |
/drug.json?<filters> |
Approved drug info | paginated drugs[]; supports /drug/{chembl_id}.json |
/mechanism.json?<filters> |
Mechanism of action | paginated mechanisms[] |
/drug_indication.json?<filters> |
Therapeutic indications | paginated drug_indications[] |
/similarity/{smiles}/{tanimoto}.json |
Tanimoto similarity (0–100) | paginated molecules[] with similarity field |
/substructure/{smiles}.json |
Substructure search | paginated molecules[] |
/image/{chembl_id}.svg |
SVG structure image | binary SVG (NOT JSON) |
/molecule_form/{chembl_id}.json |
Parent/salt forms | molecule_forms[] |
/protein_class.json |
Protein classification hierarchy | hierarchical browse |
/document.json?<filters> |
Literature source records | paginated documents[] |
Response Shape
{
"page_meta": {
"limit": 20,
"offset": 0,
"total_count": 12145,
"next": "/chembl/api/data/activity.json?...&offset=20",
"previous": null
},
"activities": [ /* or molecules[], targets[], etc. */ ]
}
Walk page_meta.next (a relative URL — prefix with https://www.ebi.ac.uk) until it becomes null.
Molecular Properties
Properties accessible via molecule_properties on each record:
| Field | Description |
|---|---|
mw_freebase |
Molecular weight (free base) |
full_mwt |
Full molecular weight (including salts) |
alogp |
Calculated LogP |
hba |
Hydrogen bond acceptors |
hbd |
Hydrogen bond donors |
psa |
Polar surface area |
rtb |
Rotatable bonds |
num_ro5_violations |
Lipinski rule-of-5 violations |
ro3_pass |
Rule of 3 compliance |
cx_most_apka / cx_most_bpka |
Most acidic / basic pKa |
Target Information Fields
| Field | Description |
|---|---|
target_chembl_id |
ChEMBL target identifier |
pref_name |
Preferred (full) target name — acronyms like "EGFR" do NOT match; use the spelled-out term |
target_type |
SINGLE PROTEIN, PROTEIN COMPLEX, ORGANISM, … |
organism |
Target organism (e.g., Homo sapiens) |
tax_id |
NCBI taxonomy ID |
target_components[] |
Components (UniProt accession, sequence, …) |
Bioactivity Data Fields
| Field | Description |
|---|---|
standard_type |
Activity type: IC50, Ki, Kd, EC50, … |
standard_value |
Numerical activity value |
standard_units |
Units: nM, uM, … |
pchembl_value |
Normalized -log10 activity (>6 = potent) |
activity_comment |
Activity annotations |
data_validity_comment |
Data quality flags (check before analysis) |
potential_duplicate |
Duplicate flag |
Core API
1. Molecule Queries
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# By ChEMBL ID
r = requests.get(f"{BASE}/molecule/CHEMBL25.json", timeout=15)
aspirin = r.json()
print(f"{aspirin['pref_name']}: MW={aspirin['molecule_properties']['mw_freebase']}")
# By name (case-insensitive substring)
r = requests.get(f"{BASE}/molecule.json",
params={"pref_name__icontains": "imatinib", "limit": 5},
timeout=15)
for mol in r.json()["molecules"]:
print(f" {mol['molecule_chembl_id']} {mol.get('pref_name')!r}")
# By Lipinski-compliant property ranges
r = requests.get(f"{BASE}/molecule.json",
params={"molecule_properties__mw_freebase__range": "300,500",
"molecule_properties__alogp__lte": 5,
"molecule_properties__hba__lte": 10,
"molecule_properties__hbd__lte": 5,
"limit": 3},
timeout=15)
print(f"Lipinski-compliant total: {r.json()['page_meta']['total_count']}")
2. Target Queries
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# By ChEMBL ID
r = requests.get(f"{BASE}/target/CHEMBL203.json", timeout=15)
egfr = r.json()
print(f"{egfr['pref_name']} ({egfr['organism']}) — type={egfr['target_type']}")
# Search by full name (NOT acronym) + type
r = requests.get(f"{BASE}/target.json",
params={"pref_name__icontains": "kinase",
"target_type": "SINGLE PROTEIN", "limit": 5},
timeout=15)
d = r.json()
print(f"Kinase SINGLE_PROTEIN targets: total={d['page_meta']['total_count']}")
for t in d["targets"][:5]:
print(f" {t['target_chembl_id']:12s} {t.get('pref_name')!r} ({t['organism']})")
# By organism
r = requests.get(f"{BASE}/target.json",
params={"organism": "Homo sapiens", "limit": 3},
timeout=15)
print(f"Human targets: total={r.json()['page_meta']['total_count']}")
3. Bioactivity Data
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Potent inhibitors for a target (EGFR = CHEMBL203)
r = requests.get(f"{BASE}/activity.json",
params={"target_chembl_id": "CHEMBL203",
"standard_type": "IC50",
"standard_value__lte": 100,
"standard_units": "nM",
"limit": 5},
timeout=30)
data = r.json()
print(f"EGFR IC50≤100nM: total={data['page_meta']['total_count']}")
for act in data["activities"][:5]:
print(f" {act['molecule_chembl_id']:14s} IC50={act['standard_value']} nM "
f"pChEMBL={act.get('pchembl_value')}")
# All pChEMBL-tagged activities for a compound
r = requests.get(f"{BASE}/activity.json",
params={"molecule_chembl_id": "CHEMBL25",
"pchembl_value__isnull": "False",
"limit": 5},
timeout=30)
print(f"Aspirin pChEMBL activities: total={r.json()['page_meta']['total_count']}")
# Multiple activity types (CHEMBL240 = D2 dopamine receptor)
r = requests.get(f"{BASE}/activity.json",
params={"target_chembl_id": "CHEMBL240",
"standard_type__in": "IC50,Ki,Kd",
"limit": 5},
timeout=30)
print(f"D2 receptor IC50/Ki/Kd: total={r.json()['page_meta']['total_count']}")
4. Structure-Based Search
import requests
from urllib.parse import quote
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Similarity search (Tanimoto ≥ 85%)
# Path-style endpoint: /similarity/{smiles}/{threshold}
# The SMILES MUST be URL-encoded (it contains '/', '(', ')' etc.)
aspirin_smiles = quote("CC(=O)Oc1ccccc1C(=O)O", safe="")
r = requests.get(f"{BASE}/similarity/{aspirin_smiles}/85.json",
params={"limit": 5}, timeout=30)
data = r.json()
print(f"Similar to aspirin (≥85% Tanimoto): total={data['page_meta']['total_count']}")
for m in data["molecules"][:5]:
print(f" {m['molecule_chembl_id']} similarity={m.get('similarity')}")
# Substructure search
benzimidazole = quote("c1ccc2[nH]cnc2c1", safe="")
r = requests.get(f"{BASE}/substructure/{benzimidazole}.json",
params={"limit": 3}, timeout=30)
print(f"Benzimidazole substructure total: {r.json()['page_meta']['total_count']}")
5. Drug and Mechanism Data
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Drug record (max clinical phase, ATC class, etc.)
r = requests.get(f"{BASE}/drug/CHEMBL941.json", timeout=15) # imatinib
drug = r.json()
print(f"Imatinib max_phase={drug.get('max_phase')}")
# Mechanisms of action — note: not every drug has mechanism records.
# Imatinib (CHEMBL941) returns 0 mechanism rows; sunitinib (CHEMBL535) has many.
r = requests.get(f"{BASE}/mechanism.json",
params={"molecule_chembl_id": "CHEMBL535"}, timeout=15)
for m in r.json()["mechanisms"]:
print(f" {m['mechanism_of_action']} → target {m.get('target_chembl_id')}")
# Therapeutic indications
r = requests.get(f"{BASE}/drug_indication.json",
params={"molecule_chembl_id": "CHEMBL941", "limit": 5},
timeout=15)
for ind in r.json()["drug_indications"]:
print(f" {ind.get('mesh_heading')!r} max_phase_for_ind={ind.get('max_phase_for_ind')}")
# SVG molecular structure image — direct binary response, NOT JSON
# Do NOT call /image/{cid}.json — that endpoint raises JSONDecodeError.
r = requests.get(f"{BASE}/image/CHEMBL25.svg", timeout=15)
r.raise_for_status()
with open("aspirin.svg", "w") as f:
f.write(r.text)
print(f"Saved aspirin.svg ({len(r.text)} bytes, looks_svg={'<svg' in r.text})")
Common Workflows
Workflow 1: Find Inhibitors for a Target
Note: pref_name__icontains matches the spelled-out name. Acronyms like 'EGFR' or 'BRAF' return 0 results — use 'epidermal growth factor receptor' or 'B-raf' (with the hyphen).
import requests, pandas as pd, time
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Step 1: Resolve the target by full name
r = requests.get(f"{BASE}/target.json",
params={"pref_name__icontains": "B-raf",
"target_type": "SINGLE PROTEIN", "limit": 5},
timeout=15)
targets = r.json()["targets"]
human_braf = next(t for t in targets if t["organism"] == "Homo sapiens")
target_id = human_braf["target_chembl_id"]
print(f"Using {target_id} — {human_braf['pref_name']}")
# Step 2: Paginate all potent IC50 activities (cap at 500 for demo)
url = (f"{BASE}/activity.json"
f"?target_chembl_id={target_id}"
f"&standard_type=IC50"
f"&standard_value__lte=100"
f"&standard_units=nM"
f"&pchembl_value__isnull=False"
f"&limit=200")
records = []
while url and len(records) < 500:
r = requests.get(url, timeout=30)
r.raise_for_status()
data = r.json()
records.extend(data["activities"])
nxt = data["page_meta"].get("next")
url = f"https://www.ebi.ac.uk{nxt}" if nxt else None
time.sleep(0.2)
df = pd.DataFrame(records)
df["standard_value"] = pd.to_numeric(df["standard_value"])
print(f"Retrieved {len(df)} potent {target_id} compounds")
print(df[["molecule_chembl_id", "standard_value", "pchembl_value"]].head(10))
Workflow 2: Analyze a Known Drug
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Sunitinib (CHEMBL535) — has documented mechanisms + indications.
# Imatinib (CHEMBL941) sometimes returns 0 mechanism rows depending on ChEMBL release.
chembl_id = "CHEMBL535"
# Molecule record
m = requests.get(f"{BASE}/molecule/{chembl_id}.json", timeout=15).json()
print(f"Name: {m['pref_name']}")
print(f"MW : {m['molecule_properties']['mw_freebase']}")
# Mechanisms
mechs = requests.get(f"{BASE}/mechanism.json",
params={"molecule_chembl_id": chembl_id},
timeout=15).json()["mechanisms"]
for mc in mechs:
print(f" Mechanism: {mc['mechanism_of_action']}")
# Indications
inds = requests.get(f"{BASE}/drug_indication.json",
params={"molecule_chembl_id": chembl_id, "limit": 5},
timeout=15).json()["drug_indications"]
for ind in inds:
print(f" Indication: {ind.get('mesh_heading')} "
f"(Phase {ind.get('max_phase_for_ind')})")
# Bioactivity record count
total = requests.get(f"{BASE}/activity.json",
params={"molecule_chembl_id": chembl_id,
"pchembl_value__isnull": "False",
"limit": 1},
timeout=30).json()["page_meta"]["total_count"]
print(f"Total bioactivity records (pChEMBL-tagged): {total}")
Workflow 3: SAR Study
import requests, pandas as pd, time
from urllib.parse import quote
BASE = "https://www.ebi.ac.uk/chembl/api/data"
# Step 1: Similar compounds to a lead (e.g., quinoline scaffold)
lead_smiles = "c1ccc2c(c1)cc(nc2N)c3ccc(cc3)NC(=O)c4ccccc4"
r = requests.get(f"{BASE}/similarity/{quote(lead_smiles, safe='')}/80.json",
params={"limit": 20}, timeout=30)
analogs = r.json()["molecules"]
print(f"Analogs found: {len(analogs)}")
# Step 2: Collect bioactivities for each analog
records = []
for compound in analogs[:20]:
cid = compound["molecule_chembl_id"]
acts = requests.get(f"{BASE}/activity.json",
params={"molecule_chembl_id": cid,
"standard_type": "IC50",
"pchembl_value__isnull": "False",
"limit": 20},
timeout=30).json()["activities"]
for act in acts:
records.append({
"chembl_id": cid,
"target": act.get("target_pref_name"),
"IC50_nM": act.get("standard_value"),
"pchembl": act.get("pchembl_value"),
"mw": (compound.get("molecule_properties") or {}).get("mw_freebase"),
"alogp": (compound.get("molecule_properties") or {}).get("alogp"),
})
time.sleep(0.2)
df = pd.DataFrame(records)
if not df.empty:
df["IC50_nM"] = pd.to_numeric(df["IC50_nM"])
print(df.groupby("target")["IC50_nM"].describe())
Common Recipes
Recipe: Virtual Screening Filter (Lipinski rule-of-5)
import requests
BASE = "https://www.ebi.ac.uk/chembl/api/data"
r = requests.get(f"{BASE}/molecule.json",
params={"molecule_properties__mw_freebase__range": "300,500",
"molecule_properties__alogp__lte": 5,
"molecule_properties__hba__lte": 10,
"molecule_properties__hbd__lte": 5,
"molecule_properties__num_ro5_violations": 0,
"limit": 1},
timeout=15)
print(f"Drug-like candidates: {r.json()['page_meta']['total_count']}")
Recipe: Paginate Activities to CSV
import requests, pandas as pd, time
BASE = "https://www.ebi.ac.uk/chembl/api/data"
url = (f"{BASE}/activity.json"
f"?target_chembl_id=CHEMBL203"
f"&standard_type=IC50"
f"&pchembl_value__isnull=False"
f"&limit=500")
all_acts = []
while url:
r = requests.get(url, timeout=60)
r.raise_for_status()
data = r.json()
all_acts.extend(data["activities"])
nxt = data["page_meta"].get("next")
url = f"https://www.ebi.ac.uk{nxt}" if nxt else None
time.sleep(0.3)
df = pd.DataFrame(all_acts)
df.to_csv("egfr_activities.csv", index=False)
print(f"Exported {len(df)} records → egfr_activities.csv")
Recipe: Robust Session with Retries
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def chembl_session(retries=3, backoff=1.0):
s = requests.Session()
s.headers.update({"Accept": "application/json"})
s.mount("https://", HTTPAdapter(max_retries=Retry(
total=retries, backoff_factor=backoff,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"])))
return s
session = chembl_session()
r = session.get("https://www.ebi.ac.uk/chembl/api/data/molecule/CHEMBL25.json", timeout=15)
print(r.json()["pref_name"])
Recipe: Download SVG Structure Image
import requests
r = requests.get("https://www.ebi.ac.uk/chembl/api/data/image/CHEMBL25.svg", timeout=15)
r.raise_for_status()
with open("aspirin.svg", "w") as f:
f.write(r.text)
Key Parameters
| Parameter | Endpoint | Default | Description |
|---|---|---|---|
limit |
all list endpoints | 20 |
Page size; max 1000 |
offset |
all list endpoints | 0 |
Pagination offset (or follow page_meta.next) |
format |
all endpoints | json (via .json suffix) |
Also .xml, .yaml |
pref_name__icontains |
/target, /molecule |
— | Substring on full name; acronyms don't match, use full term |
target_chembl_id |
/activity |
— | E.g., CHEMBL203 (EGFR), CHEMBL240 (D2 receptor) |
molecule_chembl_id |
/activity, /mechanism, /drug_indication |
— | E.g., CHEMBL25 (aspirin) |
standard_type |
/activity |
— | IC50, Ki, Kd, EC50 |
standard_value__lte |
/activity |
— | Max activity value (paired with standard_units) |
pchembl_value__isnull |
/activity |
— | "False" to require pChEMBL-tagged data |
target_type |
/target |
— | SINGLE PROTEIN, PROTEIN COMPLEX, ORGANISM, … |
{tanimoto} (path) |
/similarity/{smiles}/{tanimoto} |
— | 0–100 Tanimoto threshold |
{smiles} (path) |
/similarity, /substructure |
— | URL-encoded SMILES (urllib.parse.quote(s, safe="")) |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
pref_name__icontains=EGFR (or BRAF) returns 0 |
ChEMBL stores spelled-out names; acronyms don't match | Use "epidermal growth factor receptor"; for BRAF use "B-raf" with the hyphen |
mechanism.json?molecule_chembl_id=CHEMBL941 returns empty |
Not every drug has mechanism rows in every release (e.g., imatinib has 0 in current data) | Use CHEMBL535 (sunitinib) or CHEMBL192 (sildenafil) as known-populated examples |
JSONDecodeError on /image/{cid}.json |
The image endpoint is binary, not JSON | Always use .svg or .png suffix: /image/{cid}.svg |
404 on /molecule/{id} |
Invalid ChEMBL ID format | IDs must include the prefix: CHEMBL25, not 25 |
| 400 on similarity search | Unencoded SMILES (/ collides with URL path) |
URL-encode: urllib.parse.quote(smiles, safe="") |
Empty next page but total_count higher |
Reached internal limit (typically 10000 with offset pagination) |
Narrow filters (date range, target class) and re-paginate; or use the ChEMBL FTP downloads for >100K records |
HTTP 429 Too Many Requests |
Burst pace | Add time.sleep(0.3); mount a Retry adapter (see Recipe) |
Mixed units in activity records |
Different assays report in nM / µM / % inhibition | Filter standard_units="nM" and prefer pchembl_value for cross-assay comparison |
data_validity_comment is non-empty |
Curation flag (e.g., "Potential transcription error", "Outside typical range") | Drop these rows before SAR/regression analysis |
| Duplicate activity records | Same measurement reported in multiple sources | Check potential_duplicate=True and dedupe |
Best Practices
- Use
pchembl_valuefor cross-study comparisons — it normalizes IC50/Ki/EC50 to a comparable -log10 scale. - Always check
data_validity_commentbefore computing aggregates — flagged rows can skew distributions. - Pin
standard_units="nM"in activity queries to avoid mixing nM with µM. - Follow
page_meta.nextfor pagination instead of incrementingoffsetmanually — the URL already carries the right cursor. - URL-encode SMILES in path-style endpoints (
/similarity/{smiles}/...,/substructure/{smiles}) withurllib.parse.quote(smi, safe=""). - Use a
Sessionwith retry adapter for batch work (see Recipe) — ChEMBL handles a fair amount of traffic and occasionally returns 502/503. - For >100K records prefer the ChEMBL FTP downloads over paginated API calls.
- Be deliberate about acronyms in
pref_name__icontains—EGFR,BRAF,HER2all return 0 hits. Use the spelled-out term or filter viatarget_components__accession=<UniProt>instead.
Related Skills
rdkit-cheminformatics— SMILES manipulation, fingerprints, descriptorsdatamol-cheminformatics— molecular preprocessing & featurizationpubchem-compound-search— alternative compound database (NIH; broader coverage but less bioactivity depth)pdb-database— 3D structures of ChEMBL targets via RCSB PDB REST APIopentargets-database— links ChEMBL drug-target evidence to disease associations
References
- ChEMBL website: https://www.ebi.ac.uk/chembl/
- REST API root: https://www.ebi.ac.uk/chembl/api/data/
- API docs: https://www.ebi.ac.uk/chembl/api/data/docs
- Interface docs (Django filter syntax): https://chembl.gitbook.io/chembl-interface-documentation/web-services
- Bulk downloads (for >100K records): https://chembl.gitbook.io/chembl-interface-documentation/downloads
- For SDK-based usage, see the
chembl_webresource_clientPyPI package; this SKILL.md uses the underlying REST API directly so no SDK install is needed.
skills/structural-biology-drug-discovery/clinicaltrials-database-search/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill clinicaltrials-database-search -g -y
SKILL.md
Frontmatter
{
"name": "clinicaltrials-database-search",
"license": "CC-BY-4.0",
"description": "Query ClinicalTrials.gov API v2 for trial data. Search by condition, drug\/intervention, location, sponsor, or phase; fetch details by NCT ID; filter by status; paginate; export CSV. For clinical research, patient matching, and trial portfolio analysis."
}
ClinicalTrials.gov Database — Clinical Trial Search
Overview
Query the ClinicalTrials.gov API v2 (public, no authentication) to search and retrieve clinical trial data worldwide. Supports searching by condition, intervention, location, sponsor, and status; retrieving detailed study information by NCT ID; paginating large result sets; and exporting to CSV.
When to Use
- Searching for recruiting clinical trials for a specific condition or disease
- Finding trials testing a specific drug, device, or intervention
- Locating trials in a specific geographic region for patient referral
- Tracking a sponsor's or institution's clinical trial portfolio
- Retrieving detailed eligibility criteria, outcomes, and contacts for a specific trial
- Analyzing clinical trial trends (phases, enrollment, timelines) across a therapeutic area
- Exporting trial data for systematic reviews or meta-analyses
- Monitoring trial status changes and results postings
- For chemical compound bioactivity data use chembl-database-bioactivity instead; for published literature use pubmed-database
Prerequisites
uv pip install requests pandas
API details:
- Base URL:
https://clinicaltrials.gov/api/v2 - Authentication: None required (public API)
- Rate limit: ~50 requests/minute per IP
- Response formats: JSON (default), CSV
- Max page size: 1000 studies per request
- Date format: ISO 8601; text fields use CommonMark Markdown
Quick Start
import requests
import time
CT_API = "https://clinicaltrials.gov/api/v2"
def ct_search(params):
"""Reusable helper for ClinicalTrials.gov searches."""
response = requests.get(f"{CT_API}/studies", params=params, timeout=30)
response.raise_for_status()
return response.json()
# Search for recruiting breast cancer trials
results = ct_search({
"query.cond": "breast cancer",
"filter.overallStatus": "RECRUITING",
"pageSize": 10,
"sort": "LastUpdatePostDate:desc"
})
print(f"Found {results['totalCount']} trials")
for study in results['studies'][:3]:
nct = study['protocolSection']['identificationModule']['nctId']
title = study['protocolSection']['identificationModule']['briefTitle']
print(f" {nct}: {title}")
Key Concepts
Response Data Structure
ClinicalTrials.gov returns deeply nested JSON. Key navigation paths:
| Data | Path |
|---|---|
| NCT ID | study['protocolSection']['identificationModule']['nctId'] |
| Title | study['protocolSection']['identificationModule']['briefTitle'] |
| Status | study['protocolSection']['statusModule']['overallStatus'] |
| Phase | study['protocolSection']['designModule']['phases'] |
| Enrollment | study['protocolSection']['designModule']['enrollmentInfo']['count'] |
| Eligibility | study['protocolSection']['eligibilityModule'] |
| Locations | study['protocolSection']['contactsLocationsModule']['locations'] |
| Interventions | study['protocolSection']['armsInterventionsModule']['interventions'] |
| Results | study.get('resultsSection') (None if no results posted) |
Study Status Values
| Status | Description |
|---|---|
RECRUITING |
Currently recruiting participants |
NOT_YET_RECRUITING |
Approved but not yet open |
ENROLLING_BY_INVITATION |
Invitation-only enrollment |
ACTIVE_NOT_RECRUITING |
Active, enrollment closed |
SUSPENDED |
Temporarily halted |
TERMINATED |
Stopped prematurely |
COMPLETED |
Study concluded |
WITHDRAWN |
Withdrawn before enrollment |
Study Phase Values
| Phase | Description |
|---|---|
EARLY_PHASE1 |
Early Phase 1 (formerly Phase 0) |
PHASE1 |
Phase 1 — safety and dosing |
PHASE2 |
Phase 2 — efficacy and side effects |
PHASE3 |
Phase 3 — large-scale efficacy |
PHASE4 |
Phase 4 — post-market surveillance |
NA |
Not applicable (non-drug studies) |
Query Parameters Reference
| Parameter | Type | Description | Example |
|---|---|---|---|
query.cond |
string | Condition/disease | lung cancer |
query.intr |
string | Intervention/drug | Pembrolizumab |
query.locn |
string | Geographic location | New York |
query.spons |
string | Sponsor name | National Cancer Institute |
query.term |
string | General full-text search | immunotherapy |
filter.overallStatus |
string | Status filter (comma-separated) | RECRUITING,COMPLETED |
filter.phase |
string | Phase filter | PHASE2,PHASE3 |
filter.ids |
string | NCT ID filter | NCT04852770 |
sort |
string | Sort order | LastUpdatePostDate:desc |
pageSize |
int | Results per page (max 1000) | 100 |
pageToken |
string | Pagination token | (from previous response) |
format |
string | Response format | json or csv |
Sort options: LastUpdatePostDate, EnrollmentCount, StartDate, StudyFirstPostDate — each with :asc or :desc.
Core API
1. Search by Condition
results = ct_search({
"query.cond": "type 2 diabetes",
"filter.overallStatus": "RECRUITING",
"pageSize": 20,
"sort": "LastUpdatePostDate:desc"
})
print(f"Found {results['totalCount']} recruiting diabetes trials")
for study in results['studies'][:5]:
proto = study['protocolSection']
nct = proto['identificationModule']['nctId']
title = proto['identificationModule']['briefTitle']
print(f" {nct}: {title}")
2. Search by Intervention/Drug
# Find Phase 3 trials testing Pembrolizumab
results = ct_search({
"query.intr": "Pembrolizumab",
"filter.overallStatus": "RECRUITING,ACTIVE_NOT_RECRUITING",
"filter.phase": "PHASE3",
"pageSize": 50
})
print(f"Phase 3 Pembrolizumab trials: {results['totalCount']}")
3. Search by Location
results = ct_search({
"query.cond": "cancer",
"query.locn": "New York",
"filter.overallStatus": "RECRUITING",
"pageSize": 20
})
# Extract location details
for study in results['studies'][:3]:
locs = study['protocolSection'].get('contactsLocationsModule', {}).get('locations', [])
for loc in locs:
if 'New York' in loc.get('city', ''):
print(f" {loc.get('facility')}: {loc['city']}, {loc.get('state', '')}")
4. Search by Sponsor
results = ct_search({
"query.spons": "National Cancer Institute",
"pageSize": 20
})
for study in results['studies'][:5]:
sponsor_mod = study['protocolSection']['sponsorCollaboratorsModule']
lead = sponsor_mod['leadSponsor']['name']
collabs = [c['name'] for c in sponsor_mod.get('collaborators', [])]
print(f" Lead: {lead}, Collaborators: {collabs}")
5. Retrieve Study Details by NCT ID
nct_id = "NCT04852770"
response = requests.get(f"{CT_API}/studies/{nct_id}", timeout=30)
response.raise_for_status()
study = response.json()
# Extract key information
proto = study['protocolSection']
print(f"Title: {proto['identificationModule']['briefTitle']}")
print(f"Status: {proto['statusModule']['overallStatus']}")
# Eligibility criteria
elig = proto.get('eligibilityModule', {})
print(f"Ages: {elig.get('minimumAge')} - {elig.get('maximumAge')}")
print(f"Sex: {elig.get('sex')}")
print(f"Criteria:\n{elig.get('eligibilityCriteria', 'N/A')[:300]}")
6. Pagination for Large Result Sets
all_studies = []
page_token = None
max_pages = 10
for page in range(max_pages):
params = {
"query.cond": "cancer",
"filter.overallStatus": "RECRUITING",
"pageSize": 1000,
}
if page_token:
params["pageToken"] = page_token
results = ct_search(params)
all_studies.extend(results['studies'])
page_token = results.get('nextPageToken')
if not page_token:
break
time.sleep(1.5) # respect rate limits
print(f"Retrieved {len(all_studies)} studies across {page + 1} pages")
7. Export to CSV
response = requests.get(f"{CT_API}/studies", params={
"query.cond": "heart disease",
"filter.overallStatus": "RECRUITING",
"format": "csv",
"pageSize": 1000
}, timeout=60)
with open("heart_disease_trials.csv", "w") as f:
f.write(response.text)
print("Exported to heart_disease_trials.csv")
Common Workflows
Workflow 1: Multi-Criteria Trial Discovery
import requests, time
CT_API = "https://clinicaltrials.gov/api/v2"
def ct_search(params):
response = requests.get(f"{CT_API}/studies", params=params, timeout=30)
response.raise_for_status()
return response.json()
# Step 1: Search with multiple filters
results = ct_search({
"query.cond": "lung cancer",
"query.intr": "immunotherapy",
"query.locn": "California",
"filter.overallStatus": "RECRUITING,NOT_YET_RECRUITING",
"pageSize": 100,
"sort": "LastUpdatePostDate:desc"
})
print(f"Total matches: {results['totalCount']}")
# Step 2: Filter by phase
phase23 = [
s for s in results['studies']
if any(p in ['PHASE2', 'PHASE3']
for p in s['protocolSection'].get('designModule', {}).get('phases', []))
]
print(f"Phase 2/3 trials: {len(phase23)}")
# Step 3: Extract summaries
for study in phase23[:5]:
proto = study['protocolSection']
nct = proto['identificationModule']['nctId']
title = proto['identificationModule']['briefTitle']
enrollment = proto.get('designModule', {}).get('enrollmentInfo', {}).get('count', 'N/A')
print(f" {nct}: {title} (n={enrollment})")
Workflow 2: Completed Trials with Results Analysis
# Step 1: Find completed trials with posted results
results = ct_search({
"query.cond": "alzheimer disease",
"filter.overallStatus": "COMPLETED",
"pageSize": 100,
"sort": "LastUpdatePostDate:desc"
})
with_results = [s for s in results['studies'] if s.get('hasResults', False)]
print(f"Completed with results: {len(with_results)} / {len(results['studies'])}")
# Step 2: Get detailed results for top trial
if with_results:
nct = with_results[0]['protocolSection']['identificationModule']['nctId']
detail = requests.get(f"{CT_API}/studies/{nct}", timeout=30).json()
if 'resultsSection' in detail:
outcomes = detail['resultsSection'].get('outcomeMeasuresModule', {})
measures = outcomes.get('outcomeMeasures', [])
for m in measures[:3]:
print(f" Outcome: {m.get('title')}")
print(f" Type: {m.get('type')}")
Workflow 3: Sponsor Portfolio Comparison
sponsors = ["Pfizer", "Novartis", "Roche"]
for sponsor in sponsors:
results = ct_search({
"query.spons": sponsor,
"filter.overallStatus": "RECRUITING",
"pageSize": 1
})
print(f"{sponsor}: {results['totalCount']} recruiting trials")
time.sleep(1.5)
Common Recipes
Recipe: Rate-Limited Bulk Search
def ct_search_with_retry(params, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(f"{CT_API}/studies", params=params, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
wait = 60
print(f"Rate limited. Waiting {wait}s...")
time.sleep(wait)
else:
raise
except requests.exceptions.RequestException:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
raise Exception("Max retries exceeded")
Recipe: Extract Study Summary
def extract_summary(study):
proto = study.get('protocolSection', {})
ident = proto.get('identificationModule', {})
status = proto.get('statusModule', {})
design = proto.get('designModule', {})
return {
'nct_id': ident.get('nctId'),
'title': ident.get('officialTitle') or ident.get('briefTitle'),
'status': status.get('overallStatus'),
'phases': design.get('phases', []),
'enrollment': design.get('enrollmentInfo', {}).get('count'),
'last_update': status.get('lastUpdatePostDateStruct', {}).get('date')
}
# Usage
for study in results['studies'][:3]:
s = extract_summary(study)
print(f"{s['nct_id']}: {s['status']} | Phase: {s['phases']} | n={s['enrollment']}")
Recipe: Safe Field Navigation
def safe_get(study, *keys, default='N/A'):
"""Navigate nested study JSON safely."""
current = study
for key in keys:
if isinstance(current, dict):
current = current.get(key)
else:
return default
if current is None:
return default
return current
# Usage — handles missing fields gracefully
nct = safe_get(study, 'protocolSection', 'identificationModule', 'nctId')
phases = safe_get(study, 'protocolSection', 'designModule', 'phases', default=[])
enrollment = safe_get(study, 'protocolSection', 'designModule', 'enrollmentInfo', 'count')
Key Parameters
| Parameter | Endpoint | Default | Description |
|---|---|---|---|
query.cond |
search | — | Condition/disease search term |
query.intr |
search | — | Intervention/drug search term |
query.locn |
search | — | Geographic location filter |
query.spons |
search | — | Sponsor/organization filter |
query.term |
search | — | General full-text search |
filter.overallStatus |
search | all | Comma-separated status values |
filter.phase |
search | all | Comma-separated phase values |
pageSize |
search | 10 | Results per page (max 1000) |
sort |
search | relevance | {field}:{asc|desc} |
format |
both | json |
json or csv |
timeout |
(client) | 30s | Set in requests call |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| 429 Too Many Requests | Rate limit exceeded (~50/min) | Wait 60s; use max pageSize=1000; implement exponential backoff |
| Empty studies array | No trials match filters | Broaden search (remove status/phase filters); check spelling |
| 400 Bad Request | Invalid parameter value | Verify status/phase values match enumeration exactly (e.g., RECRUITING not recruiting) |
Missing resultsSection |
Trial has no posted results | Check study['hasResults'] before accessing results |
| KeyError on nested field | Not all trials have all modules | Use .get() with defaults or safe_get helper (see Recipes) |
| Pagination stops early | nextPageToken absent |
All results retrieved; check totalCount vs collected count |
| CSV format differs from JSON | Different field structure | CSV flattens nested structure; use JSON for programmatic access |
| Timeout on large exports | CSV with many results | Increase timeout; paginate with pageSize=1000 instead |
Best Practices
- Use maximum page size (1000) for bulk retrieval to minimize request count against rate limit
- Always check
hasResultsbefore accessingresultsSection— most trials have no posted results - Navigate safely with
.get()chains — not all trials populate all modules (especiallycontactsLocationsModule,armsInterventionsModule) - Specify multiple status values with commas (e.g.,
RECRUITING,NOT_YET_RECRUITING) — don't make separate requests per status - Use
sort=LastUpdatePostDate:descby default — returns most recently updated trials first - Date interpretation:
lastUpdatePostDateStruct.dateis ISO 8601 string;typefield indicatesACTUALvsESTIMATED
Related Skills
pubmed-database— Published literature search complementary to trial registry datachembl-database-bioactivity— Compound bioactivity data for drugs under investigationbioservices-multi-database— Alternative database access via unified Python interface
References
- ClinicalTrials.gov API documentation: https://clinicaltrials.gov/data-api/api
- API migration guide (v1→v2): https://clinicaltrials.gov/data-api/about-api/api-migration
- ClinicalTrials.gov homepage: https://clinicaltrials.gov/
- OpenAPI specification: https://clinicaltrials.gov/data-api/about-api/api-spec
Bundled Resources
Self-contained entry. Original total: 866 lines (SKILL.md 507 + api_reference.md 359). Scripts: 216 lines (query_clinicaltrials.py).
Original file disposition:
SKILL.md(507 lines) → Core API modules 1-7 (condition, intervention, location, sponsor, details, pagination, CSV export). "Core Capabilities" sections 1-10 consolidated: Search by Condition → Module 1, Search by Intervention → Module 2, Geographic Search → Module 3, Search by Sponsor → Module 4, Retrieve Detailed Study → Module 5, Pagination → Module 6, Data Export → Module 7, Combined Query → Workflow 1, Extract Summary → Recipe. "Resources" section stub → removed, content consolidated inline. Per-use-case disposition: Patient Matching → When to Use bullet + Workflow 1; Research Analysis → When to Use + Workflow 2; Drug Tracking → When to Use + Module 2; Geographic Search → Module 3; Sponsor Tracking → Module 4 + Workflow 3; Data Export → Module 7; Trial Monitoring → When to Use bullet; Eligibility Screening → Module 5references/api_reference.md(359 lines) → Fully consolidated inline: endpoint parameters → Key Concepts "Query Parameters Reference" table; status/phase values → Key Concepts tables; response structure → Key Concepts "Response Data Structure" table; HTTP error codes → Troubleshooting table; rate limit guidance → Prerequisites + Best Practices; use cases → duplicated main SKILL.md examples, absorbed into Core API; data standards (ISO 8601, CommonMark) → Prerequisites note. Error handling patterns → Recipes "Rate-Limited Bulk Search"scripts/query_clinicaltrials.py(216 lines) → Helper function pattern:search_studies()→ Quick Startct_search()helper;get_study_details()→ Module 5 inline;search_with_all_results()→ Module 6 pagination pattern;extract_study_summary()→ Recipe "Extract Study Summary". Thin-wrapper shortcut applied — each function was a thin wrapper around requests.get()
Retention: ~465 lines / 866 original (excl. scripts) = ~54%.
skills/structural-biology-drug-discovery/dailymed-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill dailymed-database -g -y
SKILL.md
Frontmatter
{
"name": "dailymed-database",
"license": "CC0-1.0",
"description": "Query FDA drug labels (DailyMed) via REST API. Search structured product labels (SPLs) by name, NDC, set ID, or RxCUI; get indications, dosage, warnings, adverse reactions, packaging. No auth. For adverse events use fda-database; for DDIs use ddinter-database."
}
DailyMed Drug Label Database
Overview
DailyMed is the National Library of Medicine's official repository of FDA-approved drug labeling information, containing 140,000+ structured product labels (SPLs) for prescription drugs, OTC medications, biologics, and vaccines. The REST API (v2) provides structured JSON/XML access to the full label content including indications, dosage, warnings, contraindications, adverse reactions, and packaging data — with no authentication required.
When to Use
- Retrieving official FDA-approved prescribing information for a drug by name, NDC code, or set ID
- Extracting structured label sections (indications, warnings, dosage, adverse reactions) for pharmacological research
- Looking up all marketed formulations of an active ingredient with packaging and NDC codes
- Cross-referencing drug labels using RxCUI identifiers from RxNorm integration
- Building drug information pipelines that require authoritative FDA label content (not user-reported data)
- Comparing label content across brand name and generic formulations of the same drug
- For adverse event reports from FAERS, use
fda-databaseinstead; DailyMed contains label text, not post-market safety signals - For drug-drug interaction severity data, use
ddinter-database; DailyMed label text is unstructured for interactions
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: drug names, NDC codes, set IDs, or RxCUI identifiers
- Environment: internet connection; no API key required
- Rate limits: no officially published limit; ~100 requests/minute is safe for polite access; add
time.sleep(0.3)in batch loops
pip install requests pandas matplotlib
Quick Start
import requests
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
# Search drug labels by name
r = requests.get(f"{BASE}/spls.json", params={"drug_name": "metformin", "pagesize": 5})
r.raise_for_status()
data = r.json()
print(f"Total labels found: {data['metadata']['total_elements']}")
for spl in data["data"][:3]:
print(f" {spl['title']!r:60s} setid={spl['setid']}")
Core API
Query 1: Search Drug Labels by Name
Search for structured product labels (SPLs) using drug name. Returns paginated list of matching labels with set IDs.
import requests
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def search_spls(drug_name, pagesize=20, page=1):
"""Search DailyMed SPLs by drug name. Returns list of label summaries."""
r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": pagesize, "page": page},
timeout=15)
r.raise_for_status()
return r.json()
result = search_spls("atorvastatin", pagesize=10)
meta = result["metadata"]
print(f"Search: 'atorvastatin' → {meta['total_elements']} labels across {meta['total_pages']} pages")
df = pd.DataFrame(result["data"])
print(df[["setid", "title", "published_date"]].to_string(index=False))
# setid title published_date
# 8f6c7c7c-... ATORVASTATIN CALCIUM tablet 2024-03-15
# a4b7d3e1-... ATORVASTATIN CALCIUM tablet, film coated 2023-11-20
Query 2: Retrieve Full Label by Set ID
Fetch the complete structured product label for a specific drug using its set ID. Returns all label sections including indications, warnings, dosage, and adverse reactions.
import requests
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def get_spl(setid):
"""Retrieve full SPL document by set ID. Returns label metadata and XML/JSON."""
r = requests.get(f"{BASE}/spls/{setid}.json", timeout=20)
r.raise_for_status()
return r.json()
# Use a known set ID from search results
setid = "8f6c7c7c-1f7f-4f1a-af86-8b2eef2a8b2c" # example atorvastatin label
label = get_spl(setid)
data = label["data"]
print(f"Title: {data.get('title')}")
print(f"Set ID: {data.get('setid')}")
print(f"Published: {data.get('published_date')}")
print(f"Version: {data.get('version')}")
# Access structured sections
if "sections" in data:
sections = data["sections"]
print(f"\nLabel sections ({len(sections)} total):")
for sec in sections[:5]:
print(f" [{sec.get('loinc_code', 'N/A')}] {sec.get('title', 'Untitled')}")
Query 3: Search by NDC Code
Look up drug labels by National Drug Code (NDC) — useful when you have a product barcode or dispensing record.
import requests
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def search_by_ndc(ndc_code):
"""Find SPL by NDC code (formatted as XXXXX-XXXX-XX or without dashes)."""
r = requests.get(f"{BASE}/spls.json",
params={"ndc": ndc_code},
timeout=15)
r.raise_for_status()
return r.json()
# NDC for Lipitor 10mg (atorvastatin)
result = search_by_ndc("0071-0155-23")
if result["data"]:
spl = result["data"][0]
print(f"Drug: {spl['title']}")
print(f"Set ID: {spl['setid']}")
print(f"Published: {spl['published_date']}")
else:
print("No label found for this NDC")
Query 4: Retrieve Packaging Information
Get detailed packaging data (NDC codes, package types, quantities) for a specific drug label by set ID.
import requests
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def get_packaging(setid):
"""Retrieve packaging information for a label (NDC codes, dosage forms, quantities)."""
r = requests.get(f"{BASE}/spls/{setid}/packaging.json", timeout=15)
r.raise_for_status()
return r.json()
setid = "8f6c7c7c-1f7f-4f1a-af86-8b2eef2a8b2c" # example setid
pkg = get_packaging(setid)
if pkg["data"]:
packages = pkg["data"]
print(f"Packaging variants: {len(packages)}")
df = pd.DataFrame(packages)
# Common fields: ndc, dosage_form, route, marketing_status
for col in ["ndc", "dosage_form", "route", "marketing_status"]:
if col in df.columns:
print(f"\n{col.upper()}:")
print(df[col].value_counts().head(5))
Query 5: Look Up by RxCUI
Retrieve drug labels using RxNorm Concept Unique Identifier (RxCUI) for integration with RxNorm-based clinical systems.
import requests
import time
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def get_spls_by_rxcui(rxcui):
"""Get all SPLs associated with an RxCUI. Returns list of set IDs and titles."""
r = requests.get(f"{BASE}/rxcuis/{rxcui}/spls.json", timeout=15)
r.raise_for_status()
return r.json()
# RxCUI for atorvastatin: 83367
rxcui = "83367"
result = get_spls_by_rxcui(rxcui)
print(f"SPLs for RxCUI {rxcui} (atorvastatin):")
for spl in result["data"][:5]:
print(f" {spl['setid']} | {spl['title'][:70]}")
print(f" Total: {result['metadata']['total_elements']} labels")
Query 6: Search Drug Names Index
List all standardized drug names in DailyMed — useful for name normalization and autocomplete in drug lookup pipelines.
import requests
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def search_drug_names(query, pagesize=20):
"""Search the DailyMed drug name index for name normalization."""
r = requests.get(f"{BASE}/drugnames.json",
params={"drug_name": query, "pagesize": pagesize},
timeout=15)
r.raise_for_status()
return r.json()
result = search_drug_names("metformin")
print(f"Drug name matches for 'metformin': {result['metadata']['total_elements']}")
for entry in result["data"][:8]:
print(f" {entry['drug_name']}")
# METFORMIN HYDROCHLORIDE
# METFORMIN HYDROCHLORIDE AND SITAGLIPTIN PHOSPHATE
# METFORMIN HYDROCHLORIDE AND SAXAGLIPTIN
Query 7: Batch Label Section Extraction
Extract specific label sections (e.g., indications, warnings) from multiple drug labels for comparative analysis.
import requests
import time
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
# LOINC codes for common SPL sections
SECTION_LOINC = {
"34067-9": "Indications and Usage",
"34068-7": "Dosage and Administration",
"34071-1": "Warnings",
"34084-4": "Adverse Reactions",
"34070-3": "Contraindications",
"43685-7": "Warnings and Precautions",
}
def get_label_sections(setid):
"""Extract structured sections from an SPL by set ID."""
r = requests.get(f"{BASE}/spls/{setid}.json", timeout=20)
r.raise_for_status()
data = r.json()["data"]
sections = {}
for sec in data.get("sections", []):
loinc = sec.get("loinc_code")
if loinc in SECTION_LOINC:
sections[SECTION_LOINC[loinc]] = sec.get("text", "")
return sections
# Get indications for two statin labels
statins = [
("Atorvastatin", "8f6c7c7c-1f7f-4f1a-af86-8b2eef2a8b2c"),
("Rosuvastatin", "a3b2c4d5-1234-5678-abcd-ef0123456789"), # example
]
records = []
for drug_name, setid in statins:
try:
sections = get_label_sections(setid)
records.append({"drug": drug_name, "indications_length": len(sections.get("Indications and Usage", ""))})
except Exception as e:
print(f"Warning: {drug_name} failed — {e}")
time.sleep(0.3)
df = pd.DataFrame(records)
print(df.to_string(index=False))
Key Concepts
Structured Product Label (SPL) and Set ID
An SPL is the official FDA-approved drug label in XML format, structured using Health Level 7 (HL7) Clinical Document Architecture (CDA). Each unique drug product label has a globally unique set ID (UUID format). Multiple versions of the same label share the same set ID but have different version numbers. Always use the most recent published version for current prescribing information.
LOINC Section Codes
DailyMed SPLs use standardized LOINC codes to identify label sections, enabling consistent programmatic extraction across all labels:
| LOINC Code | Section Name |
|---|---|
34067-9 |
Indications and Usage |
34068-7 |
Dosage and Administration |
34070-3 |
Contraindications |
34071-1 |
Warnings |
43685-7 |
Warnings and Precautions |
34084-4 |
Adverse Reactions |
34073-7 |
Drug Interactions |
34076-0 |
Patient Counseling Information |
42229-5 |
Mechanism of Action |
34069-5 |
How Supplied/Storage |
NDC Code Format
National Drug Codes (NDCs) identify drug products with a three-segment numeric format: labeler-product-package (e.g., 0071-0155-23). NDCs can also appear without dashes in some systems. DailyMed accepts both formats in API queries.
Common Workflows
Workflow 1: Drug Label Comparison by Active Ingredient
Goal: Retrieve and compare label content for all formulations of an active ingredient — useful for generic vs. brand name comparison.
import requests
import time
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def search_spls(drug_name, pagesize=50):
r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": pagesize},
timeout=15)
r.raise_for_status()
return r.json()
def get_packaging(setid):
r = requests.get(f"{BASE}/spls/{setid}/packaging.json", timeout=15)
r.raise_for_status()
return r.json()
# Search for all metformin labels
result = search_spls("metformin", pagesize=20)
labels = result["data"]
print(f"Found {len(labels)} metformin labels")
rows = []
for label in labels[:10]: # limit for demo
setid = label["setid"]
try:
pkg = get_packaging(setid)
for item in pkg["data"][:2]:
rows.append({
"title": label["title"][:60],
"setid": setid,
"ndc": item.get("ndc"),
"dosage_form": item.get("dosage_form"),
"route": item.get("route"),
"marketing_status": item.get("marketing_status"),
"published_date": label.get("published_date"),
})
except Exception as e:
print(f"Skipping {setid}: {e}")
time.sleep(0.3)
df = pd.DataFrame(rows)
print(f"\nFormulations with packaging data: {len(df)}")
print(df[["title", "dosage_form", "route", "marketing_status"]].drop_duplicates().to_string(index=False))
df.to_csv("metformin_formulations.csv", index=False)
print("\nSaved: metformin_formulations.csv")
Workflow 2: Extract Warnings and Adverse Reactions for Safety Analysis
Goal: Systematically extract structured warning and adverse reaction text from a set of drug labels for pharmacovigilance research.
import requests
import time
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
TARGET_SECTIONS = {
"34071-1": "Warnings",
"43685-7": "Warnings_and_Precautions",
"34084-4": "Adverse_Reactions",
"34070-3": "Contraindications",
}
def search_and_get_sections(drug_name, max_labels=5):
"""Search for labels and extract safety-relevant sections."""
search_r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": max_labels},
timeout=15)
search_r.raise_for_status()
labels = search_r.json()["data"][:max_labels]
records = []
for label in labels:
setid = label["setid"]
try:
label_r = requests.get(f"{BASE}/spls/{setid}.json", timeout=20)
label_r.raise_for_status()
spl_data = label_r.json()["data"]
row = {"drug_name": drug_name, "title": label["title"][:80], "setid": setid}
for loinc, col in TARGET_SECTIONS.items():
text = ""
for sec in spl_data.get("sections", []):
if sec.get("loinc_code") == loinc:
text = sec.get("text", "")[:500] # first 500 chars
break
row[col] = text
records.append(row)
except Exception as e:
print(f" Skipping {setid}: {e}")
time.sleep(0.3)
return pd.DataFrame(records)
# Compare safety sections across ACE inhibitor labels
drugs = ["lisinopril", "enalapril"]
all_records = []
for drug in drugs:
print(f"Processing: {drug}")
df = search_and_get_sections(drug, max_labels=3)
all_records.append(df)
combined = pd.concat(all_records, ignore_index=True)
print(f"\nExtracted safety sections: {len(combined)} labels")
print(combined[["drug_name", "title"]].to_string(index=False))
combined.to_csv("ace_inhibitor_safety_sections.csv", index=False)
print("Saved: ace_inhibitor_safety_sections.csv")
Workflow 3: Visualize Label Distribution by Drug Class
Goal: Query multiple drugs in a class, count their labeled formulations, and visualize the distribution.
import requests
import time
import pandas as pd
import matplotlib.pyplot as plt
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def count_labels(drug_name):
"""Return total SPL count for a drug name."""
r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": 1},
timeout=15)
r.raise_for_status()
return r.json()["metadata"]["total_elements"]
# Count labels for common statins
statins = {
"atorvastatin": "Atorvastatin",
"rosuvastatin": "Rosuvastatin",
"simvastatin": "Simvastatin",
"pravastatin": "Pravastatin",
"lovastatin": "Lovastatin",
"fluvastatin": "Fluvastatin",
"pitavastatin": "Pitavastatin",
}
counts = {}
for query, label in statins.items():
try:
counts[label] = count_labels(query)
print(f" {label}: {counts[label]} labels")
except Exception as e:
print(f" {label}: error — {e}")
time.sleep(0.3)
# Visualization
df = pd.DataFrame(list(counts.items()), columns=["Drug", "Label_Count"])
df = df.sort_values("Label_Count", ascending=True)
fig, ax = plt.subplots(figsize=(9, 5))
bars = ax.barh(df["Drug"], df["Label_Count"], color="#2196F3", edgecolor="white")
ax.bar_label(bars, fmt="%d", padding=4, fontsize=9)
ax.set_xlabel("Number of DailyMed SPL Entries")
ax.set_title("DailyMed: FDA Drug Label Count by Statin\n(includes brand + generic formulations)")
ax.set_xlim(0, df["Label_Count"].max() * 1.15)
plt.tight_layout()
plt.savefig("statin_label_counts.png", dpi=150, bbox_inches="tight")
print(f"Saved: statin_label_counts.png (total labels: {df['Label_Count'].sum()})")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
drug_name |
/spls, /drugnames |
— | any drug name string | Filter labels by drug name (partial match supported) |
ndc |
/spls |
— | NDC code string | Filter labels by National Drug Code |
pagesize |
/spls, /drugnames |
20 |
1–100 |
Results per page; max 100 |
page |
/spls, /drugnames |
1 |
positive integer | Page number for pagination |
setid |
/spls/{setid}, /spls/{setid}/packaging |
— | UUID string | Unique label identifier; required for direct access |
rxcui |
/rxcuis/{rxcui}/spls |
— | RxNorm concept ID | Look up labels by RxCUI for clinical system integration |
format |
any endpoint | json |
json, xml |
Response format; JSON is default and preferred |
Best Practices
-
Resolve set IDs before batch processing: Use the
/splssearch to get set IDs, then fetch full labels by set ID. Do not construct set IDs from drug names — they are UUID identifiers assigned by FDA. -
Add
time.sleep(0.3)in batch loops: DailyMed has no published rate limits but is public NIH infrastructure. Polite delays prevent throttling.import time for drug in drug_list: result = search_spls(drug) time.sleep(0.3) # 200 requests/minute safe upper bound -
Use LOINC codes for section extraction, not text parsing: Label sections are indexed by LOINC code, providing consistent section access across all SPL documents without brittle regex on section headers.
-
Pagination for comprehensive results: The default
pagesize=20may miss formulations. Usemetadata["total_elements"]to detect if pagination is needed:meta = result["metadata"] total_pages = meta["total_pages"] if total_pages > 1: for p in range(2, total_pages + 1): result = search_spls(drug_name, page=p) -
Prefer RxCUI for clinical integration: When integrating with clinical systems (EHR, dispensing), use RxCUI-based lookups (
/rxcuis/{rxcui}/spls) for standardized, unambiguous drug identification.
Common Recipes
Recipe: Check if a Drug Has Black Box Warnings
When to use: Quickly determine whether a drug carries the FDA's strongest warning level.
import requests
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
BLACK_BOX_LOINC = "34066-1"
def has_black_box_warning(setid):
"""Return True and warning text if drug label has a black box (boxed) warning."""
r = requests.get(f"{BASE}/spls/{setid}.json", timeout=20)
r.raise_for_status()
sections = r.json()["data"].get("sections", [])
for sec in sections:
if sec.get("loinc_code") == BLACK_BOX_LOINC:
return True, sec.get("text", "")[:300]
return False, ""
# Example: check warfarin label
setid = "8b7c3d4e-5678-90ab-cdef-1234567890ab" # example warfarin setid
has_warning, text = has_black_box_warning(setid)
print(f"Black box warning: {has_warning}")
if has_warning:
print(f"Warning text (first 300 chars): {text}")
Recipe: Multi-Page Search with All Results
When to use: Retrieve all matching labels for a drug when total count exceeds one page.
import requests
import time
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def search_all_spls(drug_name, pagesize=100, delay=0.3):
"""Retrieve all SPLs for a drug name across multiple pages."""
all_labels = []
page = 1
while True:
r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": pagesize, "page": page},
timeout=15)
r.raise_for_status()
data = r.json()
all_labels.extend(data["data"])
if page >= data["metadata"]["total_pages"]:
break
page += 1
time.sleep(delay)
return all_labels
labels = search_all_spls("ibuprofen")
print(f"Total ibuprofen labels: {len(labels)}")
Recipe: Export Label Summary to CSV
When to use: Build a flat reference table of drug labels for offline analysis.
import requests
import pandas as pd
BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
def export_drug_labels(drug_name, pagesize=50):
"""Export label summaries for a drug name to DataFrame."""
r = requests.get(f"{BASE}/spls.json",
params={"drug_name": drug_name, "pagesize": pagesize},
timeout=15)
r.raise_for_status()
data = r.json()
df = pd.DataFrame(data["data"])
df["drug_query"] = drug_name
return df, data["metadata"]["total_elements"]
df, total = export_drug_labels("amoxicillin")
print(f"Retrieved {len(df)} of {total} amoxicillin labels")
df[["setid", "title", "published_date"]].to_csv("amoxicillin_labels.csv", index=False)
print(f"Saved: amoxicillin_labels.csv (columns: {list(df.columns[:5])})")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found on /spls/{setid} |
Invalid or retired set ID | Re-search by drug name to get current set IDs |
Empty data list from search |
Drug name not matching any labels | Try shorter name (e.g., "metformin" not "metformin HCl tablets"); check spelling |
| Missing sections in SPL JSON | Older labels may not have all LOINC-coded sections | Check len(sections) before iterating; fall back to XML format for legacy labels |
ConnectionError / timeout |
DailyMed server overload | Retry with exponential backoff; increase timeout=30 |
| Pagination returning duplicates | Page boundary race condition | De-duplicate results by setid after all pages collected |
| Packaging endpoint returns empty | Label exists but has no packaging records | Some biologics and vaccines lack packaging data; use label data directly |
| RxCUI lookup returns no results | RxCUI not mapped in DailyMed | Verify RxCUI via RxNorm API before querying; some experimental drugs are not mapped |
Related Skills
fda-database— openFDA for adverse event reports (FAERS), drug recalls, and enforcement actionsddinter-database— drug-drug interaction severity and mechanisms from DDInterdrugbank-database-access— comprehensive drug information including targets, pathways, and chemical propertiesclinicaltrials-database-search— ClinicalTrials.gov for clinical trial data on drugs
References
- DailyMed — official drug label repository (NLM/NIH)
- DailyMed Web Services API v2 — complete REST API documentation
- SPL Standard (HL7) — Structured Product Labeling standard documentation
- LOINC Document Ontology — LOINC codes for SPL section identification
- NLM DailyMed Overview — data coverage and update frequency
skills/structural-biology-drug-discovery/datamol-cheminformatics/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill datamol-cheminformatics -g -y
SKILL.md
Frontmatter
{
"name": "datamol-cheminformatics",
"license": "Apache-2.0",
"description": "Pythonic RDKit wrapper with sensible defaults for drug discovery. SMILES parsing, standardization, descriptors, fingerprints, similarity, clustering, diversity selection, scaffold analysis, BRICS\/RECAP fragmentation, 3D conformers, and visualization. Returns native rdkit.Chem.Mol. Prefer datamol for standard workflows; use RDKit directly for advanced control."
}
Datamol Cheminformatics Toolkit
Overview
Datamol provides a lightweight, Pythonic abstraction layer over RDKit for molecular cheminformatics. It simplifies common drug discovery operations — SMILES parsing, standardization, descriptors, fingerprints, clustering, scaffolds, conformers, and visualization — with sensible defaults, built-in parallelization, and cloud storage support via fsspec. All molecular objects are native rdkit.Chem.Mol instances, ensuring full RDKit compatibility.
When to Use
- Parsing, validating, and standardizing molecular structures from SMILES, SDF, or other formats
- Computing molecular descriptors and fingerprints for ML featurization
- Similarity searching and diversity selection from compound libraries
- Clustering compounds by structural similarity (Butina clustering)
- Scaffold analysis and scaffold-based train/test splitting for ML
- BRICS/RECAP molecular fragmentation for fragment-based design
- 3D conformer generation and analysis
- Visualizing molecules as grids with alignment and highlighting
- Batch processing molecular datasets with parallelization
- For quick gene lookups use gget instead; for advanced substructure queries or custom fingerprints, use RDKit directly
Prerequisites
uv pip install datamol
import datamol as dm
import numpy as np
import pandas as pd
Quick Start
import datamol as dm
# Parse and standardize
mol = dm.to_mol("CC(=O)Oc1ccccc1C(=O)O") # Aspirin
mol = dm.standardize_mol(mol)
print(dm.to_smiles(mol)) # Canonical SMILES
# Compute descriptors
desc = dm.descriptors.compute_many_descriptors(mol)
print(f"MW: {desc['mw']:.1f}, LogP: {desc['logp']:.2f}, TPSA: {desc['tpsa']:.1f}")
# Generate fingerprint
fp = dm.to_fp(mol, fp_type='ecfp', radius=2, n_bits=2048)
print(f"Fingerprint shape: {fp.shape}") # (2048,)
Core API
1. Molecular I/O & Standardization
Parsing molecules:
import datamol as dm
# From SMILES (returns None on failure)
mol = dm.to_mol("CCO")
if mol is None:
print("Invalid SMILES")
# Format conversions
smiles = dm.to_smiles(mol, isomeric=True) # Canonical SMILES
inchi = dm.to_inchi(mol)
inchikey = dm.to_inchikey(mol)
selfies = dm.to_selfies(mol)
Standardization (always recommended for external data):
mol = dm.standardize_mol(
mol,
disconnect_metals=True,
normalize=True,
reionize=True
)
clean_smiles = dm.standardize_smiles("C(C)O") # From SMILES directly
File I/O:
# Reading (supports local, S3, GCS, HTTP via fsspec)
df = dm.read_sdf("compounds.sdf", mol_column='mol')
df = dm.read_csv("data.csv", smiles_column="SMILES", mol_column="mol")
df = dm.read_excel("compounds.xlsx", sheet_name=0, mol_column="mol")
df = dm.open_df("file.sdf") # Auto-detect format
# Writing
dm.to_sdf(df, "output.sdf", mol_column="mol")
dm.to_smi(mols, "output.smi")
dm.to_xlsx(df, "output.xlsx", mol_columns=["mol"]) # Renders molecule images
# Remote files
df = dm.read_sdf("s3://bucket/compounds.sdf")
dm.to_sdf(mols, "s3://bucket/output.sdf")
2. Descriptors & Properties
import datamol as dm
mol = dm.to_mol("c1ccc(cc1)CCN")
# Standard descriptor set (single molecule)
desc = dm.descriptors.compute_many_descriptors(mol)
# Returns dict: {'mw': 121.18, 'logp': 1.41, 'hbd': 1, 'hba': 1,
# 'tpsa': 26.02, 'n_aromatic_atoms': 6, ...}
# Batch computation (parallel)
mols = [dm.to_mol(s) for s in ["CCO", "c1ccccc1", "CC(=O)O"]]
desc_df = dm.descriptors.batch_compute_many_descriptors(
mols, n_jobs=-1, progress=True
)
print(desc_df.head())
# Specific descriptors
n_stereo = dm.descriptors.n_stereo_centers(mol)
n_aromatic = dm.descriptors.n_aromatic_atoms(mol)
aromatic_ratio = dm.descriptors.n_aromatic_atoms_proportion(mol)
n_rigid = dm.descriptors.n_rigid_bonds(mol)
Drug-likeness filtering (Lipinski Rule of Five):
def is_druglike(mol):
desc = dm.descriptors.compute_many_descriptors(mol)
return (desc['mw'] <= 500 and desc['logp'] <= 5
and desc['hbd'] <= 5 and desc['hba'] <= 10)
druglike = [m for m in mols if is_druglike(m)]
print(f"Drug-like: {len(druglike)}/{len(mols)}")
3. Fingerprints & Similarity
import datamol as dm
mol = dm.to_mol("c1ccc(cc1)CCN")
# Fingerprint types
fp_ecfp = dm.to_fp(mol, fp_type='ecfp', radius=2, n_bits=2048) # Morgan/ECFP
fp_maccs = dm.to_fp(mol, fp_type='maccs') # MACCS keys (167 bits)
fp_topo = dm.to_fp(mol, fp_type='topological') # Topological
fp_ap = dm.to_fp(mol, fp_type='atompair') # Atom pairs
# Pairwise distances (Tanimoto distance = 1 - similarity)
mols = [dm.to_mol(s) for s in ["CCO", "CCCO", "c1ccccc1"]]
dist_matrix = dm.pdist(mols, n_jobs=-1)
print(f"Distance vector shape: {dist_matrix.shape}")
# Distances between two sets
query = [dm.to_mol("CCO")]
library = [dm.to_mol(s) for s in ["CCCO", "c1ccccc1", "CC(=O)O"]]
distances = dm.cdist(query, library, n_jobs=-1)
print(f"Query-library distances: {distances.shape}")
4. Clustering & Diversity Selection
import datamol as dm
mols = [dm.to_mol(s) for s in smiles_list] # Assume smiles_list defined
# Butina clustering (suitable for ~1000 molecules, builds full distance matrix)
clusters = dm.cluster_mols(mols, cutoff=0.2, n_jobs=-1)
for i, cluster in enumerate(clusters[:5]):
print(f"Cluster {i}: {len(cluster)} molecules")
# Diversity selection (works for larger libraries)
diverse_mols = dm.pick_diverse(mols, npick=100)
print(f"Selected {len(diverse_mols)} diverse molecules")
# Cluster centroids
centroids = dm.pick_centroids(mols, npick=50)
print(f"Selected {len(centroids)} centroids")
5. Scaffolds & Fragments
Murcko scaffold extraction:
import datamol as dm
from collections import Counter
mol = dm.to_mol("c1ccc(cc1)CCN")
scaffold = dm.to_scaffold_murcko(mol)
print(f"Scaffold: {dm.to_smiles(scaffold)}")
# Scaffold frequency analysis
scaffolds = [dm.to_scaffold_murcko(m) for m in mols]
scaffold_smiles = [dm.to_smiles(s) for s in scaffolds]
counts = Counter(scaffold_smiles)
print(f"Top scaffolds: {counts.most_common(5)}")
# Scaffold-based train/test split (for ML)
scaffold_to_mols = {}
for mol, scaf in zip(mols, scaffold_smiles):
scaffold_to_mols.setdefault(scaf, []).append(mol)
scaffolds_list = list(scaffold_to_mols.keys())
split_idx = int(0.8 * len(scaffolds_list))
train_mols = [m for s in scaffolds_list[:split_idx] for m in scaffold_to_mols[s]]
test_mols = [m for s in scaffolds_list[split_idx:] for m in scaffold_to_mols[s]]
Fragmentation:
mol = dm.to_mol("CC(=O)Oc1ccccc1C(=O)O") # Aspirin
# BRICS (16 bond types, retrosynthetic)
brics_frags = dm.fragment.brics(mol)
print(f"BRICS fragments: {brics_frags}") # Set of fragment SMILES with [1*] attachment points
# RECAP (11 bond types, combinatorial)
recap_frags = dm.fragment.recap(mol)
# MMPA (matched molecular pair analysis)
mmpa_frags = dm.fragment.mmpa_frag(mol)
6. 3D Conformers
import datamol as dm
mol = dm.to_mol("c1ccc(cc1)CCN")
# Generate conformers
mol_3d = dm.conformers.generate(
mol,
n_confs=50, # Number to generate
rms_cutoff=0.5, # Filter similar (Angstroms)
minimize_energy=True, # UFF minimization
method='ETKDGv3' # Embedding method
)
print(f"Generated {mol_3d.GetNumConformers()} conformers")
# Access coordinates
conf = mol_3d.GetConformer(0)
positions = conf.GetPositions() # Nx3 array
print(f"Atom positions shape: {positions.shape}")
# Cluster conformers by RMSD
clusters = dm.conformers.cluster(mol_3d, rms_cutoff=1.0)
centroids = dm.conformers.return_centroids(mol_3d, clusters)
# Solvent accessible surface area
sasa = dm.conformers.sasa(mol_3d, n_jobs=-1)
print(f"SASA values: {sasa[:3]}")
Key Concepts
Datamol vs RDKit Decision Guide
| Use Datamol when... | Use RDKit directly when... |
|---|---|
| Standard SMILES ↔ Mol conversions | Custom fingerprint definitions |
| Batch processing with parallelization | Low-level atom/bond manipulation |
| Quick descriptor computation | Substructure query optimization |
| File I/O (SDF, CSV, Excel, cloud) | Reaction enumeration (large-scale) |
| Clustering & diversity selection | Custom force field parameters |
| Scaffold analysis | Advanced stereochemistry handling |
Key Data Types
- All molecules are native
rdkit.Chem.Molobjects — fully compatible with RDKit functions - Fingerprints are numpy arrays (dense bit vectors)
- DataFrames use pandas with a
molcolumn containing Mol objects - Distance matrices use Tanimoto distance (0 = identical, 1 = completely different)
Parallelization
Functions supporting n_jobs parameter: dm.read_sdf, dm.descriptors.batch_compute_many_descriptors, dm.cluster_mols, dm.pdist, dm.cdist, dm.conformers.sasa. Use n_jobs=-1 for all cores, progress=True for progress bars.
Common Workflows
1. Drug Discovery Pipeline: Load → Filter → Cluster → Visualize
import datamol as dm
# 1. Load and standardize
df = dm.read_sdf("compounds.sdf")
df['mol'] = df['mol'].apply(lambda m: dm.standardize_mol(m) if m else None)
df = df[df['mol'].notna()]
print(f"Loaded {len(df)} valid molecules")
# 2. Compute descriptors and filter by drug-likeness
desc_df = dm.descriptors.batch_compute_many_descriptors(
df['mol'].tolist(), n_jobs=-1, progress=True
)
druglike = (desc_df['mw'] <= 500) & (desc_df['logp'] <= 5) & (desc_df['hbd'] <= 5) & (desc_df['hba'] <= 10)
filtered_df = df[druglike.values].reset_index(drop=True)
print(f"Drug-like compounds: {len(filtered_df)}")
# 3. Select diverse subset
diverse = dm.pick_diverse(filtered_df['mol'].tolist(), npick=100)
# 4. Visualize
dm.viz.to_image(diverse[:20], legends=[dm.to_smiles(m) for m in diverse[:20]],
n_cols=5, mol_size=(300, 300), outfile="diverse_hits.png")
2. Virtual Screening: Query → Similarity → Rank
import datamol as dm
import numpy as np
# Query actives and screening library
actives = [dm.to_mol(s) for s in active_smiles] # Known actives
library = [dm.to_mol(s) for s in library_smiles] # Screening library
# Calculate distances (Tanimoto)
distances = dm.cdist(actives, library, n_jobs=-1)
min_distances = distances.min(axis=0) # Best match to any active
similarities = 1 - min_distances
# Rank and select top hits
top_idx = np.argsort(similarities)[::-1][:100]
top_hits = [library[i] for i in top_idx]
top_scores = [similarities[i] for i in top_idx]
print(f"Top hit similarity: {top_scores[0]:.3f}")
# Visualize top hits
dm.viz.to_image(top_hits[:20],
legends=[f"Sim: {s:.3f}" for s in top_scores[:20]],
outfile="screening_hits.png")
3. SAR Analysis: Group by Scaffold → Compare Activities
import datamol as dm
# Group compounds by scaffold
scaffolds = [dm.to_scaffold_murcko(m) for m in mols]
scaffold_smiles = [dm.to_smiles(s) for s in scaffolds]
sar_df = pd.DataFrame({
'mol': mols, 'scaffold': scaffold_smiles, 'activity': activities
})
# Analyze each scaffold series
for scaffold, group in sar_df.groupby('scaffold'):
if len(group) >= 3:
print(f"Scaffold: {scaffold} | N={len(group)} | "
f"Activity: {group['activity'].min():.2f}–{group['activity'].max():.2f}")
dm.viz.to_image(group['mol'].tolist(), align=True,
legends=[f"Act: {a:.2f}" for a in group['activity']])
Key Parameters
| Function | Parameter | Default | Description |
|---|---|---|---|
dm.to_fp |
fp_type |
'ecfp' |
Fingerprint type: ecfp, maccs, topological, atompair |
dm.to_fp |
radius |
2 |
Morgan radius (ecfp only); radius=2 ≈ ECFP4 |
dm.to_fp |
n_bits |
2048 |
Fingerprint length (ecfp, topological) |
dm.cluster_mols |
cutoff |
0.2 |
Tanimoto distance threshold (0=identical, 1=different) |
dm.pick_diverse |
npick |
required | Number of diverse molecules to select |
dm.conformers.generate |
n_confs |
None |
Number of conformers (None = auto) |
dm.conformers.generate |
rms_cutoff |
None |
RMSD filter threshold (Angstroms) |
dm.conformers.generate |
method |
'ETKDGv3' |
Embedding: ETKDGv3, ETKDGv2, ETKDG |
dm.standardize_mol |
disconnect_metals |
False |
Remove metal-ligand bonds |
dm.read_sdf |
sanitize |
True |
Apply molecule sanitization |
dm.read_sdf |
remove_hs |
True |
Remove explicit hydrogens |
dm.viz.to_image |
align |
False |
Align molecules by MCS |
dm.viz.to_image |
use_svg |
False |
Output SVG (True) or PNG (False) |
Best Practices
- Always standardize molecules from external sources — call
dm.standardize_mol()withdisconnect_metals=True, normalize=True, reionize=Truebefore any analysis. Different SMILES representations of the same molecule will produce different fingerprints - Check for None after parsing —
dm.to_mol()returnsNonefor invalid SMILES. Filter these before batch operations to avoid crashes - Use parallel processing for datasets — pass
n_jobs=-1, progress=Trueto batch operations. Sequential processing of 10,000+ molecules is unnecessarily slow - Choose fingerprints by use case — ECFP (Morgan): general structural similarity; MACCS: fast, smaller space; Atom pairs: distance-sensitive. ECFP with radius=2, n_bits=2048 is the most common default
- Mind clustering scale limits — Butina clustering (
dm.cluster_mols) builds a full distance matrix. Use for ≤~1,000 molecules. For larger sets, usedm.pick_diverse()or hierarchical methods - Use scaffold splitting for ML — random splits leak similar structures into train/test. Always use scaffold-based splitting for molecular property prediction models
- Leverage fsspec for cloud data — all I/O functions accept S3, GCS, and HTTP paths directly. Install
s3fsorgcsfsfor cloud support
Common Recipes
Recipe: Batch SMILES Validation and Standardization
When to use: Clean a list of SMILES strings before any downstream analysis.
import datamol as dm
smiles_list = ["CC(=O)Oc1ccccc1C(=O)O", "c1ccccc1", "invalid_smiles", "CC(N)C(=O)O"]
mols = [dm.to_mol(s) for s in smiles_list]
valid = [(s, m) for s, m in zip(smiles_list, mols) if m is not None]
standardized = [(s, dm.standardize_mol(m)) for s, m in valid]
print(f"Valid: {len(valid)}/{len(smiles_list)}")
for orig, mol in standardized:
print(f" {orig} → {dm.to_smiles(mol)}")
Recipe: Pairwise Similarity Matrix
When to use: Compare a small compound set against each other or a reference library.
import datamol as dm
import numpy as np
smiles = ["CC(=O)Oc1ccccc1C(=O)O", "c1ccc(cc1)C(=O)O", "CC(N)C(=O)O", "c1ccccc1"]
mols = [dm.to_mol(s) for s in smiles]
fps = [dm.to_fp(m) for m in mols]
# Pairwise Tanimoto similarity
n = len(fps)
sim_matrix = np.zeros((n, n))
for i in range(n):
for j in range(n):
sim_matrix[i, j] = dm.similarity.tanimoto(fps[i], fps[j])
print(f"Similarity matrix shape: {sim_matrix.shape}")
print(f"Most similar pair: {np.unravel_index(np.argsort(sim_matrix.ravel())[-3], (n, n))}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
dm.to_mol() returns None |
Invalid or non-canonical SMILES | Try dm.standardize_smiles() first; check for kekulization issues |
| MemoryError during clustering | Full distance matrix for large set | Use dm.pick_diverse() instead of dm.cluster_mols for >1000 molecules |
| Slow conformer generation | Too many conformers or large molecule | Reduce n_confs, increase rms_cutoff, or limit molecule size |
| Remote file access fails | Missing fsspec backend | Install s3fs (AWS), gcsfs (GCP), or adlfs (Azure) |
| Descriptor computation fails | Molecule has no conformer | Standardize first; some 3D descriptors need dm.conformers.generate() |
dm.to_xlsx missing images |
openpyxl not installed | uv pip install openpyxl |
| Inconsistent fingerprints | Different SMILES for same molecule | Standardize all molecules before fingerprint computation |
| Scaffold extraction returns full molecule | No ring system in molecule | Murcko scaffolds require at least one ring; acyclic molecules return themselves |
| Reaction product is None | Reactant doesn't match SMARTS pattern | Verify reactant matches reaction template; check atom mapping |
Import error for dm.viz |
Missing visualization dependencies | uv pip install Pillow cairosvg |
Related Skills
- rdkit-cheminformatics — full RDKit API for advanced operations not covered by datamol's simplified interface
- pubchem-compound-search — retrieve compound data by name, CID, or structure from PubChem
- scikit-learn-machine-learning — ML model training using datamol-generated features
- matplotlib-scientific-plotting — custom publication-quality molecular property plots
References
- Datamol documentation: https://docs.datamol.io/
- RDKit documentation: https://www.rdkit.org/docs/
- GitHub repository: https://github.com/datamol-io/datamol
- Bemis, G. W. & Murcko, M. A. (1996). The Properties of Known Drugs. J. Med. Chem. 39(15), 2887–2893
skills/structural-biology-drug-discovery/ddinter-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill ddinter-database -g -y
SKILL.md
Frontmatter
{
"name": "ddinter-database",
"license": "CC-BY-4.0",
"description": "Query DDInter drug-drug interactions via REST API (1.7M+ interactions, 2,400+ drugs). Search by drug name\/ID for severity (major\/moderate\/minor), mechanisms, and clinical recommendations. No auth. For FDA labeling use dailymed-database; for pharmacogenomics use clinpgx-database."
}
DDInter Drug-Drug Interaction Database
Overview
DDInter is an open, curated database of drug-drug interactions (DDIs) covering 2,400+ drugs and 1.7M+ pairwise interactions with structured severity levels (major, moderate, minor), mechanistic annotations, and clinical management recommendations. Access is provided via a JSON REST API at https://ddinter.scbdd.com/api/ — no authentication or registration required.
When to Use
- Checking whether two co-administered drugs have a known interaction and its severity (major/moderate/minor)
- Retrieving all known interactions for a given drug to support polypharmacy risk assessment
- Identifying the mechanistic basis (pharmacokinetic vs. pharmacodynamic) of a drug-drug interaction
- Screening a drug combination list for potential major interactions before clinical decision support
- Building automated DDI checking pipelines for medication review or drug repurposing workflows
- Analyzing the DDI network for a drug class (e.g., all major interactions for CYP3A4 substrates)
- For FDA-approved drug labeling text (indications, dosage, contraindications) use
dailymed-database - For pharmacogenomics interactions (CYP genotype-drug associations) use
clinpgx-database; DDInter covers drug-drug not gene-drug pairs - For drug adverse event reports from FAERS use
fda-database
Prerequisites
- Python packages:
requests,pandas,matplotlib,networkx - Data requirements: drug names or DDInter drug IDs
- Environment: internet connection; no API key required
- Rate limits: no officially published rate limit; use
time.sleep(0.3)between requests in batch loops for polite access
pip install requests pandas matplotlib networkx
Quick Start
import requests
BASE = "https://ddinter.scbdd.com/api"
# Search for a drug by name
r = requests.get(f"{BASE}/drug/", params={"drug_name": "warfarin", "format": "json"}, timeout=15)
r.raise_for_status()
data = r.json()
print(f"Results for 'warfarin': {data['count']} drugs found")
for drug in data["results"][:3]:
print(f" ID={drug['ddinter_id']} Name={drug['drug_name']}")
# Results for 'warfarin': 1 drugs found
# ID=DDInter_D00001 Name=Warfarin
Core API
Query 1: Search Drug by Name
Find a drug's DDInter ID by searching its name. The DDInter ID is required for all interaction queries.
import requests
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def search_drug(drug_name):
"""Search DDInter for a drug by name. Returns list of matching drug records."""
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"},
timeout=15)
r.raise_for_status()
return r.json()
# Search for warfarin
result = search_drug("warfarin")
print(f"Matches: {result['count']}")
if result["results"]:
drug = result["results"][0]
print(f"DDInter ID: {drug['ddinter_id']}")
print(f"Drug name: {drug['drug_name']}")
# Store DDInter ID for interaction queries
warfarin_id = drug["ddinter_id"]
print(f"\nWarfarin DDInter ID: {warfarin_id}")
# Batch name lookup
drugs_to_find = ["warfarin", "aspirin", "atorvastatin", "metformin", "amiodarone"]
id_map = {}
for name in drugs_to_find:
res = search_drug(name)
if res["results"]:
id_map[name] = res["results"][0]["ddinter_id"]
print(f" {name:20s} → {res['results'][0]['ddinter_id']}")
Query 2: Get All Interactions for a Drug
Retrieve all known DDIs for a drug by its DDInter ID. Returns interaction partners, severity, and clinical information.
import requests
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def get_drug_interactions(drug_id, page_size=100):
"""Get all DDIs for a drug by DDInter ID. Handles pagination automatically."""
all_interactions = []
url = f"{BASE}/interaction/"
params = {"drug_id": drug_id, "format": "json", "page_size": page_size}
while url:
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
all_interactions.extend(data.get("results", []))
url = data.get("next") # None when last page
params = {} # next URL already includes params
return all_interactions
# Get all interactions for warfarin (DDInter_D00001)
interactions = get_drug_interactions("DDInter_D00001")
print(f"Warfarin total interactions: {len(interactions)}")
# Summarize by severity
df = pd.DataFrame(interactions)
if not df.empty and "level" in df.columns:
severity_counts = df["level"].value_counts()
print("\nInteractions by severity:")
for level, count in severity_counts.items():
print(f" {level:15s}: {count:4d}")
# Major: 45
# Moderate: 312
# Minor: 198
Query 3: Get Interaction Details by Interaction ID
Retrieve full details for a specific drug-drug interaction, including mechanism and clinical recommendation.
import requests
BASE = "https://ddinter.scbdd.com/api"
def get_interaction_detail(interaction_id):
"""Get full details for a specific interaction by its DDInter interaction ID."""
r = requests.get(f"{BASE}/interaction/{interaction_id}/",
params={"format": "json"},
timeout=15)
r.raise_for_status()
return r.json()
# Example interaction ID (format: DDInter_I_XXXXXX)
interaction_id = "DDInter_I_000001" # example
try:
detail = get_interaction_detail(interaction_id)
print(f"Interaction: {detail.get('interaction_id')}")
print(f"Drug A: {detail.get('drug_a')}")
print(f"Drug B: {detail.get('drug_b')}")
print(f"Severity: {detail.get('level')}")
print(f"Mechanism: {detail.get('mechanism', 'Not specified')[:200]}")
print(f"Recommendation: {detail.get('recommendation', 'Not specified')[:200]}")
print(f"PK type: {detail.get('pharmacokinetic_type', 'N/A')}")
print(f"PD type: {detail.get('pharmacodynamic_type', 'N/A')}")
except Exception as e:
print(f"Note: Use a valid interaction ID from get_drug_interactions() results. Error: {e}")
Query 4: Check Interaction Between Two Specific Drugs
Query interactions between exactly two drugs using their DDInter IDs.
import requests
BASE = "https://ddinter.scbdd.com/api"
def check_drug_pair(drug_id_1, drug_id_2):
"""Check interactions between two specific drugs by their DDInter IDs."""
r = requests.get(f"{BASE}/between/",
params={"drug1": drug_id_1, "drug2": drug_id_2, "format": "json"},
timeout=15)
r.raise_for_status()
return r.json()
def find_drug_id(drug_name):
"""Helper: resolve drug name to DDInter ID."""
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"},
timeout=15)
r.raise_for_status()
results = r.json()["results"]
return results[0]["ddinter_id"] if results else None
# Check warfarin + aspirin interaction
warfarin_id = find_drug_id("warfarin")
aspirin_id = find_drug_id("aspirin")
if warfarin_id and aspirin_id:
interactions = check_drug_pair(warfarin_id, aspirin_id)
count = interactions.get("count", 0)
print(f"Warfarin + Aspirin: {count} interaction(s) found")
for ix in interactions.get("results", []):
print(f" Severity: {ix.get('level')}")
print(f" Mechanism: {ix.get('mechanism', 'N/A')[:200]}")
print(f" Recommendation: {ix.get('recommendation', 'N/A')[:200]}")
else:
print(f"Could not resolve drug IDs: warfarin={warfarin_id}, aspirin={aspirin_id}")
Query 5: Filter Interactions by Severity Level
Retrieve only high-severity (major) interactions for a drug — essential for rapid clinical risk screening.
import requests
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def get_major_interactions(drug_id):
"""Get only major-severity interactions for a drug."""
all_interactions = []
r = requests.get(f"{BASE}/interaction/",
params={"drug_id": drug_id, "format": "json", "page_size": 200},
timeout=20)
r.raise_for_status()
data = r.json()
all_interactions.extend(data.get("results", []))
# Filter to major severity
major = [ix for ix in all_interactions
if ix.get("level", "").lower() == "major"]
return major
# Get major interactions for amiodarone (known high-interaction drug)
drug_id = "DDInter_D00023" # example amiodarone ID; resolve with search_drug()
major_ixs = get_major_interactions(drug_id)
print(f"Major interactions: {len(major_ixs)}")
if major_ixs:
df = pd.DataFrame(major_ixs)
# Show drug partners and mechanism type
for col in ["drug_a", "drug_b", "level", "pharmacokinetic_type"]:
if col in df.columns:
print(f" {col}: {df[col].value_counts().head(3).to_dict()}")
df.to_csv("amiodarone_major_interactions.csv", index=False)
print("Saved: amiodarone_major_interactions.csv")
Query 6: Polypharmacy Screening for a Drug List
Screen a medication list for all pairwise major and moderate interactions.
import requests
import time
import itertools
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def find_drug_id(drug_name):
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"},
timeout=15)
r.raise_for_status()
results = r.json()["results"]
return (results[0]["ddinter_id"], results[0]["drug_name"]) if results else (None, None)
def check_pair(id1, id2):
r = requests.get(f"{BASE}/between/",
params={"drug1": id1, "drug2": id2, "format": "json"},
timeout=15)
r.raise_for_status()
return r.json().get("results", [])
# Medication list to screen
medication_names = ["warfarin", "aspirin", "atorvastatin", "metformin", "amiodarone"]
# Resolve to DDInter IDs
id_map = {}
for name in medication_names:
ddid, resolved_name = find_drug_id(name)
if ddid:
id_map[name] = (ddid, resolved_name)
print(f" {name:20s} → {ddid}")
time.sleep(0.3)
# Check all pairs
flagged = []
for (n1, (id1, rn1)), (n2, (id2, rn2)) in itertools.combinations(id_map.items(), 2):
ixs = check_pair(id1, id2)
for ix in ixs:
level = ix.get("level", "unknown")
if level.lower() in ("major", "moderate"):
flagged.append({
"drug_1": rn1,
"drug_2": rn2,
"severity": level,
"mechanism": ix.get("mechanism", "")[:100],
})
time.sleep(0.3)
df = pd.DataFrame(flagged)
print(f"\nFlagged interactions: {len(df)}")
if not df.empty:
print(df.to_string(index=False))
df.to_csv("polypharmacy_screening.csv", index=False)
print("Saved: polypharmacy_screening.csv")
Query 7: Visualize Interaction Network
Build and visualize a drug-drug interaction network for a set of drugs, with edges colored by severity.
import requests
import time
import itertools
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
BASE = "https://ddinter.scbdd.com/api"
def find_drug_id(drug_name):
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"},
timeout=15)
r.raise_for_status()
results = r.json()["results"]
return (results[0]["ddinter_id"], results[0]["drug_name"]) if results else (None, None)
def check_pair(id1, id2):
r = requests.get(f"{BASE}/between/",
params={"drug1": id1, "drug2": id2, "format": "json"},
timeout=15)
r.raise_for_status()
return r.json().get("results", [])
SEVERITY_COLORS = {"major": "#D32F2F", "moderate": "#F57C00", "minor": "#388E3C"}
# Drug list
drugs = ["warfarin", "aspirin", "atorvastatin", "fluconazole", "amiodarone"]
id_map = {}
for name in drugs:
ddid, rname = find_drug_id(name)
if ddid:
id_map[name] = (ddid, rname)
time.sleep(0.3)
# Build network
G = nx.Graph()
for name, (ddid, rname) in id_map.items():
G.add_node(rname)
edge_colors = []
for (n1, (id1, rn1)), (n2, (id2, rn2)) in itertools.combinations(id_map.items(), 2):
ixs = check_pair(id1, id2)
for ix in ixs:
level = ix.get("level", "minor").lower()
G.add_edge(rn1, rn2, severity=level, weight=3 if level == "major" else 1)
time.sleep(0.3)
# Visualize
fig, ax = plt.subplots(figsize=(9, 7))
pos = nx.spring_layout(G, seed=42, k=2)
for level, color in SEVERITY_COLORS.items():
edges = [(u, v) for u, v, d in G.edges(data=True) if d.get("severity") == level]
if edges:
width = 4 if level == "major" else 2
nx.draw_networkx_edges(G, pos, edgelist=edges, edge_color=color, width=width, alpha=0.8, ax=ax)
nx.draw_networkx_nodes(G, pos, node_color="#1565C0", node_size=1200, alpha=0.9, ax=ax)
nx.draw_networkx_labels(G, pos, font_color="white", font_size=8, font_weight="bold", ax=ax)
# Legend
from matplotlib.patches import Patch
legend = [Patch(color=c, label=l.capitalize()) for l, c in SEVERITY_COLORS.items()]
ax.legend(handles=legend, title="Severity", loc="upper right")
ax.set_title("Drug-Drug Interaction Network\n(DDInter)")
ax.axis("off")
plt.tight_layout()
plt.savefig("ddi_network.png", dpi=150, bbox_inches="tight")
print(f"Saved: ddi_network.png ({G.number_of_nodes()} drugs, {G.number_of_edges()} interactions)")
Key Concepts
Severity Classification
DDInter classifies interactions into three severity levels, following established clinical pharmacology standards:
| Severity | Code | Clinical Meaning | Action |
|---|---|---|---|
| Major | major |
Potentially life-threatening or causing permanent damage | Avoid combination; use alternative |
| Moderate | moderate |
May cause clinical deterioration; increased monitoring required | Use with caution; monitor closely |
| Minor | minor |
Limited clinical effects; interaction is documented but rarely significant | Generally safe; monitor if symptomatic |
Mechanism Types
Interactions are classified by mechanism:
- Pharmacokinetic (PK): One drug affects the absorption, distribution, metabolism, or excretion (ADME) of the other (e.g., CYP enzyme inhibition)
- Pharmacodynamic (PD): Drugs have additive, synergistic, or antagonistic effects at the pharmacological target level (e.g., additive bleeding risk)
- Mixed: Both PK and PD mechanisms contribute
Drug Identification
DDInter uses its own sequential identifier scheme (e.g., DDInter_D00001 for Warfarin). There is no direct mapping to ChEMBL IDs, PubChem CIDs, or RxCUI without a prior name search. Always resolve drug names to DDInter IDs using the /drug/ endpoint before querying interactions.
Common Workflows
Workflow 1: Comprehensive DDI Profile for a Drug
Goal: Retrieve all interactions for a drug, stratify by severity, and export a structured report.
import requests
import time
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def find_drug_id(name):
r = requests.get(f"{BASE}/drug/",
params={"drug_name": name, "format": "json"},
timeout=15)
r.raise_for_status()
res = r.json()["results"]
return (res[0]["ddinter_id"], res[0]["drug_name"]) if res else (None, None)
def get_all_interactions(drug_id, page_size=200):
all_results = []
url = f"{BASE}/interaction/"
params = {"drug_id": drug_id, "format": "json", "page_size": page_size}
while url:
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
data = r.json()
all_results.extend(data.get("results", []))
url = data.get("next")
params = {}
return all_results
# Build DDI profile for clopidogrel
drug_name = "clopidogrel"
drug_id, resolved_name = find_drug_id(drug_name)
if drug_id:
print(f"Drug: {resolved_name} ({drug_id})")
ixs = get_all_interactions(drug_id)
df = pd.DataFrame(ixs)
print(f"Total interactions: {len(df)}")
if "level" in df.columns:
print("\nSeverity breakdown:")
for level, grp in df.groupby("level"):
print(f" {level:15s}: {len(grp):4d} interactions")
# Major interactions table
major = df[df["level"].str.lower() == "major"].copy()
print(f"\nMajor interactions ({len(major)}):")
for _, row in major.head(10).iterrows():
partner = row.get("drug_b") if row.get("drug_a") == resolved_name else row.get("drug_a")
mech = str(row.get("mechanism", ""))[:80]
print(f" + {partner}: {mech}")
df.to_csv(f"{drug_name}_ddi_profile.csv", index=False)
print(f"\nSaved: {drug_name}_ddi_profile.csv")
Workflow 2: Pairwise Interaction Matrix for a Drug Panel
Goal: Build a severity matrix showing all pairwise interactions between a curated drug panel — useful for clinical pharmacology and formulary review.
import requests
import time
import itertools
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
BASE = "https://ddinter.scbdd.com/api"
SEVERITY_SCORE = {"major": 3, "moderate": 2, "minor": 1, "none": 0}
def find_drug_id(name):
r = requests.get(f"{BASE}/drug/",
params={"drug_name": name, "format": "json"},
timeout=15)
r.raise_for_status()
res = r.json()["results"]
return (res[0]["ddinter_id"], res[0]["drug_name"]) if res else (None, None)
def check_pair(id1, id2):
r = requests.get(f"{BASE}/between/",
params={"drug1": id1, "drug2": id2, "format": "json"},
timeout=15)
r.raise_for_status()
return r.json().get("results", [])
# Drug panel
drug_names = ["warfarin", "aspirin", "atorvastatin", "fluconazole", "metformin"]
id_map = {}
for name in drug_names:
ddid, rname = find_drug_id(name)
if ddid:
id_map[name] = (ddid, rname)
time.sleep(0.3)
resolved = {name: rname for name, (ddid, rname) in id_map.items()}
n = len(id_map)
names = list(id_map.keys())
rnames = [resolved[n] for n in names]
# Build matrix
matrix = np.zeros((n, n), dtype=int)
for i, (n1, (id1, _)) in enumerate(id_map.items()):
for j, (n2, (id2, _)) in enumerate(id_map.items()):
if i < j:
ixs = check_pair(id1, id2)
if ixs:
worst = max(SEVERITY_SCORE.get(ix.get("level", "none").lower(), 0) for ix in ixs)
matrix[i, j] = matrix[j, i] = worst
time.sleep(0.3)
# Heatmap
fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(matrix, cmap="RdYlGn_r", vmin=0, vmax=3)
ax.set_xticks(range(n))
ax.set_yticks(range(n))
ax.set_xticklabels(rnames, rotation=30, ha="right", fontsize=9)
ax.set_yticklabels(rnames, fontsize=9)
for i in range(n):
for j in range(n):
text = ["None", "Minor", "Mod", "Major"][matrix[i, j]]
ax.text(j, i, text, ha="center", va="center", fontsize=7)
plt.colorbar(im, ax=ax, label="Severity (0=None, 3=Major)")
ax.set_title("Drug-Drug Interaction Severity Matrix\n(DDInter)")
plt.tight_layout()
plt.savefig("ddi_severity_matrix.png", dpi=150, bbox_inches="tight")
print("Saved: ddi_severity_matrix.png")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
drug_name |
/drug/ |
— | any drug name string | Search term for drug name lookup |
drug_id |
/interaction/ |
— | DDInter_DXXXXX string |
DDInter drug ID for interaction queries |
drug1, drug2 |
/between/ |
— | DDInter_DXXXXX strings |
Both required to check a specific drug pair |
format |
all endpoints | json |
json |
Response format; JSON only via API |
page_size |
/interaction/, /drug/ |
10 |
positive integer | Results per page; use 200 for bulk retrieval |
level |
response field | — | major, moderate, minor |
Interaction severity; filter client-side |
pharmacokinetic_type |
response field | — | PK, PD, mixed |
Mechanism category |
Best Practices
-
Always resolve drug names to DDInter IDs first: The API does not accept free-text drug names in interaction queries. Use
/drug/?drug_name=to obtain theddinter_id, then pass it to/interaction/or/between/. -
Handle pagination for complete interaction lists: The default page returns at most 10 results. Drugs like warfarin or amiodarone have hundreds of interactions — iterate
nextURLs untilnull:while url: data = requests.get(url, params=params).json() results.extend(data["results"]) url = data.get("next") params = {} # clear params after first request -
Use
check_pair()for targeted queries,get_interactions()for full profiles: The/between/endpoint is faster when you need one pair. The/interaction/endpoint is needed for comprehensive DDI profiling. -
Add
time.sleep(0.3)in batch loops: DDInter has no published rate limits, but polite delays prevent server-side throttling on this publicly hosted research database. -
Filter by severity client-side: The API does not support server-side severity filtering on the
/interaction/endpoint. Retrieve all interactions and filter in pandas:df = pd.DataFrame(interactions) major_only = df[df["level"].str.lower() == "major"] -
Cross-reference with clinical databases for decision support: DDInter provides evidence-based interaction records, but for clinical decisions always verify against current prescribing information in
dailymed-databaseand institutional drug interaction tools.
Common Recipes
Recipe: Quick Safety Check for a Drug Pair
When to use: Rapid single-pair interaction lookup before combining two drugs.
import requests
BASE = "https://ddinter.scbdd.com/api"
def quick_check(drug1_name, drug2_name):
"""Check interaction between two drugs by name. Returns severity or 'No interaction found'."""
def get_id(name):
r = requests.get(f"{BASE}/drug/",
params={"drug_name": name, "format": "json"},
timeout=15)
r.raise_for_status()
results = r.json()["results"]
return (results[0]["ddinter_id"], results[0]["drug_name"]) if results else (None, name)
id1, rn1 = get_id(drug1_name)
id2, rn2 = get_id(drug2_name)
if not id1 or not id2:
return f"Drug not found: {drug1_name if not id1 else drug2_name}"
r = requests.get(f"{BASE}/between/",
params={"drug1": id1, "drug2": id2, "format": "json"},
timeout=15)
r.raise_for_status()
ixs = r.json().get("results", [])
if not ixs:
return f"{rn1} + {rn2}: No interaction found in DDInter"
worst = max(ixs, key=lambda x: {"major": 3, "moderate": 2, "minor": 1}.get(x.get("level", "minor").lower(), 0))
return f"{rn1} + {rn2}: {worst.get('level', 'unknown').upper()} — {worst.get('mechanism', 'N/A')[:120]}"
print(quick_check("warfarin", "aspirin"))
print(quick_check("metformin", "atorvastatin"))
print(quick_check("warfarin", "fluconazole"))
Recipe: Count Interactions by Severity for Multiple Drugs
When to use: Generate a summary table comparing DDI burden across multiple drugs.
import requests
import time
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
def get_severity_summary(drug_name):
"""Return severity counts (major/moderate/minor) for a drug."""
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"},
timeout=15)
r.raise_for_status()
results = r.json()["results"]
if not results:
return None
drug_id = results[0]["ddinter_id"]
# Get all interactions
all_ixs = []
url = f"{BASE}/interaction/"
params = {"drug_id": drug_id, "format": "json", "page_size": 200}
while url:
r2 = requests.get(url, params=params, timeout=20)
r2.raise_for_status()
data = r2.json()
all_ixs.extend(data.get("results", []))
url = data.get("next")
params = {}
from collections import Counter
counts = Counter(ix.get("level", "unknown").lower() for ix in all_ixs)
return {
"drug": results[0]["drug_name"],
"total": len(all_ixs),
"major": counts.get("major", 0),
"moderate": counts.get("moderate", 0),
"minor": counts.get("minor", 0),
}
drugs = ["warfarin", "amiodarone", "metformin", "atorvastatin"]
records = []
for name in drugs:
summary = get_severity_summary(name)
if summary:
records.append(summary)
print(f" {name:20s} total={summary['total']:4d} major={summary['major']:3d} mod={summary['moderate']:3d} minor={summary['minor']:3d}")
time.sleep(0.5)
df = pd.DataFrame(records)
df = df.sort_values("major", ascending=False)
df.to_csv("ddi_severity_summary.csv", index=False)
print(f"\nSaved: ddi_severity_summary.csv")
Recipe: Export All Major Interactions Across a Drug List
When to use: Build a prioritized interaction alert list for formulary review or clinical decision support.
import requests
import time
import pandas as pd
BASE = "https://ddinter.scbdd.com/api"
drug_list = ["warfarin", "aspirin", "clopidogrel", "amiodarone", "fluconazole"]
all_major = []
for drug_name in drug_list:
r = requests.get(f"{BASE}/drug/",
params={"drug_name": drug_name, "format": "json"}, timeout=15)
r.raise_for_status()
res = r.json()["results"]
if not res:
continue
drug_id, resolved = res[0]["ddinter_id"], res[0]["drug_name"]
r2 = requests.get(f"{BASE}/interaction/",
params={"drug_id": drug_id, "format": "json", "page_size": 200}, timeout=30)
r2.raise_for_status()
for ix in r2.json().get("results", []):
if ix.get("level", "").lower() == "major":
all_major.append({
"query_drug": resolved,
"interaction_partner": ix.get("drug_a") if ix.get("drug_b") == resolved else ix.get("drug_b"),
"severity": "major",
"mechanism": str(ix.get("mechanism", ""))[:200],
"recommendation": str(ix.get("recommendation", ""))[:200],
})
time.sleep(0.5)
df = pd.DataFrame(all_major).drop_duplicates()
print(f"Total major interactions across {len(drug_list)} drugs: {len(df)}")
df.to_csv("major_interactions_alert_list.csv", index=False)
print("Saved: major_interactions_alert_list.csv")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found on /interaction/ |
Invalid or malformed DDInter drug ID | Re-query /drug/?drug_name= to get a valid ID; format must be DDInter_DXXXXX |
count: 0 from /drug/ search |
Drug name not matching DDInter nomenclature | Try INN name (e.g., "acetylsalicylic acid" not "aspirin"); try partial name |
| Interaction list is incomplete | Default page_size=10 truncates results |
Set page_size=200 and iterate next URLs until null |
/between/ returns empty results |
Drug pair has no curated interaction in DDInter | Absence does not mean no interaction — check dailymed-database label text |
ConnectionError or timeout |
Server temporarily unavailable | Retry with timeout=30; use exponential backoff for bulk requests |
| Duplicate interactions in bulk export | Same interaction appears from both drug perspectives | Deduplicate by (drug_a, drug_b) pair after sorting drug IDs alphabetically |
JSONDecodeError |
Server returned non-JSON error page | Check HTTP status code; r.raise_for_status() before parsing |
Related Skills
dailymed-database— FDA-approved drug label text including drug interaction sections (unstructured)fda-database— openFDA for adverse event reports and drug recall datadrugbank-database-access— DrugBank local XML with structured DDI and target dataclinpgx-database— PharmGKB for drug-gene (pharmacogenomics) interaction datapytdc-therapeutics-data-commons— TDC DDI benchmark datasets for ML model training
References
- DDInter Database — official web interface and documentation
- DDInter REST API — browsable API with endpoint documentation
- Xiong et al., Nucleic Acids Research 2022 — DDInter database paper describing curation methodology, data sources, and coverage
- WHO INN Drug Names — International Nonproprietary Names for resolving drug name variants
skills/structural-biology-drug-discovery/deepchem/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill deepchem -g -y
SKILL.md
Frontmatter
{
"name": "deepchem",
"license": "MIT",
"description": "Deep learning for drug discovery. 60+ models (GCN, GAT, AttentiveFP, MPNN, ChemBERTa, GROVER), 50+ featurizers, MoleculeNet benchmarks, HPO, transfer learning. Unified load-featurize-split-train-evaluate API. For fingerprints use rdkit-cheminformatics; for featurization-only use molfeat."
}
DeepChem — Deep Learning for Drug Discovery
Overview
DeepChem is an open-source Python framework providing a unified API for molecular machine learning across drug discovery, materials science, and quantum chemistry. It wraps 60+ model architectures (graph neural networks, transformers, classical ML) with 50+ molecular featurizers and standardized datasets (MoleculeNet), enabling end-to-end workflows from SMILES strings to trained predictive models.
When to Use
- Predicting molecular properties (solubility, toxicity, binding affinity) from SMILES
- Benchmarking models on MoleculeNet standardized datasets (BBBP, Tox21, ESOL, FreeSolv, etc.)
- Training graph neural networks on molecular graphs (GCN, GAT, AttentiveFP, MPNN, DMPNN)
- Fine-tuning pretrained chemical language models (ChemBERTa, GROVER, MolFormer)
- Running hyperparameter optimization for molecular ML models
- Virtual screening and hit prioritization with trained models
- Materials property prediction from crystal structures (CGCNN, MEGNet)
- Protein-ligand interaction modeling and binding affinity prediction
- For fingerprint-based cheminformatics without deep learning, use
rdkit-cheminformaticsinstead - For featurization only (no model training), use
molfeat-molecular-featurizationinstead
Prerequisites
- Python packages:
deepchem(core),torchortensorflow(backend-dependent models) - GPU: Recommended for graph neural networks and transformer models; CPU sufficient for classical ML and fingerprint models
- Data: SMILES strings with property labels (CSV), or MoleculeNet datasets (auto-downloaded)
# Core installation (includes RDKit, scikit-learn, XGBoost)
pip install deepchem
# With PyTorch backend (GNN models)
pip install deepchem[torch]
# With TensorFlow backend (legacy models)
pip install deepchem[tensorflow]
# Full installation (all backends + extras)
pip install deepchem[all]
Quick Start
import deepchem as dc
# Load MoleculeNet dataset with featurization + scaffold split
tasks, datasets, transformers = dc.molnet.load_delaney(featurizer="ECFP")
train, valid, test = datasets
# Train and evaluate a multitask regressor
model = dc.models.MultitaskRegressor(n_tasks=1, n_features=1024, dropouts=0.2)
model.fit(train, nb_epoch=50)
metric = dc.metrics.Metric(dc.metrics.pearson_r2_score)
print(f"Test R2: {model.evaluate(test, [metric])}") # {'pearson_r2_score': ~0.7}
Core API
Module 1: Data Loading and Processing
Load molecular data from CSV files or MoleculeNet benchmark datasets.
import deepchem as dc
# Load from CSV (SMILES + property columns)
loader = dc.data.CSVLoader(
tasks=["measured_log_solubility"],
feature_field="smiles",
featurizer=dc.feat.CircularFingerprint(size=2048, radius=3)
)
dataset = loader.create_dataset("solubility_data.csv")
print(f"Samples: {dataset.X.shape[0]}, Features: {dataset.X.shape[1]}")
# Samples: 1128, Features: 2048
# Load from SDF (3D structures)
sdf_loader = dc.data.SDFLoader(
tasks=["activity"],
featurizer=dc.feat.CoulombMatrix(max_atoms=50)
)
dataset_3d = sdf_loader.create_dataset("molecules.sdf")
# Load MoleculeNet benchmark datasets (auto-download + featurize + split)
# Available: load_delaney, load_bbbp, load_tox21, load_hiv, load_qm7, load_qm9, etc.
tasks, datasets, transformers = dc.molnet.load_tox21(featurizer="ECFP", splitter="scaffold")
train, valid, test = datasets
print(f"Tasks: {len(tasks)}, Train: {len(train)}, Test: {len(test)}")
# Tasks: 12, Train: ~6264, Test: ~631
# Inverse-transform predictions back to original scale
y_pred = model.predict(test)
y_original = transformers[0].untransform(y_pred)
Module 2: Molecular Featurization
Convert molecules to numerical representations for ML. DeepChem provides 50+ featurizers spanning fingerprints, descriptors, graph features, and Coulomb matrices.
import deepchem as dc
smiles = ["CCO", "CC(=O)O", "c1ccccc1", "CC(C)O"]
# Fingerprints (most common for classical ML)
ecfp = dc.feat.CircularFingerprint(size=2048, radius=3)
fp_features = ecfp.featurize(smiles)
print(f"ECFP shape: {fp_features.shape}") # (4, 2048)
# RDKit descriptors (interpretable physicochemical properties)
rdkit_desc = dc.feat.RDKitDescriptors()
desc_features = rdkit_desc.featurize(smiles)
print(f"Descriptor shape: {desc_features.shape}") # (4, 208)
# Graph features (for GNN models — returns ConvMol objects)
graph_feat = dc.feat.ConvMolFeaturizer()
graphs = graph_feat.featurize(smiles)
print(f"Atoms in first mol: {graphs[0].get_num_atoms()}") # 3
# Mol2Vec embeddings (pretrained word2vec on molecular substructures)
mol2vec = dc.feat.Mol2VecFingerprint()
embeddings = mol2vec.featurize(smiles)
print(f"Mol2Vec shape: {embeddings.shape}") # (4, 300)
Module 3: Model Training and Evaluation
DeepChem provides MultitaskRegressor and MultitaskClassifier as general-purpose models, plus specialized architectures for graph and sequence data.
import deepchem as dc
# Load dataset
tasks, datasets, transformers = dc.molnet.load_delaney(featurizer="ECFP")
train, valid, test = datasets
# Regression model (fingerprint input)
model = dc.models.MultitaskRegressor(
n_tasks=1,
n_features=1024,
layer_sizes=[1000, 500],
dropouts=0.25,
learning_rate=0.001,
batch_size=64,
)
model.fit(train, nb_epoch=100)
# Evaluate with multiple metrics
metrics = [
dc.metrics.Metric(dc.metrics.pearson_r2_score),
dc.metrics.Metric(dc.metrics.mean_absolute_error),
dc.metrics.Metric(dc.metrics.rms_score),
]
results = model.evaluate(test, metrics)
print(f"R2: {results['pearson_r2_score']:.3f}, MAE: {results['mean_absolute_error']:.3f}")
# Classification model (e.g., Tox21 toxicity prediction)
tasks, datasets, transformers = dc.molnet.load_tox21(featurizer="ECFP")
train, valid, test = datasets
clf = dc.models.MultitaskClassifier(
n_tasks=len(tasks),
n_features=1024,
layer_sizes=[1000, 500],
dropouts=0.5,
learning_rate=0.001,
)
clf.fit(train, nb_epoch=50)
roc_metric = dc.metrics.Metric(dc.metrics.roc_auc_score, np.mean)
print(f"Mean ROC-AUC: {clf.evaluate(test, [roc_metric])}")
Module 4: Graph Neural Networks
GNNs operate directly on molecular graphs (atoms as nodes, bonds as edges), avoiding information loss from fixed fingerprints.
import deepchem as dc
# Load with graph featurizer
tasks, datasets, transformers = dc.molnet.load_delaney(featurizer="GraphConv")
train, valid, test = datasets
# Graph Convolutional Network (Duvenaud et al.)
gcn_model = dc.models.GraphConvModel(
n_tasks=1,
mode="regression",
graph_conv_layers=[64, 64],
dense_layer_size=256,
dropout=0.2,
learning_rate=0.001,
batch_size=64,
)
gcn_model.fit(train, nb_epoch=100)
metric = dc.metrics.Metric(dc.metrics.pearson_r2_score)
print(f"GCN R2: {gcn_model.evaluate(test, [metric])}")
# AttentiveFP (Xiong et al.) — attention-based GNN, strong on molecular properties
tasks, datasets, transformers = dc.molnet.load_delaney(
featurizer=dc.feat.MolGraphConvFeaturizer(use_edges=True)
)
train, valid, test = datasets
attfp_model = dc.models.AttentiveFPModel(
n_tasks=1,
mode="regression",
num_layers=2,
graph_feat_size=200,
num_timesteps=2,
dropout=0.2,
learning_rate=0.001,
batch_size=64,
)
attfp_model.fit(train, nb_epoch=100)
print(f"AttentiveFP R2: {attfp_model.evaluate(test, [metric])}")
Module 5: Transfer Learning
Fine-tune pretrained chemical language models for downstream tasks with limited data.
import deepchem as dc
from deepchem.models.torch_models import ChemBERTaModel
# ChemBERTa — SMILES-based transformer (pretrained on 77M molecules)
tasks, datasets, transformers = dc.molnet.load_bbbp(featurizer=dc.feat.SmilesTokenizer())
train, valid, test = datasets
chemberta = ChemBERTaModel(
task="classification",
n_tasks=1,
model_dir="chemberta_finetuned/",
)
# Fine-tune on downstream task (BBB permeability)
chemberta.fit(train, nb_epoch=10)
metric = dc.metrics.Metric(dc.metrics.roc_auc_score)
print(f"ChemBERTa ROC-AUC: {chemberta.evaluate(test, [metric])}")
Module 6: Predictions on New Molecules
Run inference on new molecules with a trained model.
import deepchem as dc
import numpy as np
# Assume trained model from Module 3
# Featurize new molecules using same featurizer
featurizer = dc.feat.CircularFingerprint(size=1024, radius=2)
new_smiles = ["c1cc(O)ccc1", "CC(=O)Nc1ccc(O)cc1", "OC(=O)c1ccccc1"]
new_features = featurizer.featurize(new_smiles)
new_dataset = dc.data.NumpyDataset(X=new_features)
predictions = model.predict(new_dataset)
for smi, pred in zip(new_smiles, predictions):
print(f"{smi}: {pred[0]:.2f}")
# Ensemble predictions from multiple models for robustness
models = [model1, model2, model3] # trained models
all_preds = np.array([m.predict(new_dataset) for m in models])
ensemble_mean = all_preds.mean(axis=0)
ensemble_std = all_preds.std(axis=0)
print(f"Ensemble prediction: {ensemble_mean[0][0]:.2f} +/- {ensemble_std[0][0]:.2f}")
Key Concepts
Unified API Pattern
All DeepChem workflows follow a consistent 5-step pattern:
Load Data → Featurize → Split → Train → Evaluate
- Load:
CSVLoader,SDFLoader, ordc.molnet.load_*()(auto-loads MoleculeNet datasets) - Featurize: Pass featurizer to loader, or call
featurizer.featurize(smiles)directly - Split:
ScaffoldSplitter(recommended for drug discovery),RandomSplitter,ButinaSplitter - Train:
model.fit(train_dataset, nb_epoch=N) - Evaluate:
model.evaluate(test_dataset, metrics_list)
Model Selection Guide
| Data Type | Model | Key Feature | Use When |
|---|---|---|---|
| SMILES + fingerprints | MultitaskRegressor |
Fast, baseline | First attempt, small datasets |
| SMILES + fingerprints | MultitaskClassifier |
Multi-label | Multi-task classification (Tox21) |
| Molecular graphs | GraphConvModel |
Learned fingerprints | Medium datasets, general properties |
| Molecular graphs | GATModel |
Attention mechanism | When atom importance matters |
| Molecular graphs | AttentiveFPModel |
Graph + timestep attention | State-of-art molecular properties |
| Molecular graphs | MPNNModel |
Message passing | Complex molecular interactions |
| Molecular graphs | DMPNNModel |
Directed MPNN | Bond-level predictions |
| SMILES strings | ChemBERTaModel |
Pretrained transformer | Low-data regime, transfer learning |
| SMILES strings | GROVERModel |
Graph + transformer | Rich molecular representations |
| Crystal structures | CGCNNModel |
Crystal graph CNN | Materials property prediction |
| Crystal structures | MEGNetModel |
Graph networks | Materials and molecules |
| Protein sequences | ProteinLigandComplexModel |
Complex modeling | Binding affinity prediction |
| Tabular features | XGBoostModel, RandomForestModel |
Classical ML | Interpretability, baselines |
Featurizer Selection Guide
| Featurizer | Class | Output | Best For |
|---|---|---|---|
| ECFP/Morgan | CircularFingerprint |
Binary vector (1024-2048) | General QSAR, fast baselines |
| MACCS Keys | MACCSKeysFingerprint |
167-bit vector | Substructure filtering |
| RDKit 2D | RDKitDescriptors |
200+ descriptors | Interpretable models |
| Mol2Vec | Mol2VecFingerprint |
300-dim embedding | Similarity, clustering |
| ConvMol | ConvMolFeaturizer |
Graph features | GraphConvModel input |
| MolGraph | MolGraphConvFeaturizer |
Node + edge features | AttentiveFPModel, MPNNModel |
| Weave | WeaveFeaturizer |
Pair features | WeaveModel input |
| Coulomb Matrix | CoulombMatrix |
Atom-pair distances | QM property prediction |
| SMILES tokens | SmilesTokenizer |
Token IDs | ChemBERTa, transformer models |
Data Splitting Strategies
| Splitter | Use Case | Why |
|---|---|---|
ScaffoldSplitter |
Drug discovery (default) | Tests generalization to new chemotypes |
RandomSplitter |
Quick experiments | Baseline, but overestimates performance |
ButinaSplitter |
Diversity-based | Clusters by Tanimoto similarity |
FingerprintSplitter |
Chemical similarity | Groups structurally similar molecules |
MaxMinSplitter |
Maximum diversity test | Extreme generalization test |
Common Workflows
Workflow 1: QSAR from CSV Data
Goal: Build a property prediction model from a CSV file with SMILES and activity columns.
import deepchem as dc
import pandas as pd
# Step 1: Load and featurize CSV data
loader = dc.data.CSVLoader(
tasks=["pIC50"],
feature_field="smiles",
featurizer=dc.feat.CircularFingerprint(size=2048, radius=3),
)
dataset = loader.create_dataset("bioactivity_data.csv")
# Step 2: Normalize targets
transformer = dc.trans.NormalizationTransformer(
transform_y=True, dataset=dataset
)
dataset = transformer.transform(dataset)
# Step 3: Scaffold split (realistic for drug discovery)
splitter = dc.splits.ScaffoldSplitter()
train, valid, test = splitter.train_valid_test_split(dataset)
print(f"Train: {len(train)}, Valid: {len(valid)}, Test: {len(test)}")
# Step 4: Train model
model = dc.models.MultitaskRegressor(
n_tasks=1, n_features=2048,
layer_sizes=[1000, 500], dropouts=0.25,
learning_rate=0.001, batch_size=64,
)
model.fit(train, nb_epoch=100)
# Step 5: Evaluate
metrics = [
dc.metrics.Metric(dc.metrics.pearson_r2_score),
dc.metrics.Metric(dc.metrics.mean_absolute_error),
]
results = model.evaluate(test, metrics)
print(f"R2: {results['pearson_r2_score']:.3f}, MAE: {results['mean_absolute_error']:.3f}")
Workflow 2: MoleculeNet Benchmark Comparison
Goal: Compare multiple models on a MoleculeNet benchmark dataset.
import deepchem as dc
# Load dataset with graph featurizer (supports both fingerprint and GNN models)
tasks, datasets, transformers = dc.molnet.load_bbbp(
featurizer="GraphConv", splitter="scaffold"
)
train, valid, test = datasets
metric = dc.metrics.Metric(dc.metrics.roc_auc_score)
# Model 1: Graph Convolutional Network
gcn = dc.models.GraphConvModel(n_tasks=1, mode="classification", dropout=0.2)
gcn.fit(train, nb_epoch=50)
gcn_score = gcn.evaluate(test, [metric])
# Model 2: Random Forest baseline (needs fingerprints)
tasks_fp, datasets_fp, _ = dc.molnet.load_bbbp(featurizer="ECFP", splitter="scaffold")
train_fp, _, test_fp = datasets_fp
rf = dc.models.SklearnModel(
model=dc.models.sklearn_models.RandomForestClassifier(n_estimators=500),
model_dir="rf_model/"
)
rf.fit(train_fp)
rf_score = rf.evaluate(test_fp, [metric])
print(f"GCN ROC-AUC: {gcn_score['roc_auc_score']:.3f}")
print(f"RF ROC-AUC: {rf_score['roc_auc_score']:.3f}")
Workflow 3: Transfer Learning Pipeline
Goal: Fine-tune a pretrained model on a small dataset.
- Load pretrained ChemBERTa model (see Module 5 for code)
- Prepare downstream dataset with
SmilesTokenizerfeaturizer - Fine-tune with reduced learning rate (
1e-5to5e-5) for 5-15 epochs - Evaluate on held-out scaffold split — expect gains over fingerprint baselines when training data < 1000 samples
- Save fine-tuned model:
model.save_checkpoint() - See
references/workflows_model_catalog.mdWorkflow 1 for complete hyperparameter optimization code
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
n_features |
MultitaskRegressor/Classifier | Required | Matches featurizer output | Input feature dimension |
layer_sizes |
MultitaskRegressor/Classifier | [1000] |
[256] to [1000, 500, 250] |
Hidden layer dimensions |
dropouts |
All neural models | 0.0 |
0.0-0.5 |
Regularization strength |
learning_rate |
All neural models | 0.001 |
1e-5-0.01 |
Training step size |
batch_size |
All neural models | 100 |
16-256 |
Samples per gradient update |
nb_epoch |
model.fit() |
10 |
10-300 |
Training iterations |
size |
CircularFingerprint |
2048 |
512-4096 |
Fingerprint bit length |
radius |
CircularFingerprint |
2 |
2-4 |
Substructure neighborhood radius |
graph_conv_layers |
GraphConvModel |
[64, 64] |
[32] to [128, 128, 64] |
Graph convolution widths |
num_layers |
AttentiveFPModel |
2 |
1-5 |
GNN message passing depth |
graph_feat_size |
AttentiveFPModel |
200 |
64-512 |
Graph feature dimension |
splitter |
dc.molnet.load_*() |
"scaffold" |
"scaffold", "random", "butina" |
Data splitting strategy |
Best Practices
-
Always use scaffold splitting for drug discovery: Random splits leak structural information and overestimate performance. Scaffold splits test generalization to novel chemotypes.
-
Normalize regression targets: Apply
NormalizationTransformer(transform_y=True)before training. Remember tountransform()predictions for interpretable values. -
Start with fingerprint baselines: Train
MultitaskRegressor+ ECFP first. Only move to GNNs if fingerprint baseline is insufficient — GNNs need more data and compute.# Baseline first baseline = dc.models.MultitaskRegressor(n_tasks=1, n_features=2048) -
Match featurizer to model: GNN models require graph featurizers (
ConvMolFeaturizer,MolGraphConvFeaturizer). Fingerprint models needCircularFingerprint. Mixing causes silent errors. -
Anti-pattern -- Do not use random split for drug discovery benchmarks: Results with
RandomSplitterare not publishable for molecular property prediction. Reviewers expect scaffold or temporal splits. -
Handle missing labels in multi-task datasets: Tox21 and many bioactivity datasets have missing values. DeepChem handles NaN labels automatically during training (masked loss), but verify with
np.isnan(dataset.y).sum(). -
Use early stopping via validation set: Monitor validation loss to prevent overfitting, especially with GNN models.
Common Recipes
Recipe: Hyperparameter Search
When to use: Optimize model performance before final evaluation.
import deepchem as dc
tasks, datasets, transformers = dc.molnet.load_delaney(featurizer="ECFP")
train, valid, test = datasets
# Define parameter grid
params = {
"n_features": [1024],
"layer_sizes": [[500], [1000, 500], [1000, 500, 250]],
"dropouts": [0.1, 0.25, 0.5],
"learning_rate": [0.001, 0.0005],
}
optimizer = dc.hyper.GridHyperparamOpt(lambda **p: dc.models.MultitaskRegressor(**p))
metric = dc.metrics.Metric(dc.metrics.pearson_r2_score)
best_model, best_params, all_results = optimizer.hyperparam_search(
params, train, valid, metric, logdir="hyperparam_logs/"
)
print(f"Best params: {best_params}")
print(f"Best R2: {best_model.evaluate(test, [metric])}")
Recipe: Save and Reload Models
When to use: Deploy trained models or resume training.
# Save model checkpoint
model.save_checkpoint(model_dir="saved_model/")
# Reload model
loaded_model = dc.models.MultitaskRegressor(n_tasks=1, n_features=2048)
loaded_model.restore(model_dir="saved_model/")
predictions = loaded_model.predict(test)
Recipe: Custom Metric
When to use: Evaluate models with domain-specific metrics.
import deepchem as dc
import numpy as np
def enrichment_factor(y_true, y_pred, top_fraction=0.01):
"""Enrichment factor at top X% of ranked predictions."""
n = len(y_true)
n_top = max(int(n * top_fraction), 1)
top_indices = np.argsort(y_pred.flatten())[-n_top:]
hits_in_top = y_true.flatten()[top_indices].sum()
expected = y_true.sum() * top_fraction
return hits_in_top / expected if expected > 0 else 0.0
ef_metric = dc.metrics.Metric(enrichment_factor, mode="regression")
print(f"EF@1%: {model.evaluate(test, [ef_metric])}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ModuleNotFoundError: torch |
PyTorch not installed | pip install deepchem[torch] for GNN models |
ValueError: n_features mismatch |
Featurizer output size does not match model n_features |
Check dataset.X.shape[1] and set n_features accordingly |
| NaN loss during training | Learning rate too high or unnormalized targets | Apply NormalizationTransformer, reduce learning rate to 1e-4 |
| Low scaffold-split performance | Model memorizes scaffolds, not properties | Use more data, try GNN models, or add regularization (dropout 0.3-0.5) |
RuntimeError: CUDA out of memory |
Batch size too large for GPU | Reduce batch_size (32 or 16), or use CPU for small datasets |
FeaturizationError on some SMILES |
Invalid or complex SMILES strings | Pre-filter with RDKit: Chem.MolFromSmiles(smi) is not None |
| Model predicts constant values | Targets not normalized or too few epochs | Apply NormalizationTransformer, increase nb_epoch |
| Slow featurization | Large dataset with expensive featurizer | Use CircularFingerprint (fast) or parallelize with n_jobs parameter |
Bundled Resources
- references/workflows_model_catalog.md -- Extended workflows (hyperparameter optimization with full code, MolGAN generative models, materials property prediction with CGCNN/MEGNet, protein-ligand modeling, custom model architecture) plus complete model catalog (60+ models organized by category) and complete featurizer catalog (50+ featurizers). Covers: workflows 4-8 from original, extended model and featurizer inventories, MoleculeNet dataset catalog. Relocated inline: top 3 workflows (QSAR, MoleculeNet benchmark, transfer learning) are in Common Workflows; core model/featurizer tables are in Key Concepts. Omitted: detailed installation troubleshooting for TensorFlow 1.x (deprecated) and Docker-specific setup (covered by official docs).
Related Skills
- rdkit-cheminformatics -- molecular manipulation, fingerprints, substructure search (upstream featurization)
- molfeat-molecular-featurization -- 100+ featurizers with scikit-learn API (featurization-only alternative)
- datamol-cheminformatics -- Pythonic molecular processing (upstream data prep)
- pytdc-therapeutics-data-commons -- curated ADMET/DTI datasets with standardized splits (complementary data source)
- torch-geometric-graph-neural-networks -- lower-level PyG for custom GNN architectures (alternative for advanced users)
- scikit-learn-machine-learning -- classical ML baselines that DeepChem wraps via
SklearnModel
References
- DeepChem documentation -- official API docs and tutorials
- DeepChem GitHub -- source code, examples, issues
- MoleculeNet benchmark paper -- Wu et al. 2018, benchmark dataset descriptions
- DeepChem tutorials -- Jupyter notebook tutorials
skills/structural-biology-drug-discovery/diffdock/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill diffdock -g -y
SKILL.md
Frontmatter
{
"name": "diffdock",
"license": "MIT",
"description": "Diffusion-based docking that predicts protein-ligand poses without a predefined site. Use for blind docking, when traditional docking fails, or exploring multiple binding modes. Pipeline: prep protein (PDB) and ligand (SMILES\/SDF), run inference, analyze confidence-ranked poses."
}
diffdock
Overview
DiffDock uses a diffusion generative model to predict protein-ligand binding poses directly from protein structure and ligand SMILES, treating docking as a generative rather than a search problem. Unlike traditional docking tools (AutoDock Vina, Glide), DiffDock does not require a predefined binding site — it samples poses across the full protein surface. It outputs a ranked set of binding poses with associated confidence scores. DiffDock excels at blind docking tasks and produces diverse pose hypotheses, making it valuable for de novo binding site discovery and challenging targets.
When to Use
- Blind docking (unknown binding site): You do not know where on the protein the ligand binds and want to discover candidate binding sites.
- Challenging targets that fail traditional docking: Allosteric sites, flexible regions, or proteins without a co-crystal structure in the target binding site.
- Exploring multiple binding modes: Generating a diverse ensemble of poses to understand conformational flexibility in the binding event.
- Structure-activity relationship (SAR) exploration: Rapidly docking a series of analogs to compare predicted binding modes.
- Fragment screening hypothesis generation: Identifying plausible binding sites for fragment molecules.
- For known binding sites with rigid protein assumptions, AutoDock Vina or GNINA may be faster and equally accurate.
- For large-scale virtual screening (>10,000 compounds), consider GNINA or DiffDock-L (the large-scale version) rather than standard DiffDock.
- Use AutoDock Vina instead when the binding pocket is well-defined and faster throughput is needed for large compound libraries
Prerequisites
- Python packages:
diffdock(conda install recommended),rdkit,torch,biopython,nglview(visualization) - System: GPU strongly recommended (NVIDIA CUDA); CPU inference is slow (~5-10 min/compound)
- Data requirements: Protein PDB file (cleaned, protonated), ligand as SMILES string or SDF file
- Environment: conda environment with CUDA-compatible PyTorch
# Recommended: clone and install from source
git clone https://github.com/gcorso/DiffDock.git
cd DiffDock
conda create -n diffdock python=3.9
conda activate diffdock
pip install torch torchvision --extra-index-url https://download.pytorch.org/whl/cu118
pip install -r requirements.txt
# Download pretrained model weights
python -c "from utils.download import download_pretrained; download_pretrained()"
Workflow
Step 1: Prepare the Protein Structure
from Bio import PDB
from Bio.PDB import PDBParser, PDBIO, Select
class NonHetSelect(Select):
"""Remove HETATM records (ligands, water) — keep only protein atoms."""
def accept_residue(self, residue):
return residue.id[0] == " "
def clean_pdb(input_pdb: str, output_pdb: str):
parser = PDBParser(QUIET=True)
structure = parser.get_structure("protein", input_pdb)
io = PDBIO()
io.set_structure(structure)
io.save(output_pdb, NonHetSelect())
print(f"Cleaned PDB saved to: {output_pdb}")
clean_pdb("raw_protein.pdb", "protein_clean.pdb")
Step 2: Prepare the Ligand Input
from rdkit import Chem
from rdkit.Chem import AllChem, SDWriter
def smiles_to_sdf(smiles: str, output_sdf: str, n_confs: int = 1):
"""Convert SMILES to 3D SDF for DiffDock input."""
mol = Chem.MolFromSmiles(smiles)
mol = Chem.AddHs(mol)
AllChem.EmbedMolecule(mol, AllChem.ETKDGv3())
AllChem.MMFFOptimizeMolecule(mol)
writer = SDWriter(output_sdf)
writer.write(mol)
writer.close()
print(f"Ligand SDF written to: {output_sdf}")
return mol
# Example: ibuprofen
smiles = "CC(C)Cc1ccc(cc1)C(C)C(=O)O"
mol = smiles_to_sdf(smiles, "ligand.sdf")
print(f"Ligand formula: {Chem.rdMolDescriptors.CalcMolFormula(mol)}")
Step 3: Run DiffDock Inference
# Command-line inference (run from the DiffDock directory)
python inference.py \
--protein_path protein_clean.pdb \
--ligand "CC(C)Cc1ccc(cc1)C(C)C(=O)O" \
--out_dir results/ \
--inference_steps 20 \
--samples_per_complex 40 \
--batch_size 10 \
--no_final_step_noise
import subprocess
def run_diffdock(protein_pdb: str, ligand_smiles: str, out_dir: str,
n_samples: int = 40, n_steps: int = 20):
cmd = [
"python", "inference.py",
"--protein_path", protein_pdb,
"--ligand", ligand_smiles,
"--out_dir", out_dir,
"--inference_steps", str(n_steps),
"--samples_per_complex", str(n_samples),
"--batch_size", "10",
"--no_final_step_noise",
]
result = subprocess.run(cmd, capture_output=True, text=True, cwd="DiffDock/")
if result.returncode == 0:
print(f"DiffDock complete. Results in: {out_dir}")
else:
print(f"Error: {result.stderr}")
return result
run_diffdock("protein_clean.pdb", "CC(C)Cc1ccc(cc1)C(C)C(=O)O", "results/")
Step 4: Parse and Rank Confidence Scores
import re
from pathlib import Path
import pandas as pd
def parse_diffdock_results(out_dir: str) -> pd.DataFrame:
"""Parse DiffDock output SDF files and confidence scores."""
out_path = Path(out_dir)
records = []
# DiffDock names output files: rank{N}_confidence{score}.sdf
for sdf_file in sorted(out_path.glob("rank*_confidence*.sdf")):
name = sdf_file.stem
# Extract rank and confidence from filename
rank_match = re.search(r"rank(\d+)", name)
conf_match = re.search(r"confidence(-?[\d.]+)", name)
if rank_match and conf_match:
records.append({
"rank": int(rank_match.group(1)),
"confidence": float(conf_match.group(1)),
"sdf_file": str(sdf_file),
})
df = pd.DataFrame(records).sort_values("rank")
print(f"Found {len(df)} poses")
print(df[["rank", "confidence", "sdf_file"]].head(10))
return df
df_results = parse_diffdock_results("results/")
Step 5: Analyze Top Poses — Extract Binding Site Residues
from rdkit import Chem
from rdkit.Chem import AllChem
from Bio.PDB import PDBParser
import numpy as np
def get_binding_residues(protein_pdb: str, ligand_sdf: str, cutoff_angstrom: float = 4.0):
"""Find protein residues within cutoff distance of the top-ranked ligand pose."""
parser = PDBParser(QUIET=True)
structure = parser.get_structure("prot", protein_pdb)
prot_atoms = [(atom.get_coord(), residue.resname, residue.id[1])
for chain in structure for residue in chain
for atom in residue.get_atoms()]
mol = Chem.SDMolSupplier(ligand_sdf, removeHs=False)[0]
lig_coords = mol.GetConformer().GetPositions()
contacts = []
for prot_coord, resname, resnum in prot_atoms:
dists = np.linalg.norm(lig_coords - prot_coord, axis=1)
if dists.min() <= cutoff_angstrom:
contacts.append((resnum, resname))
contacts = sorted(set(contacts))
print(f"Binding site residues within {cutoff_angstrom} A: {contacts[:10]}")
return contacts
# Use top-ranked pose
top_sdf = df_results.loc[df_results.rank == 1, "sdf_file"].iloc[0]
contacts = get_binding_residues("protein_clean.pdb", top_sdf)
Step 6: Visualize Poses in NGLview
import nglview as nv
from rdkit import Chem
# Load protein + top pose in Jupyter notebook
view = nv.NGLWidget()
view.add_pdbfile("protein_clean.pdb")
top_sdf = df_results.loc[df_results.rank == 1, "sdf_file"].iloc[0]
view.add_component(top_sdf)
view.representations = [
{"type": "cartoon", "params": {"color": "chainindex"}},
{"type": "ball+stick", "params": {"sele": "ligand"}},
]
print(f"Visualizing top pose: confidence={df_results.confidence.iloc[0]:.3f}")
view
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
--inference_steps |
20 |
10–40 |
Number of diffusion reverse steps; more steps = slower but more accurate |
--samples_per_complex |
40 |
10–100 |
Number of poses sampled; more = better coverage of binding modes |
--batch_size |
10 |
1–32 |
GPU batch size; reduce if OOM error |
--no_final_step_noise |
off | flag | Removes noise at last diffusion step; improves pose quality |
--actual_steps |
equals inference_steps |
1–inference_steps |
Steps to actually run (can be fewer than total) |
--save_visualisation |
off | flag | Also saves PDB visualization files alongside SDF |
cutoff_angstrom |
4.0 |
3.0–6.0 Å |
Distance cutoff for defining binding site residues |
Common Recipes
Recipe: Batch Docking of Multiple Ligands
When to use: Dock a library of analogs to the same protein for SAR analysis.
import pandas as pd
import subprocess
smiles_list = [
("compound_1", "CC(C)Cc1ccc(cc1)C(C)C(=O)O"),
("compound_2", "CC(C)Cc1ccc(cc1)C(C)C(=O)N"),
("compound_3", "CC(C)Cc1ccc(cc1)C(C)C(=O)OC"),
]
results = []
for name, smiles in smiles_list:
out = f"results/{name}"
cmd = ["python", "inference.py",
"--protein_path", "protein_clean.pdb",
"--ligand", smiles,
"--out_dir", out,
"--inference_steps", "20",
"--samples_per_complex", "20"]
subprocess.run(cmd, cwd="DiffDock/", capture_output=True)
# Parse top confidence score
df_r = parse_diffdock_results(out)
if not df_r.empty:
top_conf = df_r.loc[df_r.rank == 1, "confidence"].iloc[0]
results.append({"name": name, "smiles": smiles, "top_confidence": top_conf})
df_batch = pd.DataFrame(results).sort_values("top_confidence", ascending=False)
df_batch.to_csv("batch_docking_results.csv", index=False)
print(df_batch)
Recipe: Filter Poses by Confidence Threshold
When to use: Keep only high-confidence poses for further analysis or visualization.
# Confidence > 0 generally indicates a plausible binding pose
# DiffDock confidence scores: higher = more confident; ~0 is marginal; < -1 is poor
high_conf = df_results[df_results["confidence"] > 0.0]
print(f"High-confidence poses: {len(high_conf)} / {len(df_results)}")
print(high_conf[["rank", "confidence", "sdf_file"]])
Recipe: Convert Top Pose to PDBQT for Rescoring with Vina
When to use: Rescore DiffDock poses with AutoDock Vina's energy function.
# Convert SDF to PDBQT using OpenBabel
obabel rank1_confidence0.75.sdf -O rank1_ligand.pdbqt
obabel protein_clean.pdb -O protein.pdbqt -xr
# Rescore (no docking search, just energy evaluation)
vina --receptor protein.pdbqt --ligand rank1_ligand.pdbqt \
--score_only --out rank1_rescored.pdbqt
Expected Outputs
results/rank{N}_confidence{score}.sdf— 3D ligand poses ranked by confidence scoredf_resultsDataFrame with rank, confidence score, and file path per pose- Binding site residues list (residue numbers and names within cutoff distance)
- NGLview interactive 3D visualization in Jupyter notebooks
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
CUDA out of memory |
Batch size too large for GPU | Reduce --batch_size to 4 or 2 |
| Empty results directory | Protein PDB parsing failed | Ensure PDB contains only ATOM records; remove HETATM with clean_pdb() |
| All confidence scores < -2 | Ligand or protein format issue | Validate SMILES with RDKit; ensure protein is protonated and complete |
| Very slow inference (>30 min) | Running on CPU | GPU is strongly recommended; CUDA environment must be correctly configured |
ModuleNotFoundError: e3nn |
Dependency not installed | pip install e3nn in the DiffDock conda environment |
| Poses cluster at one site | Low --samples_per_complex |
Increase to 40–100 for better site coverage |
| Protein missing residues | Incomplete crystal structure | Use MODELLER or Swiss-Model to fill gaps before docking |
References
- DiffDock GitHub (gcorso/DiffDock) — source code, installation, and pretrained weights
- Corso et al. (2023), ICLR — DiffDock paper — original diffusion docking publication
- DiffDock web server — browser-based inference without local installation
- PatentsView API Documentation — for IP analysis context
skills/structural-biology-drug-discovery/drugbank-database-access/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill drugbank-database-access -g -y
SKILL.md
Frontmatter
{
"name": "drugbank-database-access",
"license": "Unknown",
"description": "Parse local DrugBank XML for drug info, interactions, targets, and properties. Search by ID\/name\/CAS, extract DDIs with severity, map targets\/enzymes\/transporters, compute SMILES similarity. Primary via local XML; REST API rate-limited (3k\/month dev). For live bioactivity use chembl-database-bioactivity; for compound properties use pubchem-compound-search."
}
DrugBank Database — Local XML Access
Overview
Query the DrugBank comprehensive drug database (14,000+ drug entries, 5,000+ protein targets, 17,000+ drug interactions) by parsing the locally downloaded XML file with Python's ElementTree. Covers drug lookups, interaction checking, target/pathway extraction, chemical property analysis, and cross-database identifier mapping.
When to Use
- Looking up drug information (description, indication, mechanism, pharmacology) by DrugBank ID, name, or CAS number
- Checking drug-drug interactions and severity classifications for polypharmacy safety
- Extracting drug targets, enzymes, transporters, and carriers with UniProt accessions
- Retrieving chemical properties (SMILES, InChI, molecular weight) for cheminformatics analysis
- Mapping DrugBank entries to external databases (PubChem, ChEMBL, UniProt, KEGG)
- Building drug similarity matrices from molecular fingerprints
- For live bioactivity data (IC50, Ki, EC50) use
chembl-database-bioactivityinstead - For compound property lookups without downloading a database use
pubchem-compound-searchinstead
Prerequisites
- DrugBank account: Register at https://go.drugbank.com/ (free academic license)
- XML download: Download
drugbank_all_full_database.xml.zipafter registration (~1.5 GB uncompressed) - Python packages:
lxml,rdkit(similarity),pandas(tabular analysis) - REST API (optional): 3,000 req/month dev tier; use local XML for batch work
pip install lxml pandas
pip install rdkit-pypi # chemical similarity
pip install drugbank-downloader # programmatic XML download
Quick Start
import xml.etree.ElementTree as ET
NS = {'db': 'http://www.drugbank.ca'} # Required for ALL XPath queries
tree = ET.parse('drugbank_all_full_database.xml') # 30-60s for full XML
root = tree.getroot()
# Build lookup index (DrugBank ID + lowercase name → element)
drug_index = {}
for drug in root.findall('db:drug', NS):
db_id = drug.find('db:drugbank-id[@primary="true"]', NS)
name = drug.find('db:name', NS)
if db_id is not None and name is not None:
drug_index[db_id.text] = drug
drug_index[name.text.lower()] = drug
def find_drug(query):
"""Find drug by DrugBank ID, name (case-insensitive), or CAS number."""
result = drug_index.get(query) or drug_index.get(query.lower())
if result is not None:
return result
for drug in root.findall('db:drug', NS): # CAS fallback
cas = drug.find('db:cas-number', NS)
if cas is not None and cas.text == query:
return drug
return None
drug = find_drug('DB00945') # Aspirin
name = drug.find('db:name', NS).text
print(f"{name}: {drug.find('db:description', NS).text[:100]}...")
Core API
1. Data Access and Setup
import xml.etree.ElementTree as ET
NS = {'db': 'http://www.drugbank.ca'}
tree = ET.parse('drugbank_all_full_database.xml')
root = tree.getroot()
print(f"Total drug entries: {len(root.findall('db:drug', NS))}")
For memory-constrained environments, use iterparse:
drug_names = {}
for event, elem in ET.iterparse('drugbank_all_full_database.xml', events=('end',)):
if elem.tag == '{http://www.drugbank.ca}drug':
db_id = elem.find('{http://www.drugbank.ca}drugbank-id[@primary="true"]')
name = elem.find('{http://www.drugbank.ca}name')
if db_id is not None and name is not None:
drug_names[db_id.text] = name.text
elem.clear() # Free memory
print(f"Parsed {len(drug_names)} drugs via iterparse")
2. Drug Information Queries
def get_drug_info(drug_element):
"""Extract comprehensive drug information."""
def txt(path):
el = drug_element.find(path, NS)
return el.text if el is not None and el.text else None
return {
'drugbank_id': txt('db:drugbank-id[@primary="true"]'),
'name': txt('db:name'),
'type': drug_element.get('type'),
'description': txt('db:description'),
'indication': txt('db:indication'),
'mechanism_of_action': txt('db:mechanism-of-action'),
'cas_number': txt('db:cas-number'),
'groups': [g.text for g in drug_element.findall('db:groups/db:group', NS)],
}
info = get_drug_info(find_drug('Metformin'))
print(f"{info['name']} ({info['type']}): Groups={info['groups']}")
# Search by name pattern (partial match)
def search_by_name(pattern):
pattern_lower = pattern.lower()
return [d for d in root.findall('db:drug', NS)
if d.find('db:name', NS) is not None
and pattern_lower in d.find('db:name', NS).text.lower()]
statins = search_by_name('statin')
print(f"Found {len(statins)} drugs matching 'statin'")
3. Drug-Drug Interactions
def get_interactions(drug_element):
"""Extract all drug-drug interactions."""
return [{
'drugbank_id': i.find('db:drugbank-id', NS).text,
'name': i.find('db:name', NS).text,
'description': i.find('db:description', NS).text,
} for i in drug_element.findall('db:drug-interactions/db:drug-interaction', NS)]
def classify_severity(description):
"""Classify severity from interaction description text."""
if not description:
return 'unknown'
dl = description.lower()
if any(w in dl for w in ['contraindicated', 'avoid', 'fatal', 'life-threatening']):
return 'major'
if any(w in dl for w in ['increase', 'decrease', 'enhance', 'reduce', 'alter']):
return 'moderate'
return 'minor'
interactions = get_interactions(find_drug('Aspirin'))
print(f"Aspirin has {len(interactions)} interactions")
for i in interactions[:3]:
print(f" [{classify_severity(i['description'])}] {i['name']}")
# Check pairwise interaction between two drugs
def check_interaction(drug1_elem, drug2_elem):
id2 = drug2_elem.find('db:drugbank-id[@primary="true"]', NS).text
for inter in get_interactions(drug1_elem):
if inter['drugbank_id'] == id2:
inter['severity'] = classify_severity(inter['description'])
return inter
return None
result = check_interaction(find_drug('Warfarin'), find_drug('Aspirin'))
if result:
print(f"[{result['severity']}] {result['description'][:150]}")
4. Drug Targets and Pathways
def get_targets(drug_element, target_type='targets'):
"""Extract targets/enzymes/transporters/carriers.
target_type: 'targets', 'enzymes', 'transporters', or 'carriers'
"""
results = []
for target in drug_element.findall(f'db:{target_type}/db:{target_type[:-1]}', NS):
t = {
'name': (target.find('db:name', NS).text
if target.find('db:name', NS) is not None else None),
'actions': [a.text for a in target.findall('db:actions/db:action', NS) if a.text],
}
poly = target.find('db:polypeptide', NS)
if poly is not None:
t['uniprot_id'] = poly.get('id')
gene = poly.find('db:gene-name', NS)
t['gene_name'] = gene.text if gene is not None else None
results.append(t)
return results
drug = find_drug('Imatinib')
targets = get_targets(drug, 'targets')
enzymes = get_targets(drug, 'enzymes')
print(f"Imatinib — Targets: {len(targets)}, Enzymes: {len(enzymes)}")
for t in targets[:3]:
print(f" {t['name']} (UniProt: {t.get('uniprot_id', 'N/A')}) — {t['actions']}")
def get_pathways(drug_element):
"""Extract SMPDB pathway associations."""
pathways = []
for pw in drug_element.findall('db:pathways/db:pathway', NS):
name = pw.find('db:name', NS)
smpdb = pw.find('db:smpdb-id', NS)
pathways.append({
'smpdb_id': smpdb.text if smpdb is not None else None,
'name': name.text if name is not None else None,
})
return pathways
for pw in get_pathways(find_drug('Metformin')):
print(f" {pw['smpdb_id']}: {pw['name']}")
5. Chemical Properties and Similarity
def get_property(drug_element, kind_name, section='calculated'):
"""Get a single property value by kind name."""
prefix = f'db:{section}-properties/db:property'
for prop in drug_element.findall(prefix, NS):
kind = prop.find('db:kind', NS)
if kind is not None and kind.text == kind_name:
return prop.find('db:value', NS).text
return None
def get_all_properties(drug_element):
"""Extract all calculated and experimental properties as a dict."""
props = {}
for section in ('calculated', 'experimental'):
for prop in drug_element.findall(f'db:{section}-properties/db:property', NS):
kind = prop.find('db:kind', NS)
value = prop.find('db:value', NS)
if kind is not None and value is not None:
key = f'{section}_{kind.text}' if section == 'experimental' else kind.text
props[key] = value.text
return props
drug = find_drug('Aspirin')
print(f"SMILES: {get_property(drug, 'SMILES')}")
print(f"MW: {get_property(drug, 'Molecular Weight')}")
print(f"LogP: {get_property(drug, 'logP')}")
# Tanimoto similarity between drugs using RDKit Morgan fingerprints
from rdkit import Chem
from rdkit.Chem import AllChem, DataStructs
def drug_similarity(drug1_elem, drug2_elem, radius=2, nbits=2048):
smi1, smi2 = get_property(drug1_elem, 'SMILES'), get_property(drug2_elem, 'SMILES')
if not smi1 or not smi2:
return None
mol1, mol2 = Chem.MolFromSmiles(smi1), Chem.MolFromSmiles(smi2)
if mol1 is None or mol2 is None:
return None
fp1 = AllChem.GetMorganFingerprintAsBitVect(mol1, radius, nBits=nbits)
fp2 = AllChem.GetMorganFingerprintAsBitVect(mol2, radius, nBits=nbits)
return DataStructs.TanimotoSimilarity(fp1, fp2)
sim = drug_similarity(find_drug('Aspirin'), find_drug('Ibuprofen'))
print(f"Aspirin vs Ibuprofen: {sim:.3f}")
6. Cross-Database Integration
def get_external_ids(drug_element):
"""Extract all external database identifiers."""
ids = {}
for ident in drug_element.findall('db:external-identifiers/db:external-identifier', NS):
resource = ident.find('db:resource', NS)
identifier = ident.find('db:identifier', NS)
if resource is not None and identifier is not None:
ids[resource.text] = identifier.text
return ids
ids = get_external_ids(find_drug('Imatinib'))
print(f"PubChem: {ids.get('PubChem Compound')}, ChEMBL: {ids.get('ChEMBL')}, "
f"KEGG: {ids.get('KEGG Drug')}, UniProt: {ids.get('UniProtKB')}")
# Build cross-reference table for multiple drugs
import pandas as pd
def build_crossref_table(names):
rows = []
for name in names:
d = find_drug(name)
if d is None: continue
ids = get_external_ids(d)
rows.append({'drug': name,
'drugbank_id': d.find('db:drugbank-id[@primary="true"]', NS).text,
'pubchem': ids.get('PubChem Compound'),
'chembl': ids.get('ChEMBL'),
'kegg': ids.get('KEGG Drug')})
return pd.DataFrame(rows)
print(build_crossref_table(['Aspirin', 'Metformin', 'Imatinib', 'Warfarin']).to_string(index=False))
Key Concepts
XML Namespace Handling
All DrugBank XML queries require the namespace prefix. Without it, XPath returns no results.
NS = {'db': 'http://www.drugbank.ca'}
name = drug.find('db:name', NS).text # CORRECT
name = drug.find('name') # WRONG — returns None!
# For iterparse, use full URI: '{http://www.drugbank.ca}drug'
Drug Entry Structure
| Section | XPath | Content |
|---|---|---|
| Identity | db:drugbank-id, db:name, db:cas-number |
Primary identifiers |
| Pharmacology | db:description, db:indication, db:mechanism-of-action |
Clinical text, mechanism, PD/PK |
| Interactions | db:drug-interactions/db:drug-interaction |
Interacting drugs with descriptions |
| Targets | db:targets/db:target |
Protein targets with actions |
| Enzymes/Transporters/Carriers | db:enzymes/db:enzyme, etc. |
CYP450, P-gp, binding proteins |
| Pathways | db:pathways/db:pathway |
SMPDB pathway associations |
| Properties | db:calculated-properties, db:experimental-properties |
SMILES, MW, logP, etc. |
| External IDs | db:external-identifiers |
PubChem, ChEMBL, KEGG, UniProt cross-refs |
External Identifier Mapping
| Resource Name in XML | Database | Example |
|---|---|---|
PubChem Compound |
PubChem CID | 2244 |
ChEMBL |
ChEMBL | CHEMBL25 |
KEGG Drug / KEGG Compound |
KEGG | D00109 / C01405 |
UniProtKB |
UniProt | P23219 |
PharmGKB |
PharmGKB | PA452615 |
ChEBI |
ChEBI | 15365 |
Calculated Property Kinds
Common kind values: SMILES, InChI, InChIKey, Molecular Weight, Molecular Formula, logP, logS, Polar Surface Area (PSA), Rotatable Bond Count, H Bond Acceptor Count, H Bond Donor Count, pKa (strongest acidic), pKa (strongest basic), Rule of Five, Bioavailability.
Common Workflows
Workflow 1: Drug Discovery Target Analysis
Goal: Find all drugs targeting a specific gene and analyze their properties.
import xml.etree.ElementTree as ET
import pandas as pd
NS = {'db': 'http://www.drugbank.ca'}
tree = ET.parse('drugbank_all_full_database.xml')
root = tree.getroot()
target_gene = 'EGFR'
records = []
for drug in root.findall('db:drug', NS):
for target in drug.findall('db:targets/db:target', NS):
poly = target.find('db:polypeptide', NS)
if poly is None:
continue
gene = poly.find('db:gene-name', NS)
if gene is not None and gene.text == target_gene:
actions = [a.text for a in target.findall('db:actions/db:action', NS) if a.text]
records.append({
'drugbank_id': drug.find('db:drugbank-id[@primary="true"]', NS).text,
'name': drug.find('db:name', NS).text,
'groups': ', '.join(g.text for g in drug.findall('db:groups/db:group', NS)),
'actions': ', '.join(actions),
})
df = pd.DataFrame(records)
print(f"Drugs targeting {target_gene}: {len(df)}")
print(df.to_string(index=False))
Workflow 2: Polypharmacy Safety Screening
Goal: Screen a medication list for all pairwise interactions with severity ranking.
import xml.etree.ElementTree as ET
import pandas as pd
NS = {'db': 'http://www.drugbank.ca'}
tree = ET.parse('drugbank_all_full_database.xml')
root = tree.getroot()
# Build index and interaction maps
idx = {}
inter_map = {} # drugbank_id → {interacting_id: description}
for drug in root.findall('db:drug', NS):
name = drug.find('db:name', NS)
db_id = drug.find('db:drugbank-id[@primary="true"]', NS)
if name is None or db_id is None:
continue
idx[name.text.lower()] = db_id.text
imap = {}
for i in drug.findall('db:drug-interactions/db:drug-interaction', NS):
imap[i.find('db:drugbank-id', NS).text] = i.find('db:description', NS).text
inter_map[db_id.text] = imap
medications = ['Warfarin', 'Aspirin', 'Omeprazole', 'Atorvastatin', 'Metformin']
report = []
med_ids = [(m, idx.get(m.lower())) for m in medications]
for i, (n1, id1) in enumerate(med_ids):
if not id1: continue
for n2, id2 in med_ids[i+1:]:
if not id2: continue
desc = inter_map.get(id1, {}).get(id2)
if desc:
dl = desc.lower()
sev = ('MAJOR' if any(w in dl for w in ['contraindicated','avoid','fatal'])
else 'MODERATE' if any(w in dl for w in ['increase','decrease','enhance','reduce'])
else 'MINOR')
report.append({'Drug 1': n1, 'Drug 2': n2, 'Severity': sev,
'Description': desc[:120]})
df = pd.DataFrame(report)
print(f"=== Polypharmacy Report: {len(medications)} medications, {len(df)} interactions ===")
if not df.empty:
print(df.sort_values('Severity').to_string(index=False))
Key Parameters
| Parameter | Function/Endpoint | Default | Description |
|---|---|---|---|
NS (namespace dict) |
All XPath queries | {'db': 'http://www.drugbank.ca'} |
Required for all find/findall calls |
@primary="true" |
db:drugbank-id |
— | Selects the primary DrugBank ID (DB00XXX) vs secondary IDs |
target_type |
get_targets() |
'targets' |
One of: targets, enzymes, transporters, carriers |
radius |
Morgan fingerprint | 2 |
Fingerprint radius; 2 = ECFP4, 3 = ECFP6 |
nbits |
Morgan fingerprint | 2048 |
Bit vector length; higher = fewer hash collisions |
events |
ET.iterparse() |
— | Parse events; use ('end',) to fire on closing tags |
Best Practices
- Build an in-memory index on startup: Parse once (30-60s), build dict by ID + lowercase name. Never re-parse inside a loop
- Always pass the namespace dict: Every
find()/findall()needsNS. Omitting it is the #1 source of empty results - Use
iterparsefor memory constraints: Withelem.clear(), avoids loading the full 1.5 GB tree - Guard against None: Not all drugs have all fields. Always check
el is not Nonebefore.text - Prefer calculated over experimental properties: Calculated (SMILES, logP, MW) available for nearly all drugs
- Cache interaction maps for polypharmacy: Pre-build
{drug_id: {interacting_id: desc}}once
Common Recipes
Recipe: Export All Drug Properties to CSV
import pandas as pd
records = []
for drug in root.findall('db:drug', NS):
row = {'drugbank_id': drug.find('db:drugbank-id[@primary="true"]', NS).text,
'name': drug.find('db:name', NS).text, 'type': drug.get('type')}
for prop in drug.findall('db:calculated-properties/db:property', NS):
row[prop.find('db:kind', NS).text] = prop.find('db:value', NS).text
records.append(row)
pd.DataFrame(records).to_csv('drugbank_properties.csv', index=False)
print(f"Exported {len(records)} drugs")
Recipe: Lipinski Rule-of-5 Filter
def check_lipinski(drug_element):
props = get_all_properties(drug_element)
try:
mw = float(props.get('Molecular Weight', 9999))
logp = float(props.get('logP', 99))
hba = int(props.get('H Bond Acceptor Count', 99))
hbd = int(props.get('H Bond Donor Count', 99))
except (ValueError, TypeError):
return None
violations = sum([mw > 500, logp > 5, hba > 10, hbd > 5])
return {'MW': mw, 'logP': logp, 'HBA': hba, 'HBD': hbd,
'violations': violations, 'passes': violations <= 1}
print(check_lipinski(find_drug('Imatinib')))
Recipe: Find CYP450 Substrates
cyp = 'CYP3A4'
substrates = []
for drug in root.findall('db:drug', NS):
for enz in drug.findall('db:enzymes/db:enzyme', NS):
n = enz.find('db:name', NS)
if n is not None and n.text and cyp.lower() in n.text.lower():
actions = [a.text.lower() for a in enz.findall('db:actions/db:action', NS) if a.text]
if 'substrate' in actions:
substrates.append(drug.find('db:name', NS).text)
print(f"{cyp} substrates: {len(substrates)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
find() returns None for known elements |
Missing XML namespace | Always pass NS = {'db': 'http://www.drugbank.ca'} to find()/findall() |
MemoryError parsing full XML |
~2-3 GB in memory | Use ET.iterparse() with elem.clear() |
| Slow startup (>60s) | Parsing 1.5 GB XML | Parse once, build index dict; avoid re-parsing |
| Drug not found by name | Case sensitivity or alternate name | Normalize to lowercase; try CAS or DrugBank ID |
Empty calculated-properties |
Biotech/protein drugs lack SMILES | Check drug.get('type') — biotech drugs have no small-molecule properties |
AttributeError: 'NoneType' |
Optional XML element absent | Guard with el is not None before .text |
| Asymmetric interaction counts | Interactions not symmetric in XML | Check both directions or build symmetric index |
drugbank-downloader auth failure |
Invalid credentials | Verify account at https://go.drugbank.com/ |
REST API 429 |
Exceeded rate limit | Switch to local XML for batch queries |
Bundled Resources
references/interactions_targets.md — Consolidates interactions (severity heuristics, batch screening, description parsing) and targets/pathways (polypeptide details, action catalogs, enzyme/transporter coverage, pathway enrichment). Relocated inline: basic extraction (Core API 3-4). Omitted: verbose per-field parsing duplicating Core API.
references/chemical_analysis.md — Property extraction, descriptor computation, fingerprint similarity, drug-likeness filtering. Covers: full property catalog, similarity matrices, substructure search. Relocated inline: SMILES/InChI extraction + Tanimoto (Core API 5), Lipinski (Recipe). Omitted: 3D conformers (use rdkit-cheminformatics).
Original disposition (2,717 lines: SKILL.md 190 + 5 refs 2,166 + script 351):
SKILL.md(190) — Stub rewritten with 6 Core API modulesdata-access.md(243) → Core API 1 + Quick Startdrug-queries.md(387) → Core API 2interactions.md(426) →references/interactions_targets.md+ Core API 3targets-pathways.md(519) →references/interactions_targets.md+ Core API 4chemical-analysis.md(591) →references/chemical_analysis.md+ Core API 5drugbank_helper.py(351) — Thin wrappers:find_drug→ Quick Start;get_drug_info/search_by_name→ Core API 2;get_interactions/check_interaction/check_polypharmacy→ Core API 3;get_targets→ Core API 4;get_properties/get_smiles/get_inchi→ Core API 5
Retention: ~550 lines SKILL.md. With references (~600), aggregate ~1,150 / 2,717 = ~42%. Stub original; 5 refs → 2 via ceil(5/3)=2.
Related Skills
- chembl-database-bioactivity — Live bioactivity database (IC50, Ki, EC50); complements DrugBank's static drug catalog
- pubchem-compound-search — Public compound property lookups without downloading a database
- rdkit-cheminformatics — Full cheminformatics toolkit for 3D conformers, advanced fingerprints, descriptors beyond DrugBank properties
References
- DrugBank website: https://go.drugbank.com/
- DrugBank XML schema: https://docs.drugbank.com/xml/
- drugbank-downloader: https://pypi.org/project/drugbank-downloader/
- Wishart DS et al. (2018). DrugBank 5.0. Nucleic Acids Res. 46(D1):D1074-D1082. https://doi.org/10.1093/nar/gkx1037
skills/structural-biology-drug-discovery/emdb-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill emdb-database -g -y
SKILL.md
Frontmatter
{
"name": "emdb-database",
"license": "CC-BY-4.0",
"description": "Look up EMDB cryo-EM density maps and fitted atomic models via the entry REST API + EBI Search WS. Fetch entry metadata (resolution, method, organism, sample), map download URLs, fitted PDB IDs, and citations. Keyword search via EBI Search. No auth. For atomic coordinates use pdb-database; for AlphaFold predictions use alphafold-database-access."
}
EMDB Database
Overview
The Electron Microscopy Data Bank (EMDB) at EBI archives 3D electron microscopy density maps — primarily cryo-EM and cryo-ET — for macromolecular assemblies (30,000+ entries: ribosomes, membrane proteins, viruses, large complexes). Access is split across two services:
- EMDB Entry API (
https://www.ebi.ac.uk/emdb/api/entry/{EMD-XXXXX}) — the canonical per-entry JSON containing metadata, map header, fitted PDB list, and citation. Sub-endpoints like/map,/fitted,/publicationsdo not exist — all those data live inside the single entry response. - EBI Search WS (
https://www.ebi.ac.uk/ebisearch/ws/rest/emdb) — the real keyword search backend (the barehttps://www.ebi.ac.uk/emdb/api/search/endpoint ignores the query and just returns recent entries).
No authentication or API key is required.
When to Use
- Finding cryo-EM density maps by keyword (e.g., "spike protein", "ribosome 70S")
- Fetching the download URL of a
.map.gzdensity file for use in ChimeraX / PyMOL - Identifying fitted PDB atomic models for an EMDB map (and the reverse)
- Retrieving entry metadata — resolution, reconstruction method, organism, sample
- Listing cryo-EM structures filtered by organism or resolution cutoff
- Pulling the primary citation (journal, DOI, PubMed ID) for an EMDB entry
- Use
pdb-databaseinstead when you need experimentally determined atomic coordinates - Use
alphafold-database-accessfor AI-predicted structures; EMDB is for experimental EM maps only
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: EMDB entry IDs (
EMD-XXXXX), keyword search strings, or PDB IDs for cross-referencing - Environment: internet connection; no API key
- Rate limits: no official published limits; add
time.sleep(0.2)between requests in batch loops for polite access
pip install requests pandas matplotlib
Quick Start
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
EBI_SEARCH = "https://www.ebi.ac.uk/ebisearch/ws/rest/emdb"
# Keyword search via EBI Search WS (NOT /emdb/api/search/, which ignores q=)
r = requests.get(EBI_SEARCH,
params={"query": "sars-cov-2 spike", "size": 5, "format": "json",
"fields": "id,name,resolution,em_method,organism"},
timeout=30)
r.raise_for_status()
res = r.json()
print(f"Total hits: {res['hitCount']}")
for e in res["entries"]:
f = e["fields"]
name = (f.get("name") or [""])[0][:60]
resol = (f.get("resolution") or ["?"])[0]
print(f" {e['id']}: {name} ({resol} Å)")
Core API
Query 1: Keyword Search (EBI Search WS)
Returns a paged hit list keyed by EMDB ID, with the requested fields per entry.
import requests, pandas as pd
EBI_SEARCH = "https://www.ebi.ac.uk/ebisearch/ws/rest/emdb"
def emdb_search(query, size=20, start=0,
fields="id,name,resolution,em_method,organism"):
r = requests.get(EBI_SEARCH,
params={"query": query, "size": size, "start": start,
"format": "json", "fields": fields},
timeout=30)
r.raise_for_status()
return r.json()
data = emdb_search("ribosome 70S", size=10)
rows = []
for e in data["entries"]:
f = e["fields"]
rows.append({
"emdb_id": e["id"],
"name": (f.get("name") or [""])[0],
"resolution_A": float((f.get("resolution") or [0])[0] or 0) or None,
"em_method": (f.get("em_method") or [""])[0],
"organism": (f.get("organism") or [""])[0],
})
df = pd.DataFrame(rows)
print(f"hitCount={data['hitCount']}; first {len(df)} rows:")
print(df.to_string(index=False))
# Paged retrieval — iterate `start` until exhausting hitCount
def emdb_search_all(query, size=100, max_pages=5,
fields="id,name,resolution,em_method"):
out = []
for page in range(max_pages):
d = emdb_search(query, size=size, start=page * size, fields=fields)
if not d["entries"]:
break
out.extend(d["entries"])
if len(out) >= d["hitCount"]:
break
return out
hits = emdb_search_all("ferritin", size=50, max_pages=2)
print(f"Pulled {len(hits)} ferritin entries")
Query 2: Entry Metadata
The single entry endpoint returns all metadata, the map header, fitted PDB list, and the citation in one document. Read field paths carefully — most are nested.
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
def emdb_entry(emdb_id):
r = requests.get(f"{EMDB_API}/entry/{emdb_id}", timeout=30)
r.raise_for_status()
return r.json()
e = emdb_entry("EMD-30210") # nsp12-nsp7-nsp8 + Remdesivir (RdRp)
print(f"Title : {e['admin']['title']}")
print(f"Status : {e['admin']['current_status']}")
print(f"Key dates : {e['admin'].get('key_dates')}")
sd = e["structure_determination_list"]["structure_determination"][0]
ip = sd["image_processing"][0]
res = ip["final_reconstruction"]["resolution"]
print(f"Method : {sd['method']}")
print(f"Resolution : {res['valueOf_']} {res['units']} (type: {res['res_type']})")
Query 3: Map Header / Download Info
entry["map"] contains the file name, format, voxel grid, axis order, cell, contour level(s), and recommended display threshold.
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
m = e["map"]
print(f"Map file : {m.get('file')}") # e.g. emd_30210.map.gz
print(f"Format : {m.get('format')}") # 'CCP4'
print(f"Dimensions : {m.get('dimensions')}") # {'col': ..., 'row': ..., 'sec': ...}
print(f"Axis order : {m.get('axis_order')}") # {'fast': 'X', 'medium': 'Y', 'slow': 'Z'}
print(f"Contour list : {m.get('contour_list')}") # recommended threshold(s)
# Conventional FTP/HTTPS download URL pattern (mirror at EBI):
EMDB_FTP = "https://ftp.ebi.ac.uk/pub/databases/emdb/structures"
emdb_num = "EMD-30210"
print(f"Download URL : {EMDB_FTP}/{emdb_num}/map/{m['file']}")
Query 4: Fitted PDB Atomic Models
EMDB cross-references the PDB entries that fitted into the map. Each item has the PDB ID and a relationship tag (e.g., FULLOVERLAP).
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
pdblist = e.get("crossreferences", {}).get("pdb_list", {})
pdb_refs = pdblist.get("pdb_reference", []) if isinstance(pdblist, dict) else []
print(f"Fitted PDB entries: {len(pdb_refs)}")
for ref in pdb_refs:
in_frame = (ref.get("relationship") or {}).get("in_frame")
print(f" {ref['pdb_id'].upper():6s} relationship={in_frame}")
# 7BV2 relationship=FULLOVERLAP
Query 5: Citation and Publications
Primary citation lives at entry["crossreferences"]["citation_list"]. The shape varies by citation type (journal vs. preprint).
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
citations = e.get("crossreferences", {}).get("citation_list", {})
primary = citations.get("primary_citation", {})
ct = primary.get("citation_type") or primary
title = (ct.get("title") or "").strip()
journal = ct.get("journal") or ct.get("book_title")
year = ct.get("year")
authors = [a.get("name") or a.get("name_str")
for a in (ct.get("author") or ct.get("author_order") or [])
if a]
xrefs = ct.get("external_references", []) or ct.get("xref", [])
doi = next((x.get("valueOf_") for x in xrefs if x.get("type") == "DOI"), None)
pmid = next((x.get("valueOf_") for x in xrefs if x.get("type") == "PUBMED"), None)
print(f"Title : {title[:80]}")
print(f"Journal : {journal} ({year})")
print(f"Authors : {', '.join(a for a in authors[:5] if a)}{'...' if len(authors) > 5 else ''}")
print(f"DOI : {doi}")
print(f"PMID : {pmid}")
Query 6: Sample and Organism
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
sample = e.get("sample", {})
sm_list = (sample.get("supramolecule_list") or {}).get("supramolecule", [])
for sm in sm_list:
ns = sm.get("natural_source") or sm.get("natural_source_list", {}).get("natural_source") or []
if isinstance(ns, dict):
ns = [ns]
for n in ns:
org = (n.get("organism") or {}).get("valueOf_")
print(f" Supramolecule '{sm.get('name', {}).get('valueOf_', '?')}': {org}")
Key Concepts
Field-Path Map (/api/entry/{id} document)
| What | Path |
|---|---|
| Title | admin.title |
| Release dates | admin.key_dates |
| Authors | admin.authors_list.author[*].name/name_str |
| EM method | structure_determination_list.structure_determination[0].method (e.g. singleParticle, tomography) |
| Resolution | structure_determination_list.structure_determination[0].image_processing[0].final_reconstruction.resolution.valueOf_ (string Å; cast to float) |
| Map file name | map.file |
| Map format/dims | map.format / map.dimensions |
| Contour level(s) | map.contour_list |
| Fitted PDB | crossreferences.pdb_list.pdb_reference[*].pdb_id |
| Citation | crossreferences.citation_list.primary_citation.citation_type (journal/year/title/authors/external_references) |
| DOI/PubMed | …citation_type.external_references[] with type in {DOI, PUBMED} |
| Organism | sample.supramolecule_list.supramolecule[*].natural_source[*].organism.valueOf_ |
Search vs. Entry
- Keyword search → EBI Search WS (
/ebisearch/ws/rest/emdb). Use this forquery=,size=,start=, andfields=selection. /emdb/api/search/is unreliable — it silently ignoresq=and returns the latest released entries. Don't depend on it.- Entry detail →
/emdb/api/entry/{EMD-XXXXX}. There is no/map,/fitted, or/publicationssub-endpoint — they're all inside the entry document.
Common Workflows
Workflow 1: Resolution Filter for a Topic
Goal: Find SARS-CoV-2 spike entries at high resolution and report their fitted PDB models.
import requests, time, pandas as pd
EBI_SEARCH = "https://www.ebi.ac.uk/ebisearch/ws/rest/emdb"
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
# Step 1: keyword search via EBI Search
hits = []
for start in (0, 50):
r = requests.get(EBI_SEARCH,
params={"query": "sars-cov-2 spike", "size": 50, "start": start,
"format": "json", "fields": "id,name,resolution"},
timeout=30)
r.raise_for_status()
hits.extend(r.json()["entries"])
time.sleep(0.2)
# Step 2: filter to resolution ≤ 3.0 Å (skip entries with missing field)
rows = []
for h in hits:
f = h["fields"]
name = (f.get("name") or [""])[0]
try:
resol = float((f.get("resolution") or [None])[0])
except (TypeError, ValueError):
continue
if resol <= 3.0:
rows.append({"emdb_id": h["id"], "resolution_A": resol, "name": name[:60]})
df = pd.DataFrame(rows).sort_values("resolution_A")
print(f"Spike entries ≤ 3.0 Å: {len(df)}")
print(df.head(10).to_string(index=False))
# Step 3: pull fitted PDB ids for the top 5
for emdb_id in df["emdb_id"].head(5):
e = requests.get(f"{EMDB_API}/entry/{emdb_id}", timeout=30).json()
pdblist = e.get("crossreferences", {}).get("pdb_list", {}) or {}
pdbs = [p["pdb_id"].upper() for p in pdblist.get("pdb_reference", [])]
print(f" {emdb_id} -> PDB: {', '.join(pdbs) if pdbs else '(none fitted)'}")
time.sleep(0.2)
Workflow 2: Build a Cohort Metadata Table
Goal: For a query, return a DataFrame with method, resolution, organism, and map download URL — useful for survey papers.
import requests, time, pandas as pd
EBI_SEARCH = "https://www.ebi.ac.uk/ebisearch/ws/rest/emdb"
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
EMDB_FTP = "https://ftp.ebi.ac.uk/pub/databases/emdb/structures"
def cohort_table(query, size=10):
r = requests.get(EBI_SEARCH,
params={"query": query, "size": size, "format": "json", "fields": "id"},
timeout=30)
r.raise_for_status()
rows = []
for h in r.json()["entries"]:
emdb_id = h["id"]
e = requests.get(f"{EMDB_API}/entry/{emdb_id}", timeout=30).json()
sd = e["structure_determination_list"]["structure_determination"][0]
ip = sd["image_processing"][0]
try:
resol = float(ip["final_reconstruction"]["resolution"]["valueOf_"])
except (KeyError, ValueError, TypeError):
resol = None
m = e["map"]
rows.append({
"emdb_id": emdb_id,
"title": e["admin"]["title"][:60],
"method": sd.get("method"),
"resolution_A": resol,
"map_url": f"{EMDB_FTP}/{emdb_id}/map/{m['file']}" if m.get("file") else None,
})
time.sleep(0.2)
return pd.DataFrame(rows)
df = cohort_table("ribosome 70S bacterial", size=6)
print(df.to_string(index=False))
df.to_csv("emdb_cohort.csv", index=False)
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
query |
EBI Search /emdb |
required | text search string | Keyword to match |
size |
EBI Search /emdb |
15 |
1–100 | Hits per page |
start |
EBI Search /emdb |
0 |
non-negative int | Pagination offset |
fields |
EBI Search /emdb |
(subset) | comma-separated EBI Search fields | Which per-hit fields to return (id,name,resolution,em_method,organism, etc.) |
format |
EBI Search /emdb |
json |
json, xml |
Response format |
(path) {EMD-XXXXX} |
/api/entry/{id} |
required | EMDB accession | Single-entry detail |
Best Practices
- Always use EBI Search WS for keyword search.
https://www.ebi.ac.uk/emdb/api/search/ignoresq=and just returns the latest releases — relying on it produces silently wrong cohorts. - There are no sub-endpoints. Don't call
/api/entry/{id}/map,/fitted,/publications, or/api/statistics/— all return 404 (or HTML for/statistics/). Read everything from the single entry document. - Cast
resolution.valueOf_to float explicitly. The field is a string like"2.5"; numeric filters need an explicit cast (withtry/exceptfor entries that lack a value). - EBI Search field values arrive as lists. Even single-valued fields like
namecome as{"name": ["..."]}— always index[0]or join. - Add
time.sleep(0.2)in entry-by-entry loops. No rate limit is published, but the API is hosted on a shared service; polite spacing avoids transient 502s. - Map download URLs follow
https://ftp.ebi.ac.uk/pub/databases/emdb/structures/{EMDB_ID}/map/{file}— derive them fromentry["map"]["file"], don't hardcode.
Common Recipes
Recipe: PDB → EMDB Cross-Reference
import requests
# Given an EMDB ID, get its fitted PDB; given a PDB ID, you'd query
# RCSB PDB /rest/v2/entry/{pdb_id} and read `rcsb_external_references.emdb_id`.
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
pdblist = e.get("crossreferences", {}).get("pdb_list") or {}
print({"emdb": e["emdb_id"],
"pdbs": [p["pdb_id"].upper() for p in pdblist.get("pdb_reference", [])]})
Recipe: Top Resolutions for an Organism
import requests, time, pandas as pd
EBI_SEARCH = "https://www.ebi.ac.uk/ebisearch/ws/rest/emdb"
def best_by_organism(organism, size=50):
r = requests.get(EBI_SEARCH,
params={"query": f'organism:"{organism}"', "size": size,
"format": "json", "fields": "id,name,resolution"},
timeout=30)
r.raise_for_status()
rows = []
for h in r.json()["entries"]:
f = h["fields"]
try:
resol = float((f.get("resolution") or [None])[0])
except (TypeError, ValueError):
continue
rows.append({"emdb_id": h["id"], "resolution_A": resol,
"name": (f.get("name") or [""])[0][:60]})
return pd.DataFrame(rows).sort_values("resolution_A").reset_index(drop=True)
df = best_by_organism("Saccharomyces cerevisiae", size=20)
print(df.head(8).to_string(index=False))
Recipe: Citation Export (BibTeX-ready)
import requests
EMDB_API = "https://www.ebi.ac.uk/emdb/api"
e = requests.get(f"{EMDB_API}/entry/EMD-30210", timeout=30).json()
ct = (e.get("crossreferences", {})
.get("citation_list", {})
.get("primary_citation", {})
.get("citation_type")) or {}
authors = [a.get("name") or a.get("name_str")
for a in (ct.get("author") or ct.get("author_order") or [])]
xrefs = ct.get("external_references", []) or ct.get("xref", [])
doi = next((x.get("valueOf_") for x in xrefs if x.get("type") == "DOI"), "")
print({"title": (ct.get("title") or "").strip(),
"journal": ct.get("journal"),
"year": ct.get("year"),
"authors_n": len(authors),
"doi": doi})
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
KeyError: 'results' or 'numFound' on EMDB search |
/api/search/ returns a raw JSON array and ignores q= |
Use EBI Search WS (/ebisearch/ws/rest/emdb) — wrapper is {hitCount, entries, facets} |
HTTP 404 on /api/entry/{id}/map (or /fitted, /publications) |
These sub-endpoints don't exist | Read entry["map"], entry["crossreferences"]["pdb_list"], entry["crossreferences"]["citation_list"] from the single entry response |
| Resolution comes back as a string | EMDB stores numeric fields as strings | float(entry[...]['resolution']['valueOf_']) — wrap in try/except |
| EBI Search fields look like single-element lists | EBI Search returns multi-valued fields as lists | Read f["name"][0] (or fall back to (... or [""])[0]) |
Empty entries from EBI Search |
Wrong field qualifier or typo | Drop the field qualifier, search plain text; check at https://www.ebi.ac.uk/ebisearch/ |
HTML response from /api/statistics/ |
/statistics/ returns HTML, not JSON |
Endpoint is for the web UI; don't call it programmatically |
pdb_list is {} for an entry |
Map has no fitted atomic model | This is genuine — many tomograms / sub-tomogram averages lack fitted PDB |
Related Skills
pdb-database— RCSB PDB for the atomic-model side of EMDB cross-referencesalphafold-database-access— AI-predicted structures (complement to experimental EMDB maps)uniprot-protein-database— Resolve organism / sequence context for an EMDB samplecellxgene-census— Tissue/cell-type expression data complementary to structural surveys
References
- EMDB at EBI — Browse, download, statistics, deposition
- EMDB Entry API — Example entry JSON
- EBI Search WS — EMDB — Keyword search docs
- EMDB FTP mirror — Density map and metadata downloads
- Lawson CL et al. "EMDB — the Electron Microscopy Data Bank." Nucleic Acids Research 52(D1): D456–D465 (2024). https://doi.org/10.1093/nar/gkad1019
skills/structural-biology-drug-discovery/fda-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill fda-database -g -y
SKILL.md
Frontmatter
{
"name": "fda-database",
"license": "CC0-1.0",
"description": "Query openFDA REST API for adverse events (FAERS), labeling, product info, recalls, enforcement. Search by drug name, ingredient, MedDRA, or NDC. 1k req\/day no key; 120k with free key. For trials use clinicaltrials-database-search; for structures use drugbank-database-access or chembl-database-bioactivity."
}
openFDA Drug and Adverse Event Database
Overview
openFDA provides public access to FDA regulatory data through a simple REST API. Key datasets include the FDA Adverse Event Reporting System (FAERS) with 20M+ adverse event reports, drug product labeling (NDC, SPL), drug approvals (Drugs@FDA), medical device reports, and recall enforcement actions. The API supports full-text search and structured queries using Elasticsearch-style syntax.
When to Use
- Retrieving adverse event reports for a drug to assess safety signals and side effect profiles
- Querying FAERS for disproportionality analysis (comparing drug vs. drug adverse event profiles)
- Looking up official drug labeling (indications, contraindications, warnings, dosing) by drug name or NDC
- Searching for drug recalls and enforcement actions by drug name or company
- Identifying all marketed products containing a given active ingredient
- Building pharmacovigilance pipelines that monitor drug safety signals from public regulatory data
- For clinical trial efficacy data use
clinicaltrials-database-search; for drug structures/targets usedrugbank-database-access
Prerequisites
- Python packages:
requests,pandas - Data requirements: drug names, active ingredients, MedDRA terms, NDC codes
- Environment: internet connection; no authentication required for basic use
- Rate limits: 1000 req/day without API key; 120,000 req/day with free API key from https://open.fda.gov/apis/authentication/
pip install requests pandas
Quick Start
import requests
BASE = "https://api.fda.gov/drug"
# Optional: add api_key parameter for higher rate limits
# Find adverse events for aspirin
r = requests.get(
f"{BASE}/event.json",
params={
"search": 'patient.drug.medicinalproduct:"aspirin"',
"count": "patient.reaction.reactionmeddrapt.exact",
"limit": 10
}
)
r.raise_for_status()
data = r.json()
print("Top adverse reactions for aspirin:")
for item in data["results"][:5]:
print(f" {item['term']:40s} count={item['count']}")
Core API
Query 1: Adverse Event Report Search (FAERS)
Search the FDA Adverse Event Reporting System for drug-event associations.
import requests, pandas as pd
BASE = "https://api.fda.gov/drug"
def faers_search(drug_name, limit=100):
"""Search FAERS for adverse event reports mentioning a drug."""
r = requests.get(f"{BASE}/event.json",
params={"search": f'patient.drug.medicinalproduct:"{drug_name}"',
"limit": limit})
r.raise_for_status()
return r.json()
data = faers_search("warfarin", limit=5)
total = data["meta"]["results"]["total"]
print(f"Total FAERS reports for warfarin: {total:,}")
# Show first report summary
report = data["results"][0]
print(f"\nReport {report['safetyreportid']}:")
print(f" Date : {report.get('receivedate', 'n/a')}")
print(f" Serious : {report.get('serious', 'n/a')}")
drugs = [d.get("medicinalproduct", "n/a") for d in report.get("patient", {}).get("drug", [])]
print(f" Drugs : {drugs[:5]}")
reactions = [r.get("reactionmeddrapt", "n/a") for r in report.get("patient", {}).get("reaction", [])]
print(f" Reactions: {reactions[:5]}")
Query 2: Count Top Adverse Events for a Drug
Use the count parameter to aggregate adverse event terms.
import requests, pandas as pd
BASE = "https://api.fda.gov/drug"
def top_adverse_events(drug_name, limit=20):
"""Get the most frequently reported adverse events for a drug."""
r = requests.get(f"{BASE}/event.json",
params={
"search": f'patient.drug.medicinalproduct:"{drug_name}"',
"count": "patient.reaction.reactionmeddrapt.exact",
"limit": limit
})
r.raise_for_status()
results = r.json()["results"]
return pd.DataFrame(results).rename(columns={"term": "reaction", "count": "reports"})
df_atorvastatin = top_adverse_events("atorvastatin", limit=15)
print("Top adverse events for atorvastatin:")
print(df_atorvastatin.head(10).to_string(index=False))
df_atorvastatin.to_csv("atorvastatin_adverse_events.csv", index=False)
# Compare two drugs: adverse event profile overlap
df_drug1 = top_adverse_events("simvastatin", limit=20)
df_drug2 = top_adverse_events("atorvastatin", limit=20)
common = set(df_drug1["reaction"]) & set(df_drug2["reaction"])
print(f"\nCommon adverse events (simvastatin ∩ atorvastatin): {len(common)}")
print("Shared reactions:", list(common)[:10])
Query 3: Drug Labeling Search
Retrieve official drug labels (indications, warnings, dosing, contraindications).
import requests
BASE = "https://api.fda.gov/drug"
def get_label(drug_name):
"""Retrieve FDA drug label by brand or generic name."""
r = requests.get(f"{BASE}/label.json",
params={"search": f'openfda.brand_name:"{drug_name}"',
"limit": 1})
if r.status_code == 404:
r = requests.get(f"{BASE}/label.json",
params={"search": f'openfda.generic_name:"{drug_name}"',
"limit": 1})
r.raise_for_status()
results = r.json()["results"]
return results[0] if results else None
label = get_label("Lipitor")
if label:
print(f"Brand name : {label.get('openfda', {}).get('brand_name', ['n/a'])[0]}")
print(f"Generic name: {label.get('openfda', {}).get('generic_name', ['n/a'])[0]}")
print(f"Manufacturer: {label.get('openfda', {}).get('manufacturer_name', ['n/a'])[0]}")
indications = label.get("indications_and_usage", ["n/a"])[0]
print(f"\nIndications (first 300 chars):\n{indications[:300]}...")
Query 4: Drug Product Lookup by NDC
Retrieve marketed product information by National Drug Code.
import requests, pandas as pd
BASE = "https://api.fda.gov/drug"
def ndc_search(ndc_or_name, limit=10):
"""Search NDC directory for drug product information."""
# Search by product name or NDC
r = requests.get(f"{BASE}/ndc.json",
params={"search": f'generic_name:"{ndc_or_name}"',
"limit": limit})
r.raise_for_status()
return r.json()
data = ndc_search("metformin", limit=10)
total = data["meta"]["results"]["total"]
print(f"Metformin products: {total}")
rows = []
for prod in data["results"]:
rows.append({
"product_ndc": prod.get("product_ndc"),
"brand_name": prod.get("brand_name"),
"generic_name": prod.get("generic_name"),
"dosage_form": prod.get("dosage_form"),
"route": ", ".join(prod.get("route", [])),
"labeler": prod.get("labeler_name"),
})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
Query 5: Drug Recall Search
Search FDA enforcement actions and drug recalls by drug name or company.
import requests, pandas as pd
BASE = "https://api.fda.gov/drug"
def drug_recalls(drug_name, limit=20):
"""Find FDA drug recalls for a given drug name."""
r = requests.get(f"{BASE}/enforcement.json",
params={
"search": f'product_description:"{drug_name}"',
"limit": limit
})
if r.status_code == 404:
return pd.DataFrame()
r.raise_for_status()
results = r.json()["results"]
return pd.DataFrame([{
"recalling_firm": rec.get("recalling_firm"),
"product": rec.get("product_description", "")[:80],
"reason": rec.get("reason_for_recall", "")[:100],
"classification": rec.get("classification"),
"recall_date": rec.get("recall_initiation_date"),
"status": rec.get("status"),
} for rec in results])
recalls = drug_recalls("metformin", limit=5)
print(f"Metformin recalls: {len(recalls)}")
if not recalls.empty:
print(recalls[["recalling_firm", "classification", "recall_date", "status"]].to_string(index=False))
Query 6: Active Ingredient Search Across Products
Find all drug products containing a specific active ingredient.
import requests, pandas as pd
BASE = "https://api.fda.gov/drug"
def products_by_ingredient(ingredient, limit=50):
"""Find all FDA-listed products with a given active ingredient."""
r = requests.get(f"{BASE}/ndc.json",
params={
"search": f'active_ingredients.name:"{ingredient}"',
"limit": limit
})
r.raise_for_status()
data = r.json()
print(f"Total products with {ingredient}: {data['meta']['results']['total']}")
rows = []
for prod in data["results"]:
for ai in prod.get("active_ingredients", []):
if ingredient.lower() in ai.get("name", "").lower():
rows.append({
"brand": prod.get("brand_name"),
"generic": prod.get("generic_name"),
"strength": ai.get("strength"),
"dosage_form": prod.get("dosage_form"),
"route": ", ".join(prod.get("route", [])),
})
return pd.DataFrame(rows)
df = products_by_ingredient("metformin hydrochloride")
print(df.drop_duplicates(subset=["generic", "strength", "dosage_form"]).head(10).to_string(index=False))
Key Concepts
openFDA API Endpoints
| Endpoint | Dataset | Key Use |
|---|---|---|
/drug/event.json |
FAERS (adverse events) | Pharmacovigilance, safety signals |
/drug/label.json |
Structured Product Labeling | Indications, warnings, dosing |
/drug/ndc.json |
NDC Directory | Marketed products, strengths |
/drug/enforcement.json |
Recalls & Enforcement | Drug recalls, market withdrawals |
/device/event.json |
MAUDE (device events) | Medical device adverse events |
Query Syntax
openFDA uses Elasticsearch-style queries. Use field:"exact phrase" for exact matching, field:term for fuzzy matching, and +field1:"A" +field2:"B" for AND logic. Use count parameter to aggregate (equivalent to GROUP BY). Use limit (1–1000) for pagination with skip for offset.
Common Workflows
Workflow 1: Drug Safety Signal Analysis
Goal: Compare adverse event frequency for multiple drugs in the same therapeutic class to identify differentiated safety profiles.
import requests, pandas as pd, time
BASE = "https://api.fda.gov/drug"
drugs = ["atorvastatin", "simvastatin", "rosuvastatin"]
def count_reactions(drug, limit=20):
r = requests.get(f"{BASE}/event.json",
params={"search": f'patient.drug.medicinalproduct:"{drug}"',
"count": "patient.reaction.reactionmeddrapt.exact",
"limit": limit})
if r.status_code != 200:
return pd.Series(dtype=float, name=drug)
df = pd.DataFrame(r.json()["results"])
df.columns = ["reaction", drug]
return df.set_index("reaction")[drug]
series_list = []
for drug in drugs:
s = count_reactions(drug, limit=20)
series_list.append(s)
time.sleep(0.5)
comparison = pd.concat(series_list, axis=1).fillna(0)
comparison = comparison.sort_values(drugs[0], ascending=False)
print("Adverse event count comparison (statins):")
print(comparison.head(10).to_string())
comparison.to_csv("statin_safety_comparison.csv")
Workflow 2: Drug Label Information Extractor
Goal: Extract indications, contraindications, and warnings for multiple drugs and save to CSV.
import requests, pandas as pd, time, re
BASE = "https://api.fda.gov/drug"
def get_label_sections(drug_name):
r = requests.get(f"{BASE}/label.json",
params={"search": f'openfda.generic_name:"{drug_name}"', "limit": 1})
if r.status_code != 200 or not r.json()["results"]:
return None
label = r.json()["results"][0]
def clean(field):
text = " ".join(label.get(field, [""]))
return re.sub(r"\s+", " ", text).strip()[:500]
return {
"drug": drug_name,
"indications": clean("indications_and_usage"),
"contraindications": clean("contraindications"),
"warnings": clean("warnings_and_cautions") or clean("warnings"),
}
drugs = ["metformin", "atorvastatin", "lisinopril", "omeprazole"]
rows = []
for drug in drugs:
info = get_label_sections(drug)
if info:
rows.append(info)
time.sleep(0.4)
df = pd.DataFrame(rows)
df.to_csv("drug_labels.csv", index=False)
print(df[["drug", "indications"]].to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
search |
All endpoints | — | Elasticsearch syntax | Filter query |
count |
All endpoints | — | field name + .exact |
Aggregate/count by field value |
limit |
All endpoints | 1 |
1–1000 |
Results per request |
skip |
All endpoints | 0 |
integer | Offset for pagination |
api_key |
All endpoints | — | API key string | Increase rate limit to 120K/day |
.exact suffix |
count field | — | appended to field name | Exact string matching vs tokenized |
Best Practices
-
Get a free API key: Registration at https://open.fda.gov/apis/authentication/ is instant and raises your limit from 1,000 to 120,000 requests/day — essential for production use.
-
Use
.exactfor drug name searches:patient.drug.medicinalproduct.exact(not.medicinalproduct) gives exact phrase matching, preventing partial matches that inflate counts. -
Normalize drug names: FAERS reporters use many spellings (e.g., "Lipitor", "atorvastatin calcium", "ATORVASTATIN"). Search multiple name variants or use generic ingredient field for consistent coverage.
-
Interpret FAERS counts carefully: Report counts do not equal incidence rates. FAERS is voluntary and subject to reporting bias; higher counts may reflect market size or media attention, not higher risk.
-
Paginate large result sets: Maximum
limitis 1000; useskipto paginate through large result sets (totalinmeta.results).
Common Recipes
Recipe: Total FAERS Reports Count for a Drug
When to use: Quick check of total adverse event report volume for a drug.
import requests
drug = "ibuprofen"
r = requests.get("https://api.fda.gov/drug/event.json",
params={"search": f'patient.drug.medicinalproduct.exact:"{drug}"',
"limit": 1})
total = r.json()["meta"]["results"]["total"]
print(f"Total FAERS reports for {drug}: {total:,}")
Recipe: Find Serious Adverse Events Only
When to use: Filter FAERS for reports classified as serious (death, hospitalization, disability).
import requests, pandas as pd
r = requests.get("https://api.fda.gov/drug/event.json",
params={
"search": 'patient.drug.medicinalproduct:"warfarin" AND serious:1',
"count": "patient.reaction.reactionmeddrapt.exact",
"limit": 10
})
df = pd.DataFrame(r.json()["results"])
df.columns = ["reaction", "serious_reports"]
print(df.to_string(index=False))
Recipe: Check Drug Market Approval Status
When to use: Verify whether a drug has FDA NDA/ANDA approval and find the approval year.
import requests
r = requests.get("https://api.fda.gov/drug/label.json",
params={"search": 'openfda.generic_name:"metformin"', "limit": 1})
label = r.json()["results"][0]
openfda = label.get("openfda", {})
print(f"Application numbers: {openfda.get('application_number', ['n/a'])}")
print(f"Product type: {openfda.get('product_type', ['n/a'])}")
print(f"NDA sponsor: {openfda.get('manufacturer_name', ['n/a'])}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 with {"error": {"code": "NOT_FOUND"}} |
No results match query | Check drug name spelling; try alternative name formats |
HTTP 429 Too Many Requests |
Rate limit exceeded | Register for API key; add time.sleep(1) between requests |
| Count results don't match expectations | Drug name tokenization | Use .exact suffix: medicinalproduct.exact not medicinalproduct |
| Label search returns wrong drug | Ambiguous name | Add +openfda.product_type:"HUMAN PRESCRIPTION DRUG" to filter |
| Missing fields in FAERS report | Incomplete voluntary report | Check if field exists with .get("field", "n/a") |
skip + limit > 26000 error |
Pagination limit | openFDA caps pagination at 26,000 records; use count endpoint for aggregates beyond this |
Related Skills
clinicaltrials-database-search— Clinical trial data for drugs identified via openFDAdrugbank-database-access— Drug structures, targets, and interactions to contextualize FDA datachembl-database-bioactivity— Preclinical bioactivity data for drugs in the FAERS databasestring-database-ppi— Protein interactions for drug targets found via adverse event analysis
References
- openFDA API documentation — Full API reference, endpoints, and query syntax
- openFDA API key registration — Free registration for increased rate limits
- FAERS overview — Understanding FAERS data and limitations
- openFDA GitHub — Source code and data download references
skills/structural-biology-drug-discovery/gtopdb-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill gtopdb-database -g -y
SKILL.md
Frontmatter
{
"name": "gtopdb-database",
"license": "ODbL-1.0",
"description": "Query IUPHAR\/BPS Guide to Pharmacology (GtoPdb) for receptor-ligand interactions, target\/ligand metadata, families, and approved drugs. Affinities (pKi\/pIC50\/pKd), action (Agonist\/Antagonist\/etc.), species, structures (SMILES\/InChI). No auth. Always resolve targets via geneSymbol\/accession; most metadata lives in sub-resources (\/databaseLinks, \/structure, \/synonyms)."
}
Guide to Pharmacology (GtoPdb) Database
Overview
The IUPHAR/BPS Guide to Pharmacology (GtoPdb) catalogues drug targets, ligands, and quantitative interactions across receptor pharmacology. The web services REST API at https://www.guidetopharmacology.org/services/ returns JSON for targets, ligands, interactions, and family hierarchies. Base records are intentionally lean — gene symbols, UniProt accessions, ChEMBL IDs, SMILES/InChI all live in sub-resources (/targets/{id}/databaseLinks, /targets/{id}/synonyms, /ligands/{id}/structure, /ligands/{id}/databaseLinks). No authentication required.
When to Use
- Looking up the affinity (pKi/pIC50/pKd) of a ligand at a specific target
- Listing all annotated ligands for a receptor (e.g., μ-opioid receptor / OPRM1)
- Finding the approval status of a ligand (
approved=true) and its cross-references (PubChem CID, ChEMBL ID, DrugBank ID) - Retrieving the IUPHAR family hierarchy (867 families) for receptor classification
- Pulling structure descriptors (SMILES, InChI, InChIKey) for chemoinformatics
- Mapping HGNC symbol → UniProt → GtoPdb target ID for cross-database integration
- Use
chembl-database-bioactivityfor larger bioactivity datasets (2.4M+ compounds); GtoPdb is curated, smaller, with more annotation depth - Use
dailymed-databasefor FDA-approved drug labelling; GtoPdb is for pharmacology, not regulatory text
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: HGNC symbols, UniProt accessions, GtoPdb target/ligand IDs, or drug INNs
- Environment: internet connection; no API key
- Rate limits: no published limits; use
time.sleep(0.2)between requests in batch loops
pip install requests pandas matplotlib
Quick Start
import requests
BASE = "https://www.guidetopharmacology.org/services"
# Resolve HGNC symbol → GtoPdb target. geneSymbol= and accession= give an
# exact match. (name= matches across all fields and silently returns the
# wrong target — never use it for canonical lookups.)
r = requests.get(f"{BASE}/targets", params={"geneSymbol": "OPRM1"}, timeout=30)
targets = r.json()
print(f"OPRM1 hits: {len(targets)}") # 1
t = targets[0]
print(f"targetId={t['targetId']} name='{t['name']}' type={t['type']} family={t['familyIds']}")
# targetId=319 name='μ receptor' type=GPCR family=[50]
Core API
Query 1: Resolve a Target (HGNC symbol or UniProt accession)
import requests
BASE = "https://www.guidetopharmacology.org/services"
def find_target(*, geneSymbol=None, accession=None):
"""Exact-match target lookup. Pass ONE of geneSymbol or accession."""
if geneSymbol:
params = {"geneSymbol": geneSymbol}
elif accession:
params = {"accession": accession}
else:
raise ValueError("provide geneSymbol or accession")
r = requests.get(f"{BASE}/targets", params=params, timeout=30)
r.raise_for_status()
hits = r.json()
if not hits:
return None
return hits[0]
print(find_target(geneSymbol="OPRM1")) # targetId 319 (μ receptor)
print(find_target(accession="P35372")) # same — UniProt P35372 is OPRM1
# Base record has only IDs and family pointers — no gene symbol, UniProt, or
# synonym text. Read sub-resources to get those.
import requests
BASE = "https://www.guidetopharmacology.org/services"
print(requests.get(f"{BASE}/targets/319", timeout=30).json().keys())
# dict_keys(['targetId','name','type','familyIds','subunitIds','complexIds'])
Query 2: Target Cross-References and Synonyms
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
def target_xrefs(target_id):
"""Cross-database accessions: UniProt, HGNC, ChEMBL Target, Ensembl, etc."""
r = requests.get(f"{BASE}/targets/{target_id}/databaseLinks", timeout=30)
r.raise_for_status()
return pd.DataFrame(r.json())
def target_synonyms(target_id):
r = requests.get(f"{BASE}/targets/{target_id}/synonyms", timeout=30)
r.raise_for_status()
return [s.get("name") for s in r.json()]
df_links = target_xrefs(319)
print(df_links[["database", "accession", "species"]].head(8).to_string(index=False))
# database accession species
# ChEMBL Target CHEMBL233 Human
# UniProtKB P35372 Human
# HGNC 8156 Human
# ...
print("Synonyms:", target_synonyms(319))
Query 3: Target Interactions and Affinities
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
def target_interactions(target_id):
"""All ligand-target interaction records for a target.
Each row carries ligandId, ligandName, type (Agonist/Antagonist/etc.),
action, affinity (string), affinityParameter (pKi/pIC50/...), refs."""
r = requests.get(f"{BASE}/targets/{target_id}/interactions", timeout=60)
r.raise_for_status()
rows = []
for i in r.json():
rows.append({
"ligandId": i.get("ligandId"),
"ligandName": i.get("ligandName"),
"type": i.get("type"), # Agonist/Antagonist/Allosteric modulator/...
"action": i.get("action"),
"affinity": i.get("affinity"), # string, may include "-" (range) or "~"
"affinityParameter": i.get("affinityParameter"), # pKi, pIC50, pKd, pEC50, pA2, pKB
"species": i.get("targetSpecies"),
"primary": i.get("primaryTarget"),
"endogenous": i.get("endogenous"),
})
return pd.DataFrame(rows)
df_ints = target_interactions(319) # μ receptor
print(f"OPRM1 interactions: {len(df_ints)}")
# Top pKi values (cast safely — affinity is a string; some are ranges like "8.5-9.0")
df_ints["pki"] = pd.to_numeric(df_ints["affinity"], errors="coerce")
print(df_ints[df_ints["affinityParameter"] == "pKi"]
.sort_values("pki", ascending=False)
.head(8)[["ligandName", "type", "pki"]].to_string(index=False))
Query 4: Ligand Lookup and Structure
import requests
BASE = "https://www.guidetopharmacology.org/services"
def ligand(ligand_id):
"""Base ligand record. Keys: ligandId, name, type, approved, approvalSource,
abbreviation, inn, whoEssential, withdrawn, antibacterial, immuno, malaria,
labelled, radioactive, activeDrugIds, prodrugIds, subunitIds, complexIds."""
r = requests.get(f"{BASE}/ligands/{ligand_id}", timeout=30)
r.raise_for_status()
return r.json()
def ligand_structure(ligand_id):
"""Structure descriptors: smiles, inchi, inchiKey, iupacName."""
r = requests.get(f"{BASE}/ligands/{ligand_id}/structure", timeout=30)
r.raise_for_status()
return r.json()
l = ligand(1627) # morphine
s = ligand_structure(1627)
print(f"{l['name']:12s} approved={l['approved']} type={l['type']} inn={l.get('inn')}")
print(f" SMILES : {s['smiles']}")
print(f" InChIKey : {s['inchiKey']}")
# Ligand cross-references: PubChem CID, ChEMBL, DrugBank, CAS, ChEBI, BindingDB
import requests
BASE = "https://www.guidetopharmacology.org/services"
def ligand_xrefs(ligand_id):
r = requests.get(f"{BASE}/ligands/{ligand_id}/databaseLinks", timeout=30)
r.raise_for_status()
return r.json()
xrefs = ligand_xrefs(1627)
for x in xrefs[:10]:
print(f" {x['database']:25s} {x['accession']}")
Query 5: Family Hierarchy
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
# Correct endpoint is /targets/families (NOT /families, which is 404)
def list_families():
r = requests.get(f"{BASE}/targets/families", timeout=30)
r.raise_for_status()
return pd.DataFrame(r.json())
df_fams = list_families()
print(f"Total families: {len(df_fams)}") # ~867
print(df_fams[df_fams["name"].str.contains("opioid", case=False, na=False)]
[["familyId", "name"]].to_string(index=False))
Query 6: Approved Drugs (Server-Side Alias)
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
# Special: `type=Approved` is a server-side alias that returns all `approved=true`
# ligands. The `approved=true` query param is silently ignored — use type=.
def approved_ligands():
r = requests.get(f"{BASE}/ligands", params={"type": "Approved"}, timeout=60)
r.raise_for_status()
return pd.DataFrame(r.json())
df = approved_ligands()
print(f"Approved ligands in GtoPdb: {len(df)}") # ~2,197
print(df[df["name"].str.contains("morphine|fentanyl|naloxone", case=False, na=False)]
[["ligandId", "name", "approvalSource"]].head(8).to_string(index=False))
Key Concepts
Base Records vs Sub-Resources
GtoPdb deliberately keeps base records lean. To get the data you usually want, you must call a sub-resource:
| Want | Endpoint |
|---|---|
| Gene symbol / UniProt / HGNC / ChEMBL Target | /targets/{id}/databaseLinks |
| Target synonyms | /targets/{id}/synonyms |
| Species annotation | /targets/{id}/databaseLinks (each row has species) |
| Interactions / affinities for a target | /targets/{id}/interactions |
| SMILES / InChI / InChIKey | /ligands/{id}/structure |
| PubChem CID / ChEMBL / DrugBank / CAS / ChEBI | /ligands/{id}/databaseLinks |
| Ligand pharmacology summary | /ligands/{id}/pharmacology (long text fields) |
geneSymbol= and accession= are Exact-Match
The name= filter searches across all target fields — it returns false positives (e.g. name=beta-2 matches PLC β2 and GABA_A β2 alongside β2-adrenoceptor). Always prefer geneSymbol=HGNC or accession=UniProt for canonical lookups.
Filters That Are Silently Ignored
- On
/interactions:targetId,ligandId,targetType,ligandTypeare accepted but silently ignored — the endpoint returns ~280k rows for any filter. Use/targets/{id}/interactionsor/ligands/{id}/interactionsinstead. - On
/ligands:approved=trueis silently ignored. Usetype=Approved(server alias) to filter.
Affinity Parameters
affinityParameter is one of pKi, pKd, pIC50, pEC50, pA2, pKB. The affinity field is a string — it can be a number ("9.4"), a range ("8.5-9.0"), or qualified ("~7.5", ">8"). Always cast via pd.to_numeric(..., errors="coerce").
Common Workflows
Workflow 1: Build a Target Ligand Profile
Goal: For a target (by HGNC), retrieve all approved drugs with affinity ≥ pKi 7.
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
def target_profile(gene_symbol, min_pki=7.0):
t = requests.get(f"{BASE}/targets",
params={"geneSymbol": gene_symbol}, timeout=30).json()
if not t:
return None
tid = t[0]["targetId"]
ints = requests.get(f"{BASE}/targets/{tid}/interactions", timeout=60).json()
rows = []
for i in ints:
# Skip rows without numeric pKi
if i.get("affinityParameter") != "pKi":
continue
try:
pki = float(i.get("affinity"))
except (TypeError, ValueError):
continue
if pki < min_pki:
continue
# Check approval via the ligand base record
lig = requests.get(f"{BASE}/ligands/{i['ligandId']}", timeout=30).json()
rows.append({
"ligand": i["ligandName"],
"ligandId": i["ligandId"],
"type": i.get("type"),
"pki": pki,
"approved": lig.get("approved"),
"withdrawn": lig.get("withdrawn"),
})
return pd.DataFrame(rows).sort_values("pki", ascending=False)
df = target_profile("OPRM1", min_pki=8.0)
print(df.head(10).to_string(index=False))
df.to_csv("OPRM1_profile.csv", index=False)
Workflow 2: Ligand → Targets
Goal: List the off-targets of a ligand with their affinities.
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
def ligand_targets(ligand_id):
r = requests.get(f"{BASE}/ligands/{ligand_id}/interactions", timeout=60)
r.raise_for_status()
rows = []
for i in r.json():
rows.append({
"targetId": i.get("targetId"),
"targetName": i.get("targetName"),
"type": i.get("type"),
"affinity": pd.to_numeric(i.get("affinity"), errors="coerce"),
"affinityParameter": i.get("affinityParameter"),
"species": i.get("targetSpecies"),
"primary": i.get("primaryTarget"),
})
return pd.DataFrame(rows).sort_values("affinity", ascending=False)
df = ligand_targets(1627) # morphine
print(f"Morphine interactions across all targets: {len(df)}")
print(df.head(8).to_string(index=False))
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
geneSymbol |
/targets |
— | HGNC symbol | Exact-match target lookup |
accession |
/targets |
— | UniProt accession | Exact-match target lookup |
name |
/targets, /ligands |
— | any string | Fuzzy — matches across all fields; can return wrong record |
type |
/ligands |
— | Synthetic organic, Peptide, Natural product, Metabolite, Antibody, Nucleic acid, Inorganic, Approved (alias) |
Filter ligand list by type or approval |
(path) {targetId} |
/targets/{id}/{rel} |
required | integer | Target sub-resource lookup (databaseLinks/synonyms/interactions/…) |
(path) {ligandId} |
/ligands/{id}/{rel} |
required | integer | Ligand sub-resource lookup (structure/databaseLinks/interactions/pharmacology) |
(path) families |
/targets/families |
— | — | List of 867 IUPHAR families |
Best Practices
- Use
geneSymbol=/accession=for canonical lookups, nevername=— fuzzyname=matches across all fields and silently returns the wrong target. - Read sub-resources for everything beyond IDs. Base
/targets/{id}and/ligands/{id}records lack gene symbols, UniProt, SMILES, ChEMBL, etc. — they live under/databaseLinks,/synonyms,/structure. - Filter interactions via
/targets/{id}/interactionsor/ligands/{id}/interactions— never/interactions?targetId=…, which silently ignores the filter. - Use
type=Approvedserver alias to fetch approved drugs;approved=truequery param is ignored. - Always cast
affinitynumerically witherrors="coerce"— it's a string that can be ranges, qualified, or missing. - Family endpoint is
/targets/families— not/families(which 404s).
Common Recipes
Recipe: Resolve Gene Symbol → UniProt via GtoPdb
import requests
BASE = "https://www.guidetopharmacology.org/services"
def gene_to_uniprot(symbol):
t = requests.get(f"{BASE}/targets", params={"geneSymbol": symbol}, timeout=30).json()
if not t:
return None
links = requests.get(f"{BASE}/targets/{t[0]['targetId']}/databaseLinks", timeout=30).json()
for x in links:
if x.get("database") == "UniProtKB" and x.get("species") == "Human":
return x["accession"]
return None
print(gene_to_uniprot("OPRM1")) # P35372
print(gene_to_uniprot("ADRB2")) # P07550
Recipe: Approved Drugs in a Receptor Family
import requests, pandas as pd
BASE = "https://www.guidetopharmacology.org/services"
def family_targets(family_id):
fams = requests.get(f"{BASE}/targets/families", timeout=30).json()
return next((f.get("targetIds", []) for f in fams if f["familyId"] == family_id), [])
def approved_drugs_for_family(family_id):
rows = []
for tid in family_targets(family_id):
ints = requests.get(f"{BASE}/targets/{tid}/interactions", timeout=60).json()
for i in ints:
lig = requests.get(f"{BASE}/ligands/{i['ligandId']}", timeout=30).json()
if not lig.get("approved"):
continue
rows.append({"targetId": tid, "drug": i["ligandName"],
"type": i.get("type"),
"affinity": pd.to_numeric(i.get("affinity"), errors="coerce"),
"param": i.get("affinityParameter")})
return pd.DataFrame(rows).drop_duplicates(subset=["targetId", "drug"])
# family 50 = opioid receptors (example)
df = approved_drugs_for_family(50)
print(df.head(10).to_string(index=False))
Recipe: SMILES Lookup for a List of GtoPdb Ligand IDs
import requests, pandas as pd, time
BASE = "https://www.guidetopharmacology.org/services"
def smiles_table(ligand_ids):
rows = []
for lid in ligand_ids:
s = requests.get(f"{BASE}/ligands/{lid}/structure", timeout=30).json()
rows.append({"ligandId": lid, "smiles": s.get("smiles"),
"inchiKey": s.get("inchiKey")})
time.sleep(0.2)
return pd.DataFrame(rows)
print(smiles_table([1627, 1638, 5466]).to_string(index=False)) # morphine, naloxone, fentanyl
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
name=... returns wrong target |
name= matches across all fields |
Use geneSymbol= or accession= for canonical lookups |
target["hgncSymbol"] / target["uniprotId"] KeyError |
Base record only has IDs | Call /targets/{id}/databaseLinks |
ligand["smiles"] / inchikey / pubchemCid KeyError |
Base ligand record has flags only | Call /ligands/{id}/structure and /ligands/{id}/databaseLinks |
/interactions?targetId=... returns ~280k rows |
Filter silently ignored | Use /targets/{id}/interactions |
?approved=true returns the full ligand list |
Param silently ignored | Use ?type=Approved (server alias) |
/families → HTTP 404 |
Wrong path | Use /targets/families |
ValueError: could not convert string to float on affinity |
Field is a string that can be a range or qualifier | pd.to_numeric(s, errors="coerce"); or parse ranges manually |
Filter by ligandType="Approved" on interactions returns empty |
ligandType field does not exist on interactions |
Filter via the ligand record's approved flag instead |
Related Skills
chembl-database-bioactivity— Larger-scale bioactivity (2.4M+ compounds) for the same target classespubchem-compound-search— Compound-centric chemoinformatics; cross-reference via PubChem CID from/ligands/{id}/databaseLinksdailymed-database— FDA-approved label text for marketed drugs (regulatory complement)uniprot-protein-database— Resolve the UniProt accession that GtoPdb cross-references
References
- Guide to Pharmacology home
- Web services API reference
- Nomenclature & Curation
- Harding SD et al. "The IUPHAR/BPS Guide to PHARMACOLOGY in 2024." Nucleic Acids Research 52(D1): D1438–D1449 (2024). https://doi.org/10.1093/nar/gkad944
skills/structural-biology-drug-discovery/mdanalysis-trajectory/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill mdanalysis-trajectory -g -y
SKILL.md
Frontmatter
{
"name": "mdanalysis-trajectory",
"license": "GPL-2.0",
"description": "Analyze MD trajectories from GROMACS, AMBER, NAMD, CHARMM, LAMMPS. Reads topology\/trajectory into Universe objects; supports RMSD, RMSF, radius of gyration, contact maps, H-bonds, PCA, and custom distance\/angle calculations. Use for post-simulation structural analysis; use OpenMM\/GROMACS for running simulations."
}
MDAnalysis — Molecular Dynamics Trajectory Analysis
Overview
MDAnalysis provides a uniform Python interface for reading and analyzing molecular dynamics trajectories regardless of MD engine (GROMACS, AMBER, NAMD, CHARMM, LAMMPS, OpenMM). It represents molecular systems as Universe objects containing an AtomGroup with positions, velocities, forces, and topology data. Trajectories are iterated frame-by-frame or analyzed in bulk using analysis modules for RMSD, RMSF, radius of gyration, hydrogen bonds, solvent-accessible surface area, and PCA. MDAnalysis integrates with NumPy, pandas, and matplotlib, making it the standard tool for post-simulation structural analysis in computational chemistry and drug discovery.
When to Use
- Computing RMSD and RMSF of protein backbone or specific residue groups after MD simulation
- Analyzing ligand binding stability: pocket RMSD, contact persistence, hydrogen bond occupancy
- Performing principal component analysis (PCA) on trajectory conformations
- Computing solvent-accessible surface area (SASA), radius of gyration, and end-to-end distance
- Extracting representative cluster structures from long MD trajectories for visualization
- Use GROMACS or AMBER analysis tools (
gmx rms,cpptraj) instead for engine-specific analysis within a HPC pipeline - Use OpenMM or GROMACS directly for running MD simulations; MDAnalysis is for post-simulation analysis
Prerequisites
- Python packages:
MDAnalysis,numpy,matplotlib,pandas - Input: topology file (.psf, .prmtop, .gro, .pdb) + trajectory file (.dcd, .trr, .xtc, .nc, .dms)
# Install MDAnalysis
pip install MDAnalysis
# Install with all analysis extras
pip install "MDAnalysis[analysis]"
# Verify
python -c "import MDAnalysis as mda; print(mda.__version__)"
# 2.7.0
Quick Start
import MDAnalysis as mda
import numpy as np
# Load a GROMACS topology + trajectory
u = mda.Universe("protein.gro", "trajectory.xtc")
print(f"Atoms: {u.atoms.n_atoms}")
print(f"Residues: {u.residues.n_residues}")
print(f"Frames: {u.trajectory.n_frames}")
print(f"First frame positions (first 3 atoms):\n{u.atoms.positions[:3]}")
Core API
Module 1: Universe and AtomGroup — Loading and Selecting Atoms
Load trajectories and select atom subsets.
import MDAnalysis as mda
# Load topology + trajectory (GROMACS xtc format)
u = mda.Universe("system.gro", "md_production.xtc")
# AtomGroup selections (CHARMM-style selection language)
protein = u.select_atoms("protein")
backbone = u.select_atoms("backbone")
ca_atoms = u.select_atoms("name CA")
ligand = u.select_atoms("resname LIG")
binding_site = u.select_atoms("protein and around 5.0 resname LIG")
print(f"Protein atoms: {protein.n_atoms}")
print(f"CA atoms: {ca_atoms.n_atoms}")
print(f"Ligand atoms: {ligand.n_atoms}")
print(f"Binding site residues: {binding_site.residues.n_residues}")
# Access atom properties at current frame
print(f"CA positions shape: {ca_atoms.positions.shape}") # (N, 3)
print(f"Protein mass: {protein.total_mass():.1f} Da")
Module 2: Trajectory Iteration — Per-Frame Analysis
Iterate over trajectory frames for time-series analysis.
import MDAnalysis as mda
import numpy as np
u = mda.Universe("protein.gro", "trajectory.xtc")
backbone = u.select_atoms("backbone")
times = []
rg_values = []
for ts in u.trajectory:
times.append(u.trajectory.time)
rg_values.append(backbone.radius_of_gyration())
import pandas as pd
df = pd.DataFrame({"time_ps": times, "Rg_A": rg_values})
print(f"Frames analyzed: {len(df)}")
print(f"Mean Rg: {df['Rg_A'].mean():.2f} Å")
print(f"Rg std: {df['Rg_A'].std():.2f} Å")
df.to_csv("radius_of_gyration.csv", index=False)
Module 3: RMSD Analysis — Structural Drift Over Time
Compute backbone RMSD relative to a reference structure.
import MDAnalysis as mda
from MDAnalysis.analysis import rms
import numpy as np
import matplotlib.pyplot as plt
u = mda.Universe("protein.gro", "trajectory.xtc")
# RMSD of Cα atoms relative to first frame
rmsd = rms.RMSD(u, select="name CA")
rmsd.run()
# Results: frame, time (ps), RMSD (Å)
results = rmsd.results.rmsd
print(f"Mean RMSD: {results[:, 2].mean():.2f} Å")
print(f"Max RMSD: {results[:, 2].max():.2f} Å")
# Plot
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(results[:, 1] / 1000, results[:, 2], color="steelblue", lw=0.8)
ax.set_xlabel("Time (ns)")
ax.set_ylabel("RMSD (Å)")
ax.set_title("Backbone RMSD")
plt.tight_layout()
plt.savefig("rmsd.png", dpi=150)
print("Saved: rmsd.png")
Module 4: RMSF Analysis — Per-Residue Flexibility
Compute root-mean-square fluctuations to identify flexible regions.
import MDAnalysis as mda
from MDAnalysis.analysis import rms
import numpy as np
import matplotlib.pyplot as plt
u = mda.Universe("protein.gro", "trajectory.xtc")
# RMSF per Cα atom (after aligning trajectory)
ca_atoms = u.select_atoms("name CA")
rmsf_analysis = rms.RMSF(ca_atoms)
rmsf_analysis.run()
rmsf_values = rmsf_analysis.results.rmsf
resids = ca_atoms.resids
print(f"Most flexible residue: {resids[np.argmax(rmsf_values)]} ({rmsf_values.max():.2f} Å)")
print(f"Most rigid residue: {resids[np.argmin(rmsf_values)]} ({rmsf_values.min():.2f} Å)")
# Plot B-factor-like profile
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(resids, rmsf_values, color="coral", lw=1)
ax.fill_between(resids, 0, rmsf_values, alpha=0.3, color="coral")
ax.set_xlabel("Residue ID")
ax.set_ylabel("RMSF (Å)")
ax.set_title("Per-residue RMSF")
plt.tight_layout()
plt.savefig("rmsf.png", dpi=150)
Module 5: Hydrogen Bond Analysis
Identify and count hydrogen bonds between protein and ligand over the trajectory.
import MDAnalysis as mda
from MDAnalysis.analysis.hydrogenbonds import HydrogenBondAnalysis
import pandas as pd
u = mda.Universe("complex.gro", "trajectory.xtc")
# Protein-ligand hydrogen bond analysis
hbonds = HydrogenBondAnalysis(
universe=u,
donors_sel="protein",
acceptors_sel="resname LIG",
d_h_cutoff=1.2, # donor-hydrogen distance cutoff (Å)
d_a_cutoff=3.0, # donor-acceptor distance cutoff (Å)
d_h_a_angle_cutoff=150.0, # angle cutoff (degrees)
)
hbonds.run()
# Get occupancy: fraction of frames with each H-bond
hbonds.generate_table()
df = pd.DataFrame(hbonds.table)
print(f"Total unique H-bonds observed: {len(df['donor_resid'].unique())}")
# Count H-bonds per frame
counts = hbonds.count_by_time()
print(f"Mean H-bonds per frame: {counts[:, 1].mean():.2f}")
print(f"Max H-bonds in one frame: {counts[:, 1].max():.0f}")
Module 6: PCA and Conformational Clustering
Extract principal modes of motion and cluster conformations.
import MDAnalysis as mda
from MDAnalysis.analysis import pca, align
import numpy as np
import matplotlib.pyplot as plt
u = mda.Universe("protein.gro", "trajectory.xtc")
# Align trajectory to first frame
aligner = align.AlignTraj(u, u, select="backbone", in_memory=True)
aligner.run()
# PCA on backbone Cα atoms
ca = u.select_atoms("name CA")
pc = pca.PCA(u, select="backbone")
pc.run()
# Explained variance
cumvar = np.cumsum(pc.results.variance / pc.results.variance.sum())
n_for_90 = np.searchsorted(cumvar, 0.90) + 1
print(f"PCs to explain 90% variance: {n_for_90}")
print(f"PC1 variance: {pc.results.variance[0] / pc.results.variance.sum() * 100:.1f}%")
# Project trajectory onto PC1-PC2 space
transformed = pc.transform(ca, n_components=2)
plt.scatter(transformed[:, 0], transformed[:, 1], c=range(len(transformed)),
cmap="viridis", s=2)
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.colorbar(label="Frame")
plt.title("PCA Conformational Landscape")
plt.savefig("pca.png", dpi=150)
print("Saved: pca.png")
Key Parameters
| Parameter | Module | Default | Effect |
|---|---|---|---|
select (Universe) |
AtomGroup | — | MDAnalysis selection language string (e.g., "protein and name CA") |
in_memory |
align.AlignTraj | False |
Load full trajectory into RAM for faster analysis |
step |
trajectory loop | 1 |
Analyze every Nth frame; step=10 reduces computation 10× |
start/stop |
analysis.run() | all | Frame range; run(start=100, stop=500) analyzes frames 100-500 |
d_a_cutoff |
HydrogenBondAnalysis | 3.0 |
Donor-acceptor distance cutoff (Å) |
d_h_a_angle_cutoff |
HydrogenBondAnalysis | 150.0 |
H-bond angle cutoff (degrees) |
n_components |
PCA.transform | all | Number of PCs to project onto |
groupselection |
RMSD | None |
Secondary selection for fitting; primary used for RMSD calculation |
weights |
RMSD/align | None |
"mass" for mass-weighted RMSD |
verbose |
analysis.run() | False |
Print progress bar during analysis |
Common Workflows
Workflow 1: Complete Protein-Ligand Binding Stability Analysis
import MDAnalysis as mda
from MDAnalysis.analysis import rms, align
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
u = mda.Universe("complex.gro", "md_100ns.xtc")
# Align trajectory to initial frame
align.AlignTraj(u, u, select="protein and backbone", in_memory=True).run()
protein_bb = u.select_atoms("backbone")
ligand = u.select_atoms("resname LIG")
results = {"time_ns": [], "protein_rmsd": [], "ligand_rmsd": [], "pocket_rg": []}
ref_positions = {"protein": protein_bb.positions.copy(), "ligand": ligand.positions.copy()}
for ts in u.trajectory:
results["time_ns"].append(ts.time / 1000)
results["protein_rmsd"].append(
rms.rmsd(protein_bb.positions, ref_positions["protein"], superposition=False))
results["ligand_rmsd"].append(
rms.rmsd(ligand.positions, ref_positions["ligand"], superposition=False))
results["pocket_rg"].append(
u.select_atoms("protein and around 6.0 resname LIG").radius_of_gyration())
df = pd.DataFrame(results)
df.to_csv("binding_stability.csv", index=False)
print(f"Ligand mean RMSD: {df['ligand_rmsd'].mean():.2f} Å")
print(f"Pocket stable: {'Yes' if df['pocket_rg'].std() < 0.5 else 'No'}")
Workflow 2: Extract Minimum RMSD Representative Structures
import MDAnalysis as mda
from MDAnalysis.analysis import rms
import numpy as np
u = mda.Universe("protein.gro", "trajectory.xtc")
# Compute RMSD for all frames
rmsd_analysis = rms.RMSD(u, select="backbone")
rmsd_analysis.run()
rmsd_values = rmsd_analysis.results.rmsd[:, 2]
# Find frame with lowest RMSD (most representative)
min_frame = np.argmin(rmsd_values)
print(f"Most representative frame: {min_frame} (RMSD: {rmsd_values[min_frame]:.2f} Å)")
# Extract and save that frame as PDB
u.trajectory[min_frame]
with mda.Writer("representative_structure.pdb", u.atoms.n_atoms) as writer:
writer.write(u.atoms)
print("Saved: representative_structure.pdb")
Common Recipes
Recipe 1: Compute Contact Map Between Two Protein Domains
import MDAnalysis as mda
from MDAnalysis.analysis import contacts
import numpy as np
u = mda.Universe("protein.gro", "trajectory.xtc")
# Define two domains
domain_A = u.select_atoms("resid 1-100 and name CA")
domain_B = u.select_atoms("resid 200-300 and name CA")
# Count domain-domain contacts (< 8 Å) over time
contact_counts = []
for ts in u.trajectory[::10]: # every 10th frame
dist_matrix = np.sqrt(np.sum(
(domain_A.positions[:, None] - domain_B.positions[None, :]) ** 2, axis=-1
))
contact_counts.append((dist_matrix < 8.0).sum())
print(f"Mean contacts: {np.mean(contact_counts):.1f}")
print(f"Contact count range: {min(contact_counts)} – {max(contact_counts)}")
Recipe 2: Write Trajectory Subset to New File
import MDAnalysis as mda
u = mda.Universe("system.gro", "long_trajectory.xtc")
# Write only protein atoms, every 10th frame, frames 500-2000
protein = u.select_atoms("protein")
with mda.Writer("protein_subset.xtc", protein.n_atoms) as writer:
for ts in u.trajectory[500:2000:10]:
writer.write(protein)
print(f"Subset trajectory written: {(2000-500)//10} frames")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: Universe has no file |
Missing trajectory argument | Provide both topology AND trajectory: mda.Universe(top, traj) |
| RMSD drift despite alignment | Wrong selection for fitting | Use "backbone" for fitting; verify topology matches trajectory |
KeyError: 'resname LIG' |
Non-standard residue name | Check u.residues.resnames; use exact name from topology |
| Very slow frame iteration | Large trajectory in memory | Use step=10 to skip frames; convert xtc to smaller DCD |
| Hydrogen bond count is 0 | No explicit hydrogens in topology | Use topology with explicit H atoms; add hydrogens with psfgen or tleap |
NoDataError: xtc has no charges |
Property not in trajectory | Use topology file that contains charges (.psf, .prmtop, .gro with itp) |
Memory error with in_memory=True |
Trajectory too large for RAM | Remove in_memory=True; increase RAM; or process in chunks |
| Import error on Apple Silicon | Binary not compiled for arm64 | Install via conda-forge: conda install -c conda-forge MDAnalysis |
References
- MDAnalysis documentation — official API reference and user guide
- MDAnalysis GitHub: MDAnalysis/mdanalysis — source code, tutorials, and issue tracker
- Michaud-Agrawal N et al. (2011) "MDAnalysis: A toolkit for the analysis of molecular dynamics simulations" — J Comput Chem 32:2319-2327. DOI:10.1002/jcc.21787
- Gowers RJ et al. (2016) "MDAnalysis: A Python Package for the Rapid Analysis of Molecular Dynamics Simulations" — Proc SciPy 2016. DOI:10.25080/majora-629e541a-00e
skills/structural-biology-drug-discovery/medchem/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill medchem -g -y
SKILL.md
Frontmatter
{
"name": "medchem",
"license": "Apache-2.0",
"description": "Medicinal chemistry filters for compound triage. Drug-likeness rules (Lipinski Ro5, Veber, Oprea, CNS, leadlike, REOS, Golden Triangle, Ro3), structural alerts (PAINS, NIBR, Lilly Demerits), chemical group detectors, complexity metrics, and filter composition query language. Built on RDKit\/datamol. For hit-to-lead filtering, library design, ADMET pre-screening. For molecular I\/O use rdkit-cheminformatics or datamol."
}
Medchem
Overview
Medchem is a Python library for molecular filtering and prioritization in drug discovery. It provides hundreds of established medicinal chemistry rules, structural alerts, and chemical group detectors to triage compound libraries at scale. All filters support parallel execution and return structured results.
When to Use
- Applying drug-likeness rules (Lipinski, Veber, Oprea, CNS, REOS) to compound libraries
- Filtering molecules by structural alerts (PAINS, NIBR, Lilly Demerits)
- Detecting specific chemical groups (hinge binders, Michael acceptors, reactive groups)
- Calculating molecular complexity metrics (Bertz, Whitlock, Barone)
- Applying custom property constraints (MW, LogP, TPSA, rotatable bonds)
- Composing complex multi-rule filter queries with Boolean logic
- For SMILES/SDF parsing, descriptors, and fingerprints use rdkit-cheminformatics
- For high-level molecular manipulation use datamol-cheminformatics
Prerequisites
pip install medchem datamol
Medchem depends on RDKit and datamol. All molecule inputs are RDKit Chem.Mol objects; use datamol.to_mol() to convert from SMILES.
Quick Start
import datamol as dm
import medchem as mc
# Convert SMILES to molecules
smiles_list = ["CC(=O)OC1=CC=CC=C1C(=O)O", "c1ccccc1N", "O=C(O)c1ccccc1"]
mols = [dm.to_mol(s) for s in smiles_list]
# Apply Rule of Five + structural alerts in one pass
rule_filter = mc.rules.RuleFilters(rule_list=["rule_of_five"])
alert_filter = mc.structural.CommonAlertsFilters()
rule_results = rule_filter(mols=mols, n_jobs=-1)
alert_results = alert_filter(mols=mols, n_jobs=-1)
print(f"Rule results: {rule_results}")
print(f"Alert results: {[r['has_alerts'] for r in alert_results]}")
Core API
1. Drug-Likeness Rules
Apply established medicinal chemistry rules via mc.rules. Individual rules return bool; RuleFilters applies multiple rules in batch.
import medchem as mc
# Single rule on a SMILES string
passes = mc.rules.basic_rules.rule_of_five("CC(=O)OC1=CC=CC=C1C(=O)O")
print(f"Passes Ro5: {passes}") # True
# Available individual rules:
# rule_of_five, rule_of_three, rule_of_oprea, rule_of_cns,
# rule_of_leadlike_soft, rule_of_leadlike_strict, rule_of_veber,
# rule_of_reos, rule_of_drug, golden_triangle, pains_filter
import datamol as dm
import medchem as mc
# Batch application with RuleFilters
mols = [dm.to_mol(s) for s in smiles_list]
rfilter = mc.rules.RuleFilters(
rule_list=["rule_of_five", "rule_of_oprea", "rule_of_cns"]
)
results = rfilter(mols=mols, n_jobs=-1, progress=True)
# Returns list of dicts: [{"rule_of_five": True, "rule_of_oprea": False, ...}, ...]
print(f"First molecule: {results[0]}")
2. Structural Alert Filters
Detect problematic structural patterns via mc.structural. Three filter sets cover different scope and stringency.
import datamol as dm
import medchem as mc
mol = dm.to_mol("c1ccc(N)cc1")
mols = [dm.to_mol(s) for s in smiles_list]
# Common Alerts — general structural alerts from ChEMBL / literature
alert_filter = mc.structural.CommonAlertsFilters()
has_alerts, details = alert_filter.check_mol(mol) # single molecule
batch_results = alert_filter(mols=mols, n_jobs=-1, progress=True)
# Each result: {"has_alerts": bool, "alert_details": [...], "num_alerts": int}
print(f"Alerts: {batch_results[0]}")
import medchem as mc
# NIBR Filters — Novartis industrial filter set (returns bool list)
nibr_filter = mc.structural.NIBRFilters()
nibr_results = nibr_filter(mols=mols, n_jobs=-1)
print(f"NIBR pass: {nibr_results}") # [True, False, ...]
# Lilly Demerits — 275 patterns, molecules rejected at >100 demerits
lilly_filter = mc.structural.LillyDemeritsFilters()
lilly_results = lilly_filter(mols=mols, n_jobs=-1)
# Each result: {"demerits": int, "passes": bool, "matched_patterns": [...]}
print(f"Lilly: {lilly_results[0]}")
3. Chemical Groups
Detect specific functional group motifs via mc.groups.ChemicalGroup.
Predefined groups: hinge_binders, phosphate_binders, michael_acceptors, reactive_groups.
import medchem as mc
# Check for kinase hinge binders and Michael acceptors
group = mc.groups.ChemicalGroup(
groups=["hinge_binders", "michael_acceptors"]
)
has_matches = group.has_match(mols) # List[bool]
match_info = group.get_matches(mols[0]) # {group_name: [(atom_indices), ...]}
all_matches = group.get_all_matches(mols) # List[Dict]
print(f"Has hinge binder: {has_matches}")
# Custom SMARTS patterns
custom = mc.groups.ChemicalGroup(
groups=["reactive_groups"],
custom_smarts={"trifluoromethyl_ketone": "[C;H0](=O)C(F)(F)F"}
)
4. Named Catalogs
Access curated chemical structure catalogs via mc.catalogs.
Available catalogs: functional_groups, protecting_groups, reagents, fragments.
import medchem as mc
catalog = mc.catalogs.NamedCatalogs.get("functional_groups")
matches = catalog.get_matches(mol)
print(f"Functional group matches: {matches}")
5. Molecular Complexity
Calculate synthetic accessibility proxies via mc.complexity.
Methods: bertz (topological), whitlock, barone.
import datamol as dm
import medchem as mc
mol = dm.to_mol("CC(=O)OC1=CC=CC=C1C(=O)O")
# Single molecule complexity
score = mc.complexity.calculate_complexity(mol, method="bertz")
print(f"Bertz complexity: {score:.1f}")
# Batch filtering by complexity threshold
cfilter = mc.complexity.ComplexityFilter(max_complexity=500, method="bertz")
results = cfilter(mols=mols, n_jobs=-1)
print(f"Passes complexity: {results}") # List[bool]
6. Property Constraints
Apply custom property-based constraints via mc.constraints.Constraints.
import medchem as mc
constraints = mc.constraints.Constraints(
mw_range=(200, 500),
logp_range=(-2, 5),
tpsa_max=140,
rotatable_bonds_max=10,
hbd_max=5,
hba_max=10,
rings_range=(1, 5),
aromatic_rings_max=3,
)
results = constraints(mols=mols, n_jobs=-1)
# Each result: {"passes": bool, "violations": ["mw_range", ...]}
print(f"Violations: {results[0]}")
7. Query Language
Compose complex filter logic with Boolean expressions via mc.query.
import medchem as mc
# Parse a query combining rules, alerts, and property checks
query = mc.query.parse("rule_of_five AND NOT common_alerts")
results = query.apply(mols=mols, n_jobs=-1) # List[bool]
print(f"Passing: {sum(results)}/{len(results)}")
# More complex queries
q2 = mc.query.parse("rule_of_cns AND complexity < 400")
q3 = mc.query.parse("(rule_of_five OR rule_of_oprea) AND NOT pains_filter")
q4 = mc.query.parse("mw > 200 AND mw < 500 AND logp < 5")
8. Functional API & Utilities
Shortcut functions in mc.functional and utilities in mc.utils.
import medchem as mc
# Functional API — one-liner filters
nibr_ok = mc.functional.nibr_filter(mols=mols, n_jobs=-1) # List[bool]
alerts = mc.functional.common_alerts_filter(mols=mols, n_jobs=-1)
lilly = mc.functional.lilly_demerits_filter(mols=mols, n_jobs=-1)
# Utilities
standardized = mc.utils.standardize_mol(mol) # sanitize, neutralize charges
batch_out = mc.utils.batch_process(
mols=mols, func=mc.complexity.calculate_complexity,
n_jobs=-1, progress=True, batch_size=100
)
Key Concepts
Rule Thresholds Quick Reference
| Rule | MW | LogP | HBD | HBA | RotBonds | TPSA | Other |
|---|---|---|---|---|---|---|---|
| Ro5 (Lipinski) | ≤500 | ≤5 | ≤5 | ≤10 | — | — | — |
| Veber | — | — | — | — | ≤10 | ≤140 | — |
| Oprea (lead) | 200-350 | -2 to 4 | — | — | ≤7 | — | Rings ≤4 |
| Leadlike Soft | 250-450 | -3 to 4 | — | — | ≤10 | — | — |
| Leadlike Strict | 200-350 | -2 to 3.5 | — | — | ≤7 | — | Rings 1-3 |
| CNS | ≤450 | -1 to 5 | ≤2 | — | — | ≤90 | — |
| REOS | 200-500 | -5 to 5 | 0-5 | 0-10 | — | — | — |
| Ro3 (fragment) | ≤300 | ≤3 | ≤3 | ≤3 | ≤3 | ≤60 | — |
| Golden Triangle | 200-50*LogP+400 | -2 to 5 | — | — | — | — | — |
| Rule of Drug | Ro5 + Veber + no PAINS |
Filter Selection by Discovery Stage
| Stage | Recommended Filters | Rationale |
|---|---|---|
| Initial screening | Ro5, PAINS, Common Alerts | Broad triage, remove obvious liabilities |
| Hit-to-lead | Oprea or Leadlike Soft, NIBR, Lilly | Lead-like space, industrial filters |
| Lead optimization | Rule of Drug, Leadlike Strict, Complexity | Strict drug-likeness + synthetic feasibility |
| CNS targets | Rule of CNS, TPSA ≤90, HBD ≤2 | BBB permeability requirements |
| Fragment-based | Ro3, low complexity (≤250) | Fragments have "room to grow" |
Rules Are Guidelines, Not Absolutes
~10% of marketed drugs violate Ro5. Exceptions are common for natural products, antibiotics, PROTACs, and prodrugs. Always combine rule-based filtering with domain expertise and target-class knowledge. Different modalities (oral, IV, topical) and target classes (kinases, GPCRs, ion channels) have distinct optimal property spaces.
Common Workflows
Workflow 1: Compound Library Triage
Full pipeline from raw SMILES to filtered drug-like candidates.
import pandas as pd
import datamol as dm
import medchem as mc
# Load compound library
df = pd.read_csv("compounds.csv")
mols = [dm.to_mol(s) for s in df["smiles"]]
print(f"Input: {len(mols)} molecules")
# Step 1: Drug-likeness rules
rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber"])
rule_results = rfilter(mols=mols, n_jobs=-1, progress=True)
# Step 2: Structural alerts
alert_filter = mc.structural.CommonAlertsFilters()
alert_results = alert_filter(mols=mols, n_jobs=-1, progress=True)
# Step 3: Combine results
df["passes_rules"] = [all(r.values()) for r in rule_results]
df["has_alerts"] = [r["has_alerts"] for r in alert_results]
df["drug_like"] = df["passes_rules"] & ~df["has_alerts"]
filtered = df[df["drug_like"]]
print(f"Output: {len(filtered)} drug-like molecules ({len(filtered)/len(df)*100:.1f}%)")
filtered.to_csv("filtered_compounds.csv", index=False)
Workflow 2: Lead Optimization Filter Cascade
Apply progressively stricter filters with detailed reporting.
import datamol as dm
import medchem as mc
import pandas as pd
mols = [dm.to_mol(s) for s in smiles_list]
# Cascade: rules → structural alerts → complexity → Lilly demerits
filters = [
("Leadlike Strict", mc.rules.RuleFilters(rule_list=["rule_of_leadlike_strict"])),
("NIBR Alerts", mc.structural.NIBRFilters()),
("Complexity ≤400", mc.complexity.ComplexityFilter(max_complexity=400)),
("Lilly Demerits", mc.structural.LillyDemeritsFilters()),
]
surviving = list(range(len(mols)))
for name, filt in filters:
results = filt(mols=[mols[i] for i in surviving], n_jobs=-1)
# Handle different result formats
if isinstance(results[0], dict):
passed = [i for i, r in zip(surviving, results)
if r.get("passes", not r.get("has_alerts", True))]
else:
passed = [i for i, r in zip(surviving, results) if r]
print(f"{name}: {len(surviving)} → {len(passed)}")
surviving = passed
print(f"Final candidates: {len(surviving)} / {len(mols)}")
Key Parameters
| Parameter | Module | Default | Range | Effect |
|---|---|---|---|---|
rule_list |
RuleFilters |
— | See rules table | Which drug-likeness rules to apply |
n_jobs |
All filters | 1 |
-1 to N |
Parallel workers (-1 = all cores) |
progress |
All filters | False |
bool | Show progress bar |
max_complexity |
ComplexityFilter |
— | 0-1000+ | Bertz complexity threshold |
method |
calculate_complexity |
"bertz" |
bertz/whitlock/barone | Complexity metric |
mw_range |
Constraints |
None |
tuple(float, float) | Molecular weight range (Da) |
logp_range |
Constraints |
None |
tuple(float, float) | LogP range |
tpsa_max |
Constraints |
None |
0-200+ | Max topological polar surface area |
groups |
ChemicalGroup |
— | list of names | Predefined chemical groups to detect |
custom_smarts |
ChemicalGroup |
None |
dict | Custom SMARTS patterns {name: SMARTS} |
Best Practices
-
Start broad, then narrow: Apply permissive filters first (Ro5, PAINS), then progressively tighten (NIBR, Lilly, complexity) as the pipeline narrows the candidate set.
-
Always use parallelization: For libraries >1000 molecules, set
n_jobs=-1to use all CPU cores. -
Combine rules with structural alerts: Rules check physicochemical properties; alerts check substructure patterns. Both are needed for robust triage.
-
Anti-pattern — blind filtering: Do not blindly reject everything that fails Ro5. Consider the target class and modality before filtering.
-
Anti-pattern — ignoring prodrugs: Prodrugs intentionally violate standard rules. Flag them as exceptions rather than filtering them out.
-
Document filtering decisions: Track which molecules were removed and why for reproducibility and regulatory compliance.
-
Validate with known actives: Run your filter cascade on known active compounds for your target to estimate false-positive rate.
Common Recipes
Recipe: DataFrame Integration
import pandas as pd
import datamol as dm
import medchem as mc
df = pd.read_csv("molecules.csv")
df["mol"] = df["smiles"].apply(dm.to_mol)
rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_cns"])
results = rfilter(mols=df["mol"].tolist(), n_jobs=-1)
df["passes_ro5"] = [r["rule_of_five"] for r in results]
df["passes_cns"] = [r["rule_of_cns"] for r in results]
filtered = df[df["passes_ro5"] & df["passes_cns"]]
print(f"CNS drug-like: {len(filtered)}")
Recipe: Combining with ML Scoring
import medchem as mc
# Rule-based pre-filter
rule_results = mc.rules.RuleFilters(rule_list=["rule_of_five"])(mols, n_jobs=-1)
filtered_mols = [mol for mol, r in zip(mols, rule_results) if r["rule_of_five"]]
# ML model scoring on filtered set (reduces compute cost)
ml_scores = ml_model.predict(filtered_mols)
candidates = [mol for mol, score in zip(filtered_mols, ml_scores) if score > 0.8]
print(f"ML-scored candidates: {len(candidates)}")
Recipe: Custom SMARTS Group Screening
import medchem as mc
# Define project-specific warheads for covalent inhibitor screening
custom_warheads = {
"acrylamide": "[C;H1](=O)[CH]=[CH2]",
"vinyl_sulfonamide": "[NH]S(=O)(=O)[CH]=[CH2]",
"chloroacetamide": "ClCC(=O)N",
}
group = mc.groups.ChemicalGroup(groups=[], custom_smarts=custom_warheads)
has_warhead = group.has_match(mols)
warhead_mols = [mol for mol, match in zip(mols, has_warhead) if match]
print(f"Covalent warhead candidates: {len(warhead_mols)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
None in molecule list |
Invalid SMILES in input | Pre-filter: mols = [m for m in mols if m is not None] |
| All molecules fail Ro5 | Library is fragment-like or PPI space | Use rule_of_three or rule_of_leadlike_soft instead |
| No PAINS alerts found | Molecules are simple/fragment-like | Expected — PAINS patterns target screening-hit-size molecules |
| Lilly demerits all >100 | Highly functionalized molecules | Check individual patterns; consider raising threshold or using NIBR instead |
| Slow processing | Large library without parallelization | Set n_jobs=-1 for parallel execution |
ImportError: medchem |
Missing dependency | pip install medchem datamol (requires RDKit) |
| Query parse error | Invalid query syntax | Check operators: AND, OR, NOT, comparisons: <, >, == |
| Inconsistent result formats | Different filter classes return different types | Check docs: RuleFilters → dict, NIBRFilters → bool, LillyDemeritsFilters → dict |
Bundled Resources
references/rules_catalog.md
Complete catalog of all medicinal chemistry rules and structural alert filters with literature references, threshold criteria, chemical group patterns, custom SMARTS examples, and stage-specific filter selection guidelines. API function signatures were consolidated into Core API code blocks above.
Related Skills
- rdkit-cheminformatics — Low-level cheminformatics: SMILES/SDF parsing, descriptors, fingerprints, substructure search
- datamol-cheminformatics — High-level molecular manipulation: standardization, scaffolds, fragmentation, 3D conformers
- pubchem-compound-search — Database queries: retrieve compound properties and bioactivity data for validation
References
- Lipinski CA et al. Adv Drug Deliv Rev (1997) 23:3-25 — Rule of Five
- Veber DF et al. J Med Chem (2002) 45:2615-2623 — Veber rules
- Oprea TI et al. J Chem Inf Comput Sci (2001) 41:1308-1315 — Lead-likeness
- Baell JB & Holloway GA. J Med Chem (2010) 53:2719-2740 — PAINS filters
- Congreve M et al. Drug Discov Today (2003) 8:876-877 — Rule of Three
- Johnson TW et al. J Med Chem (2009) 52:5487-5500 — Golden Triangle
- Walters WP & Murcko MA. Adv Drug Deliv Rev (2002) 54:255-271 — REOS
- Official docs: https://medchem-docs.datamol.io/
- GitHub: https://github.com/datamol-io/medchem
skills/structural-biology-drug-discovery/molfeat-molecular-featurization/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill molfeat-molecular-featurization -g -y
SKILL.md
Frontmatter
{
"name": "molfeat-molecular-featurization",
"license": "Apache-2.0",
"description": "Molecular featurization hub (100+ featurizers) for ML. SMILES to fingerprints (ECFP, MACCS, MAP4), descriptors (RDKit 2D, Mordred), pretrained embeddings (ChemBERTa, GIN, Graphormer), pharmacophores. Scikit-learn compatible with parallelization\/caching. For QSAR, virtual screening, similarity, and molecular DL."
}
Molfeat — Molecular Featurization Hub
Overview
Molfeat is a comprehensive Python library for molecular featurization that unifies 100+ pre-trained embeddings and hand-crafted featurizers under a scikit-learn compatible API. Convert SMILES strings into numerical representations (fingerprints, descriptors, deep learning embeddings) for QSAR modeling, virtual screening, similarity searching, and chemical space analysis.
When to Use
- Building QSAR/QSPR models requiring molecular features as input
- Virtual screening — ranking compound libraries by predicted activity
- Similarity searching against molecular databases
- Chemical space analysis — clustering, visualization, dimensionality reduction
- Deep learning on molecules using pretrained embeddings (ChemBERTa, GIN)
- Featurization pipelines integrating with scikit-learn or PyTorch
- Comparing multiple molecular representations for benchmarking
- For molecular manipulation and filtering use datamol instead; for substructure-based molecular operations use rdkit-cheminformatics
Prerequisites
uv pip install molfeat
# Optional extras for specific featurizer types
uv pip install "molfeat[transformer]" # ChemBERTa, ChemGPT, MolT5
uv pip install "molfeat[dgl]" # GIN graph neural networks
uv pip install "molfeat[graphormer]" # Graphormer models
uv pip install "molfeat[fcd]" # FCD descriptors
uv pip install "molfeat[map4]" # MAP4 fingerprints
uv pip install "molfeat[all]" # All dependencies
Quick Start
from molfeat.calc import FPCalculator
from molfeat.trans import MoleculeTransformer
smiles = ["CCO", "CC(=O)O", "c1ccccc1", "CC(C)O"]
# Create fingerprint calculator + transformer
calc = FPCalculator("ecfp", radius=3, fpSize=2048)
transformer = MoleculeTransformer(calc, n_jobs=-1)
# Featurize batch in parallel
features = transformer(smiles)
print(f"Shape: {features.shape}") # (4, 2048)
# Save configuration for reproducibility
transformer.to_state_yaml_file("featurizer_config.yml")
Key Concepts
Architecture: Calculator → Transformer → Store
Molfeat organizes featurization into three layers:
| Layer | Class | Purpose | Use When |
|---|---|---|---|
| Calculator | molfeat.calc.* |
Single molecule → feature vector | Custom loops, single molecules |
| Transformer | molfeat.trans.MoleculeTransformer |
Batch processing with parallelization | Datasets, scikit-learn pipelines |
| Store | molfeat.store.ModelStore |
Discovery and loading of pretrained models | Finding available featurizers |
Calculators are callable: calc("CCO") returns a numpy array. Transformers wrap calculators for batch processing: transformer(smiles_list) returns a 2D array. Pretrained transformers (PretrainedMolTransformer) add batched GPU inference and caching.
Featurizer Selection Guide
| Task | Recommended | Dimensions | Speed |
|---|---|---|---|
| General QSAR | ecfp (radius=3) |
2048 | Fast |
| Scaffold similarity | maccs |
167 | Very fast |
| Large-scale screening | map4 |
1024 | Fast |
| Interpretable models | desc2D (RDKitDescriptors2D) |
200+ | Fast |
| Comprehensive descriptors | mordred |
1800+ | Medium |
| Transfer learning | ChemBERTa-77M-MLM |
768 | Slow* |
| Graph-based DL | gin-supervised-masking |
Variable | Slow* |
| Pharmacophore | fcfp or cats2D |
2048 / 21 | Fast |
| 3D shape | usr / usrcat |
12 / 60 | Fast |
*First run slow; subsequent runs cached.
State Persistence
Save and reload exact featurizer configuration for reproducibility:
# Save
transformer.to_state_yaml_file("config.yml")
transformer.to_state_json_file("config.json")
# Reload
loaded = MoleculeTransformer.from_state_yaml_file("config.yml")
Core API
1. Fingerprint Calculators
from molfeat.calc import FPCalculator
# ECFP — most popular, general-purpose
ecfp = FPCalculator("ecfp", radius=3, fpSize=2048)
fp = ecfp("CCO")
print(f"ECFP shape: {fp.shape}") # (2048,)
# MACCS keys — 167-bit structural keys, fast scaffold similarity
maccs = FPCalculator("maccs")
fp = maccs("c1ccccc1")
print(f"MACCS shape: {fp.shape}") # (167,)
# Count-based fingerprints (non-binary)
ecfp_count = FPCalculator("ecfp-count", radius=3, fpSize=2048)
# MAP4 — MinHashed atom-pair, efficient for large databases
map4 = FPCalculator("map4")
print(f"MAP4 shape: {map4('CCO').shape}") # (1024,)
Available fingerprint types: ecfp, fcfp, maccs, rdkit, avalon, pattern, layered, atompair, topological, map4, secfp, erg, estate (and count variants with -count suffix).
2. Descriptor Calculators
from molfeat.calc import RDKitDescriptors2D, MordredDescriptors
# RDKit 2D — 200+ named properties (MW, logP, TPSA, etc.)
desc2d = RDKitDescriptors2D()
descriptors = desc2d("CCO")
print(f"2D descriptors: {len(descriptors)}") # 200+
print(f"Feature names: {desc2d.columns[:5]}")
# Mordred — 1800+ comprehensive descriptors
mordred = MordredDescriptors()
descriptors = mordred("c1ccccc1O")
print(f"Mordred descriptors: {len(descriptors)}") # 1800+
3. Pharmacophore & Shape Calculators
from molfeat.calc import CATSCalculator, USRDescriptors
# CATS — pharmacophore point pair distributions
cats = CATSCalculator(mode="2D", scale="raw")
descriptors = cats("CC(C)Cc1ccc(C)cc1C")
print(f"CATS shape: {descriptors.shape}") # (21,)
# USR — ultrafast shape recognition
usr = USRDescriptors()
shape = usr("CC(=O)Oc1ccccc1C(=O)O")
print(f"USR shape: {shape.shape}") # (12,)
4. Batch Processing with Transformers
from molfeat.trans import MoleculeTransformer, FeatConcat
from molfeat.calc import FPCalculator
smiles = ["CCO", "CC(=O)O", "c1ccccc1", "CC(C)O", "CCCC"]
# Parallel batch processing
transformer = MoleculeTransformer(FPCalculator("ecfp"), n_jobs=-1)
features = transformer(smiles)
print(f"Batch shape: {features.shape}") # (5, 2048)
# Concatenate multiple featurizers
concat = FeatConcat([
FPCalculator("maccs"), # 167 dims
FPCalculator("ecfp") # 2048 dims
])
combo_transformer = MoleculeTransformer(concat, n_jobs=-1)
combo_features = combo_transformer(smiles)
print(f"Combined shape: {combo_features.shape}") # (5, 2215)
# Error-tolerant processing
safe_transformer = MoleculeTransformer(
FPCalculator("ecfp"), n_jobs=-1,
ignore_errors=True, verbose=True
)
features = safe_transformer(["CCO", "invalid", "c1ccccc1"])
# Returns None for failed molecules
5. Pretrained Model Embeddings
from molfeat.trans.pretrained import PretrainedMolTransformer
# ChemBERTa — RoBERTa trained on 77M PubChem compounds
chemberta = PretrainedMolTransformer("ChemBERTa-77M-MLM", n_jobs=-1)
embeddings = chemberta(["CCO", "CC(=O)O", "c1ccccc1"])
print(f"ChemBERTa shape: {embeddings.shape}") # (3, 768)
# GIN — graph neural network pretrained on ChEMBL
gin = PretrainedMolTransformer("gin-supervised-masking", n_jobs=-1)
graph_emb = gin(["CCO", "CC(=O)O"])
print(f"GIN shape: {graph_emb.shape}")
6. ModelStore — Discovering Featurizers
from molfeat.store.modelstore import ModelStore
store = ModelStore()
print(f"Total available: {len(store.available_models)}")
# Search for specific model
results = store.search(name="ChemBERTa")
for model in results:
print(f" {model.name}: {model.description}")
# View usage and load
card = store.search(name="ChemBERTa-77M-MLM")[0]
card.usage()
transformer = store.load("ChemBERTa-77M-MLM")
Common Workflows
Workflow 1: QSAR Model Building
from molfeat.calc import FPCalculator
from molfeat.trans import MoleculeTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
# Featurize molecules
transformer = MoleculeTransformer(FPCalculator("ecfp", radius=3), n_jobs=-1)
X = transformer(smiles_train)
print(f"Features shape: {X.shape}")
# Train and evaluate
model = RandomForestRegressor(n_estimators=100)
scores = cross_val_score(model, X, y_train, cv=5, scoring='r2')
print(f"R² = {scores.mean():.3f} ± {scores.std():.3f}")
# Save for deployment
transformer.to_state_yaml_file("production_featurizer.yml")
Workflow 2: Virtual Screening Pipeline
from sklearn.ensemble import RandomForestClassifier
# Step 1: Featurize known actives/inactives
transformer = MoleculeTransformer(FPCalculator("ecfp"), n_jobs=-1)
X_train = transformer(train_smiles)
# Step 2: Train classifier
clf = RandomForestClassifier(n_estimators=500, n_jobs=-1)
clf.fit(X_train, train_labels)
# Step 3: Screen library (e.g., 1M compounds)
X_screen = transformer(screening_smiles)
predictions = clf.predict_proba(X_screen)[:, 1]
# Step 4: Rank and select top hits
top_indices = predictions.argsort()[::-1][:1000]
top_hits = [screening_smiles[i] for i in top_indices]
print(f"Top 1000 hits selected from {len(screening_smiles)} compounds")
Workflow 3: Featurizer Benchmarking
from molfeat.calc import FPCalculator, RDKitDescriptors2D
from sklearn.metrics import roc_auc_score
featurizers = {
'ECFP': FPCalculator("ecfp"),
'MACCS': FPCalculator("maccs"),
'Descriptors': RDKitDescriptors2D(),
}
for name, calc in featurizers.items():
transformer = MoleculeTransformer(calc, n_jobs=-1)
X_train = transformer(smiles_train)
X_test = transformer(smiles_test)
clf = RandomForestClassifier(n_estimators=100)
clf.fit(X_train, y_train)
auc = roc_auc_score(y_test, clf.predict_proba(X_test)[:, 1])
print(f"{name}: AUC = {auc:.3f}")
Common Recipes
Recipe: Scikit-learn Pipeline Integration
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier
pipeline = Pipeline([
('featurizer', MoleculeTransformer(FPCalculator("ecfp"), n_jobs=-1)),
('classifier', RandomForestClassifier(n_estimators=100))
])
pipeline.fit(smiles_train, y_train)
predictions = pipeline.predict(smiles_test)
Recipe: Similarity Search
from sklearn.metrics.pairwise import cosine_similarity
calc = FPCalculator("ecfp")
query_fp = calc("CC(=O)Oc1ccccc1C(=O)O").reshape(1, -1) # Aspirin
transformer = MoleculeTransformer(calc, n_jobs=-1)
db_fps = transformer(database_smiles)
similarities = cosine_similarity(query_fp, db_fps)[0]
top_k = similarities.argsort()[-10:][::-1]
for i in top_k:
print(f" {database_smiles[i]}: {similarities[i]:.3f}")
Recipe: Chunk Processing for Large Datasets
import numpy as np
def featurize_chunks(smiles_list, transformer, chunk_size=10000):
all_features = []
for i in range(0, len(smiles_list), chunk_size):
chunk = smiles_list[i:i+chunk_size]
features = transformer(chunk)
all_features.append(features)
print(f"Processed {min(i+chunk_size, len(smiles_list))}/{len(smiles_list)}")
return np.vstack(all_features)
Key Parameters
| Parameter | Module | Default | Description |
|---|---|---|---|
method |
FPCalculator | — | Fingerprint type: ecfp, maccs, map4, etc. |
radius |
FPCalculator | 3 | Circular fingerprint radius |
fpSize |
FPCalculator | 2048 | Fingerprint bit length |
counting |
FPCalculator | False | Count vector instead of binary |
n_jobs |
MoleculeTransformer | 1 | Parallel workers (-1 = all cores) |
ignore_errors |
MoleculeTransformer | False | Skip invalid molecules (returns None) |
verbose |
MoleculeTransformer | False | Log processing details |
dtype |
MoleculeTransformer | float64 | Output type (float32 for memory) |
mode |
CATSCalculator | "2D" | Distance calculation mode |
scale |
CATSCalculator | "raw" | Scaling: raw, num, count |
Best Practices
- Use
n_jobs=-1for parallel processing on all CPU cores — significant speedup for batch featurization - Start with ECFP for initial baselines — best general-purpose fingerprint before trying deep learning
- Use
ignore_errors=Truefor large datasets — invalid SMILES won't crash the pipeline - Save configurations with
to_state_yaml_file()for reproducibility — recreate exact featurizer later - Use float32 when memory matters:
MoleculeTransformer(calc, dtype=np.float32) - Cache pretrained embeddings — first ChemBERTa/GIN inference is slow, subsequent runs use cache
- Process in chunks for datasets >100K — prevents memory exhaustion (see Recipes)
- Combine fingerprints with
FeatConcatto capture complementary molecular information
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ValueError: unsupported featurizer |
Unknown method name | Check FPCalculator supported types or use ModelStore.search() |
ImportError for pretrained model |
Missing optional dependency | Install extras: pip install "molfeat[transformer]" or "molfeat[dgl]" |
None in output array |
Invalid SMILES with ignore_errors=True |
Filter results: [f for f in features if f is not None] |
| Memory error on large dataset | Too many molecules at once | Process in chunks of 10K-50K (see Recipes) |
| Slow pretrained model inference | First run downloads model weights | Normal — subsequent runs use cache |
| Shape mismatch in pipeline | Mixed valid/invalid molecules | Ensure ignore_errors=True and filter None before ML model |
| Reproducibility issues | Different molfeat versions | Pin version and save config: transformer.to_state_yaml_file() |
Related Skills
datamol-cheminformatics— High-level molecular manipulation (standardization, I/O, conformers)rdkit-cheminformatics— Low-level cheminformatics (substructure, reactions, 3D)scikit-learn— ML models consuming molfeat features
References
- Official documentation: https://molfeat-docs.datamol.io/
- GitHub repository: https://github.com/datamol-io/molfeat
- PyPI package: https://pypi.org/project/molfeat/
- Tutorial: https://portal.valencelabs.com/datamol/post/types-of-featurizers-b1e8HHrbFMkbun6
Bundled Resources
Main SKILL.md + 2 reference files. Original total: 1,273 lines (SKILL.md 510 + api_reference.md 429 + available_featurizers.md 334). Scripts: none. Examples: 724 lines (examples.md).
references/available_featurizers.md: Complete catalog of all 100+ featurizers organized by category — transformer models, GNNs, descriptors, fingerprints, pharmacophore, shape, scaffold, graph featurizers. Includes dimensions, dependencies, and selection guidance per category. Purely lookup-oriented content preserved as reference.
references/api_reference.md: Detailed API reference for molfeat.calc, molfeat.trans, and molfeat.store modules. Covers SerializableCalculator base class, all calculator subclasses with parameters, MoleculeTransformer methods, PretrainedMolTransformer, FeatConcat, ModelStore/ModelCard API, data type control, and PyTorch integration patterns.
Original file disposition:
SKILL.md(510 lines) → Core API modules 1-6, Key Concepts (architecture, selection guide), Quick Start, Workflows 1-3. "Choosing the Right Featurizer" → Key Concepts selection guide table. "Advanced Features" (custom preprocessing, batch processing, caching) → Recipes + Best Practices. "Common Featurizers Reference" table → Key Concepts selection guide. "Performance Tips" → Best Practices. Per-use-case disposition: QSAR Modeling → Workflow 1, Virtual Screening → Workflow 2, Similarity Search → Recipe, Chemical Space → When to Use bullet, scikit-learn Pipeline → Recipe, Featurizer Comparison → Workflow 3references/api_reference.md(429 lines) → Migrated to newreferences/api_reference.md. Core patterns (FPCalculator, MoleculeTransformer, basic ModelStore) relocated to SKILL.md Core API modules 1-6. Detailed class methods, SerializableCalculator base class, PrecomputedMolTransformer, and PyTorch integration retained in referencereferences/available_featurizers.md(334 lines) → Migrated to newreferences/available_featurizers.md. Top-level summary → Key Concepts selection guide table. Full categorized catalog retained in referencereferences/examples.md(724 lines) → Fully consolidated inline: installation → Prerequisites; calculator examples → Core API 1-3; transformer examples → Core API 4; pretrained examples → Core API 5; ML integration → Workflows 1-3 + Recipes; advanced patterns (custom preprocessing, caching, chunk processing) → Recipes + Best Practices; troubleshooting → Troubleshooting table. No separate reference file needed — all content absorbed into SKILL.md sections
Retention: ~490 lines (SKILL.md) + ~170 lines (available_featurizers) + ~190 lines (api_reference) = ~850 / 1,273 original (excl. examples.md treated as consolidated) = ~67%. Including examples.md in denominator: ~850 / 1,997 = ~43%.
skills/structural-biology-drug-discovery/opentargets-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill opentargets-database -g -y
SKILL.md
Frontmatter
{
"name": "opentargets-database",
"license": "Apache-2.0",
"description": "Query Open Targets GraphQL API for target-disease associations, evidence, drug links, safety. Search targets by gene, diseases by EFO ID; scores from 20+ sources, drug mechanisms, tractability. For ChEMBL use chembl-database-bioactivity; for trials use clinicaltrials-database-search."
}
Open Targets Platform Database
Overview
Open Targets Platform integrates evidence from genetics, genomics, literature, and drug databases to systematically score target-disease associations for 60,000+ targets and 20,000+ diseases/phenotypes. The public GraphQL API (no authentication required) provides access to association scores, evidence from 20+ data sources (GWAS, ClinVar, ChEMBL, drugs, pathways, mouse models, expression), and detailed drug-target-disease triangles.
When to Use
- Ranking therapeutic targets for a disease by overall association score and evidence breakdown
- Finding all diseases associated with a gene of interest and their confidence scores
- Retrieving approved and investigational drugs for a target, with mechanism of action and clinical phase
- Assessing target druggability and tractability (small molecule, antibody, PROTAC likelihood)
- Pulling genetic association evidence (GWAS hits, variant-to-gene mappings) for a target-disease pair
- Exploring safety/adverse event data for a drug target from FAERS and literature
- For bioactivity IC50/Ki data use
chembl-database-bioactivity; for clinical trial details useclinicaltrials-database-search
Prerequisites
- Python packages:
requests - Data requirements: gene symbols (HGNC), Ensembl gene IDs, disease EFO IDs, or drug names
- Environment: internet connection; no authentication needed
- Rate limits: no hard limit stated; use reasonable delays for large queries (>100 targets)
pip install requests
Quick Start
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
# Top disease associations for BRCA1
query = """
query TargetDiseases($ensgId: String!) {
target(ensemblId: $ensgId) {
id
approvedSymbol
associatedDiseases(page: {index: 0, size: 5}) {
rows {
disease { id name }
score
}
}
}
}
"""
data = ot_query(query, {"ensgId": "ENSG00000012048"})
target = data["target"]
print(f"Target: {target['approvedSymbol']}")
for row in target["associatedDiseases"]["rows"]:
print(f" {row['disease']['name']}: {row['score']:.3f}")
Core API
Query 1: Target Lookup by Gene Symbol
Search for a target and retrieve basic metadata (Ensembl ID, biotype, description).
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
# Search by gene symbol
query = """
query SearchTarget($sym: String!) {
search(queryString: $sym, entityNames: ["target"]) {
hits {
id
name
entity
object {
... on Target {
approvedSymbol
approvedName
biotype
functionDescriptions
}
}
}
}
}
"""
data = ot_query(query, {"sym": "BRCA1"})
for hit in data["search"]["hits"][:3]:
obj = hit.get("object", {})
print(f"ID: {hit['id']} | {obj.get('approvedSymbol')} | {obj.get('biotype')}")
descs = obj.get("functionDescriptions", [])
if descs:
print(f" Function: {descs[0][:120]}")
# Direct lookup by Ensembl ID
query2 = """
query Target($ensgId: String!) {
target(ensemblId: $ensgId) {
id approvedSymbol approvedName biotype
tractability { label modality value }
}
}
"""
data2 = ot_query(query2, {"ensgId": "ENSG00000141510"}) # TP53
t = data2["target"]
print(f"\n{t['approvedSymbol']} ({t['id']}): {t['biotype']}")
print("Tractability:")
for tr in t.get("tractability", [])[:5]:
print(f" {tr['modality']} | {tr['label']}: {tr['value']}")
Query 2: Target-Disease Associations
Retrieve association scores for a target across all associated diseases.
import requests, pandas as pd
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query Associations($ensgId: String!, $size: Int!) {
target(ensemblId: $ensgId) {
approvedSymbol
associatedDiseases(page: {index: 0, size: $size}, orderByScore: "score") {
count
rows {
disease { id name therapeuticAreas { name } }
score
datatypeScores { id score }
}
}
}
}
"""
data = ot_query(query, {"ensgId": "ENSG00000012048", "size": 20})
target = data["target"]
assoc = target["associatedDiseases"]
print(f"{target['approvedSymbol']}: {assoc['count']} associated diseases")
rows = []
for r in assoc["rows"]:
scores = {d["id"]: d["score"] for d in r.get("datatypeScores", [])}
rows.append({
"disease": r["disease"]["name"],
"disease_id": r["disease"]["id"],
"overall_score": round(r["score"], 4),
"genetics": round(scores.get("genetic_association", 0), 3),
"drugs": round(scores.get("known_drug", 0), 3),
"literature": round(scores.get("literature", 0), 3),
})
df = pd.DataFrame(rows)
print(df.head(10).to_string(index=False))
Query 3: Disease-Target Associations
Given a disease, retrieve all associated targets ranked by score.
import requests, pandas as pd
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query DiseaseTargets($efoId: String!, $size: Int!) {
disease(efoId: $efoId) {
id name
associatedTargets(page: {index: 0, size: $size}, orderByScore: "score") {
count
rows {
target { id approvedSymbol biotype }
score
datatypeScores { id score }
}
}
}
}
"""
# EFO_0000305 = breast carcinoma
data = ot_query(query, {"efoId": "EFO_0000305", "size": 10})
disease = data["disease"]
print(f"Disease: {disease['name']}")
print(f"Total associated targets: {disease['associatedTargets']['count']}")
for row in disease["associatedTargets"]["rows"][:5]:
t = row["target"]
print(f" {t['approvedSymbol']:12s} score={row['score']:.3f} biotype={t['biotype']}")
Query 4: Known Drugs for a Target
Retrieve approved and investigational drugs, their mechanism, and clinical phase.
import requests, pandas as pd
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query KnownDrugs($ensgId: String!) {
target(ensemblId: $ensgId) {
approvedSymbol
drugAndClinicalCandidates {
count
rows {
maxClinicalStage
drug { id name drugType maximumClinicalStage mechanismsOfAction { rows { mechanismOfAction } } }
diseases { disease { id name } }
}
}
}
}
"""
data = ot_query(query, {"ensgId": "ENSG00000146648"}) # EGFR
target = data["target"]
drugs_data = target["drugAndClinicalCandidates"]
print(f"{target['approvedSymbol']}: {drugs_data['count']} drug-indication pairs")
rows = []
for r in drugs_data["rows"]:
drug = r["drug"]
moa = drug.get("mechanismsOfAction") or {}
moa_first = (moa.get("rows") or [{}])[0].get("mechanismOfAction")
first_disease = (r.get("diseases") or [{}])[0].get("disease") or {}
rows.append({
"drug": drug["name"],
"type": drug["drugType"],
"maxClinicalStage": r["maxClinicalStage"],
"approved": drug["maximumClinicalStage"] == "PHASE_4",
"indication": first_disease.get("name", "n/a"),
"mechanism": moa_first,
})
df = pd.DataFrame(rows).drop_duplicates(subset=["drug", "indication"])
print(df.head(10).to_string(index=False))
Query 5: Evidence for a Specific Target-Disease Pair
Retrieve detailed evidence records (GWAS, ClinVar, literature) for a target-disease pair.
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query Evidence($ensgId: String!, $efoId: String!) {
disease(efoId: $efoId) {
evidences(
ensemblIds: [$ensgId]
enableIndirect: true
size: 10
datasourceIds: ["gwas_catalog", "clinvar", "chembl"]
) {
count
rows {
datasourceId
score
variantRsId
studyId
publicationYear
clinicalSignificances
}
}
}
}
"""
data = ot_query(query, {"ensgId": "ENSG00000012048", "efoId": "EFO_0000305"})
evidences = data["disease"]["evidences"]
print(f"Evidence records: {evidences['count']}")
for ev in evidences["rows"][:5]:
print(f" Source: {ev['datasourceId']:20s} | Score: {ev['score']:.3f}")
Query 6: Safety and Adverse Events
Retrieve known adverse events and safety data for a target.
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query Safety($ensgId: String!) {
target(ensemblId: $ensgId) {
approvedSymbol
safetyLiabilities {
event
effects { direction dosing }
biosamples { tissueLabel cellLabel }
datasource
}
}
}
"""
data = ot_query(query, {"ensgId": "ENSG00000146648"}) # EGFR
target = data["target"]
print(f"Safety liabilities for {target['approvedSymbol']}:")
for s in target.get("safetyLiabilities", [])[:5]:
print(f" Event: {s['event']}")
# 2025 schema: `datasource` is a scalar literature-citation string,
# not the legacy `datasources[{name, pmid}]` list of objects
print(f" Source: {s.get('datasource', 'n/a')}")
for eff in s.get("effects", []) or []:
print(f" Effect: direction={eff.get('direction')} dosing={eff.get('dosing')}")
Key Concepts
Association Scores
Open Targets uses harmonic sum aggregation to combine evidence from multiple data sources into a 0–1 association score. Subscores include: genetic_association, somatic_mutation, known_drug, affected_pathway, literature, RNA_expression, animal_model, and others. Higher scores indicate more and stronger evidence.
EFO IDs for Diseases
Open Targets uses Experimental Factor Ontology (EFO) identifiers for diseases (e.g., EFO_0000305 for breast carcinoma). Search by disease name using the search query to find EFO IDs before querying associations.
Common Workflows
Workflow 1: Target Prioritization for a Disease
Goal: Given a disease, rank all associated targets by overall score and export with evidence breakdown.
import requests, pandas as pd, time
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
def disease_search(name):
q = 'query S($q:String!){search(queryString:$q,entityNames:["disease"]){hits{id name}}}'
data = ot_query(q, {"q": name})
return [(h["id"], h["name"]) for h in data["search"]["hits"][:3]]
def get_top_targets(efo_id, n=50):
q = """
query($efoId:String!,$size:Int!){
disease(efoId:$efoId){
name
associatedTargets(page:{index:0,size:$size},orderByScore:"score"){
count
rows{
target{id approvedSymbol biotype}
score
datatypeScores { id score }
}
}
}
}"""
data = ot_query(q, {"efoId": efo_id, "size": n})
disease = data["disease"]
rows = []
for row in disease["associatedTargets"]["rows"]:
t = row["target"]
scores = {d["id"]: round(d["score"], 3) for d in row.get("datatypeScores", [])}
rows.append({
"target": t["approvedSymbol"],
"ensembl_id": t["id"],
"biotype": t["biotype"],
"overall_score": round(row["score"], 4),
**scores
})
return disease["name"], pd.DataFrame(rows)
# Step 1: Find EFO ID for disease
candidates = disease_search("non-small cell lung carcinoma")
print("Disease candidates:", candidates)
# Step 2: Get top targets
disease_name, df = get_top_targets("EFO_0003060", n=50)
df.to_csv("target_prioritization.csv", index=False)
print(f"\nTop targets for {disease_name}:")
cols = [c for c in ["target", "overall_score", "genetic_association", "known_drug", "literature", "rna_expression", "somatic_mutation"] if c in df.columns]
print(df[cols].head(10).to_string(index=False))
Workflow 2: Drug-Target-Disease Triangle
Goal: For a target, retrieve all drugs and their associated indications and phases.
import requests, pandas as pd
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
def ot_query(gql, variables=None):
r = requests.post(OT_URL, json={"query": gql, "variables": variables or {}})
r.raise_for_status()
return r.json()["data"]
query = """
query($ensgId:String!){
target(ensemblId:$ensgId){
approvedSymbol
drugAndClinicalCandidates{
count
rows{
maxClinicalStage
drug{id name drugType maximumClinicalStage mechanismsOfAction { rows { mechanismOfAction } } }
diseases { disease { id name } }
}
}
}
}"""
targets = {
"EGFR": "ENSG00000146648",
"ERBB2": "ENSG00000141736",
}
all_rows = []
for sym, ensg in targets.items():
data = ot_query(query, {"ensgId": ensg})
for row in data["target"]["drugAndClinicalCandidates"]["rows"]:
drug = row["drug"]
moa = drug.get("mechanismsOfAction") or {}
moa_first = (moa.get("rows") or [{}])[0].get("mechanismOfAction")
first_disease = (row.get("diseases") or [{}])[0].get("disease") or {}
all_rows.append({
"target": sym,
"drug": drug["name"],
"drug_type": drug["drugType"],
"maxClinicalStage": row["maxClinicalStage"],
"approved": drug["maximumClinicalStage"] == "PHASE_4",
"indication": first_disease.get("name", "n/a"),
"mechanism": moa_first,
})
df = pd.DataFrame(all_rows)
df.to_csv("drug_target_matrix.csv", index=False)
print(df.head(10).to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
page.size |
Associations | 10 |
1–10000 |
Records per page |
page.index |
Associations | 0 |
0–N |
Page index for pagination |
orderByScore |
Associations | "score" |
"score", component IDs |
Sort associations by score |
datasourceIds |
Evidence | all sources | list of datasource IDs | Filter evidence by source |
enableIndirect |
Evidence | false |
true/false |
Include child disease evidence |
entityNames |
Search | all | ["target"], ["disease"] |
Filter search entity type |
Best Practices
-
Use EFO IDs for diseases: Disease names vary; always use the
searchquery to get the canonical EFO ID before running association queries to avoid name-matching issues. -
Paginate for full result sets: Default page size is 10; use
page.size: 10000for complete results, but be aware this can return large payloads. -
Filter by
datatypeScores: For genetic target validation, filter ongenetic_associationsubscore > 0.1; for drug repurposing, prioritizeknown_drugsubscore. -
Use
enableIndirect: truein evidence queries to include evidence for disease subtypes (child terms in EFO hierarchy). -
Cache GraphQL responses: Open Targets data updates quarterly; cache responses during analysis to avoid redundant API calls.
Common Recipes
Recipe: Disease Name to EFO ID
When to use: Resolve a disease name to the EFO ID needed for association queries.
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
query = """
query($q: String!) {
search(queryString: $q, entityNames: ["disease"]) {
hits { id name score }
}
}"""
r = requests.post(OT_URL, json={"query": query, "variables": {"q": "breast cancer"}})
for hit in r.json()["data"]["search"]["hits"][:5]:
print(f"{hit['id']}: {hit['name']} (score={hit['score']:.3f})")
Recipe: Target Tractability Assessment
When to use: Assess whether a target is tractable for small molecules, antibodies, or PROTACs.
import requests
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
query = """
query($ensgId: String!) {
target(ensemblId: $ensgId) {
approvedSymbol
tractability { label modality value }
}
}"""
r = requests.post(OT_URL, json={"query": query, "variables": {"ensgId": "ENSG00000141510"}})
t = r.json()["data"]["target"]
print(f"Tractability for {t['approvedSymbol']}:")
for tr in t.get("tractability", []):
if tr["value"]:
print(f" [{tr['modality']}] {tr['label']}")
Recipe: Approved Drugs for a Disease
When to use: Find all approved drugs for a disease with phase 4 evidence.
import requests, pandas as pd
OT_URL = "https://api.platform.opentargets.org/api/v4/graphql"
query = """
query($efoId: String!) {
disease(efoId: $efoId) {
name
drugAndClinicalCandidates { count rows {
maxClinicalStage
drug { name maximumClinicalStage drugType }
}}
}
}"""
r = requests.post(OT_URL, json={"query": query, "variables": {"efoId": "EFO_0000305"}})
data = r.json()["data"]["disease"]
approved = [row for row in data["drugAndClinicalCandidates"]["rows"] if row["drug"]["maximumClinicalStage"] == "PHASE_4"]
print(f"Approved drugs for {data['name']}: {len(approved)}")
for row in approved[:5]:
print(f" {row['drug']['name']} ({row['drug']['drugType']}) maxStage={row['maxClinicalStage']}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 400 with GraphQL error |
Malformed query or invalid field name | Check query against GraphQL schema at https://api.platform.opentargets.org/api/v4/graphql |
Empty rows in associations |
EFO ID not recognized | Use search query to find correct EFO ID |
| Target not found | Gene symbol vs Ensembl ID mismatch | Use search query first to resolve Ensembl ID |
| Slow query for large result set | page.size too large |
Cap at 500 rows; paginate with multiple requests |
| Missing tractability data | Target not assessed | Not all targets have tractability; check tractability field is non-null |
drugAndClinicalCandidates empty |
No drug-target evidence in ChEMBL | Use chembl-database-bioactivity for preclinical compound activity |
Related Skills
chembl-database-bioactivity— Bioactivity IC50/Ki data for compounds against targetsclinicaltrials-database-search— Detailed clinical trial information for drugs found via Open Targetsensembl-database— Ensembl IDs and variant annotations needed as input to Open Targets queriesstring-database-ppi— Protein-protein interaction networks to contextualize target biology
References
- Open Targets Platform — Interactive target-disease browser
- Open Targets GraphQL API documentation — Query reference and schema
- GraphQL playground — Interactive query builder
- Open Targets data sources — Evidence types and scoring methodology
skills/structural-biology-drug-discovery/pdb-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pdb-database -g -y
SKILL.md
Frontmatter
{
"name": "pdb-database",
"license": "BSD-3-Clause",
"description": "Query RCSB PDB (200K+ structures) via the public REST + GraphQL APIs with plain `requests` (no SDK). Search by text, attribute, sequence, or 3D structure similarity (Search API); retrieve metadata via GraphQL (Data API); download PDB\/mmCIF from files.rcsb.org. For AlphaFold predictions use alphafold-database-access; for protein sequences only use uniprot-protein-database."
}
PDB Database
Why no SDK? The
rcsb-apiPython SDK is convenient sugar over three public, no-auth REST endpoints (search.rcsb.org,data.rcsb.org,files.rcsb.org). When the SDK is unavailable, every operation can be reproduced with plainrequestsand a small JSON payload. This SKILL.md uses the REST path throughout so the code runs in any environment withrequestsinstalled.
Overview
RCSB PDB is the worldwide repository for 3D structural data of biological macromolecules with 200,000+ experimentally determined structures. Programmatic access is via three free, no-auth endpoints:
| API | Base URL | Method | Purpose |
|---|---|---|---|
| Search | https://search.rcsb.org/rcsbsearch/v2/query |
POST JSON |
Find PDB IDs by text, attribute filters, sequence, or 3D similarity |
| Data | https://data.rcsb.org/graphql |
POST GraphQL |
Retrieve structured metadata (entries, polymer entities, assemblies, ligands) |
| Files | https://files.rcsb.org/download/{id}.{format} |
GET |
Download coordinate files (mmCIF, PDB, FASTA) |
Use this skill for programmatic structural biology queries, drug target analysis, and protein family comparisons.
When to Use
- Searching for protein or nucleic acid crystal/cryo-EM/NMR structures by keyword or property
- Finding structures similar to a query sequence (MMseqs2) or 3D geometry (BioZernike)
- Retrieving experimental metadata (resolution, method, organism, deposition date) for structure sets
- Downloading coordinate files (PDB, mmCIF) for molecular dynamics, docking, or visualization
- Building structure-based datasets for machine learning or drug discovery pipelines
- Comparing protein-ligand complexes across a target family
- For AlphaFold predicted structures, use
alphafold-database-accessinstead - For protein sequence/annotation queries without structures, use
uniprot-protein-databaseinstead
Prerequisites
- Python packages:
requests(only requirement). Optional:biopythonfor parsing downloaded coordinate files. - No API key required: RCSB PDB is freely accessible.
- Rate limits: No published hard limit. Polite delays of
time.sleep(0.2-0.5)between requests are sufficient; implement exponential backoff on HTTP 429.
pip install requests
# Optional, for coordinate parsing:
pip install biopython
Quick Start
Typical search-then-fetch pattern: hit the Search API, get a list of PDB IDs, then resolve metadata via the GraphQL Data API.
import requests
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
DATA = "https://data.rcsb.org/graphql"
# 1. Search: human X-ray structures of "kinase" at resolution < 2.0 Å
payload = {
"query": {
"type": "group", "logical_operator": "and",
"nodes": [
{"type": "terminal", "service": "full_text",
"parameters": {"value": "kinase"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entity_source_organism.scientific_name",
"operator": "exact_match", "value": "Homo sapiens"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entry_info.resolution_combined",
"operator": "less", "value": 2.0}},
],
},
"return_type": "entry",
"request_options": {"paginate": {"rows": 10}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
result = r.json()
pdb_ids = [hit["identifier"] for hit in result["result_set"]]
print(f"Total matches: {result['total_count']}, first batch: {pdb_ids}")
# 2. Fetch metadata for the first hit via GraphQL
gql = """{ entry(entry_id: "%s") {
struct { title }
exptl { method }
rcsb_entry_info { resolution_combined deposited_atom_count polymer_entity_count }
} }""" % pdb_ids[0]
r2 = requests.post(DATA, json={"query": gql}, timeout=30)
entry = r2.json()["data"]["entry"]
print(entry["struct"]["title"])
print(f"Method: {entry['exptl'][0]['method']}, Resolution: {entry['rcsb_entry_info']['resolution_combined']} Å")
Core API
Module 1: Text and Attribute Search
Free-text search uses service: "full_text" and searches across all indexed fields.
import requests
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
def text_search(keyword, rows=25):
payload = {
"query": {"type": "terminal", "service": "full_text",
"parameters": {"value": keyword}},
"return_type": "entry",
"request_options": {"paginate": {"rows": rows}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
data = r.json()
return [hit["identifier"] for hit in data["result_set"]], data["total_count"]
ids, total = text_search("hemoglobin")
print(f"Found {total} structures; first batch: {ids[:5]}")
Attribute search uses service: "text" with structured attribute/operator/value parameters.
import requests
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
def attribute_search(attribute, operator, value, return_type="entry", rows=25):
payload = {
"query": {"type": "terminal", "service": "text",
"parameters": {"attribute": attribute,
"operator": operator,
"value": value}},
"return_type": return_type,
"request_options": {"paginate": {"rows": rows}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
return r.json()
# Human proteins
human = attribute_search("rcsb_entity_source_organism.scientific_name",
"exact_match", "Homo sapiens", rows=5)
print(f"Human structures: {human['total_count']}")
# X-ray only
xray = attribute_search("exptl.method", "exact_match", "X-RAY DIFFRACTION", rows=5)
print(f"X-ray structures: {xray['total_count']}")
# Resolution range: 1.5–2.5 Å
res = attribute_search(
"rcsb_entry_info.resolution_combined", "range",
{"from": 1.5, "to": 2.5, "include_lower": True, "include_upper": True},
rows=5
)
print(f"1.5–2.5 Å: {res['total_count']}")
Module 2: Sequence Similarity Search
Find structures with similar sequences using MMseqs2. Service is "sequence"; target selects protein vs. nucleic acid.
import requests
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
kras_seq = ("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGETCLLDILDTAGQ"
"EEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHHYREQIKRVKDSEDVPMVLVGNKCDLPS"
"RTVDTKQAQDLARSYGIPFIETSAKTRQGVDDAFYTLVREIRKHKEKMSK")
payload = {
"query": {
"type": "terminal", "service": "sequence",
"parameters": {
"target": "pdb_protein_sequence", # or "pdb_dna_sequence", "pdb_rna_sequence"
"value": kras_seq,
"evalue_cutoff": 0.1,
"identity_cutoff": 0.9,
},
},
"return_type": "polymer_entity",
"request_options": {"paginate": {"rows": 10}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
data = r.json()
print(f"KRAS-like hits: {data['total_count']}")
for hit in data["result_set"][:5]:
print(f" {hit['identifier']} score={hit.get('score', 'n/a')}")
Module 3: Structure Similarity Search
Find structures with similar 3D geometry using BioZernike descriptors. Service is "structure"; pass the reference entry + assembly ID.
import requests
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
payload = {
"query": {
"type": "terminal", "service": "structure",
"parameters": {
"value": {"entry_id": "4HHB", "assembly_id": "1"},
"operator": "strict_shape_match", # or "relaxed_shape_match"
},
},
"return_type": "polymer_entity",
"request_options": {"paginate": {"rows": 10}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
data = r.json()
print(f"Structurally similar to 4HHB: {data['total_count']}")
for hit in data["result_set"][:5]:
print(f" {hit['identifier']} score={hit.get('score', 'n/a')}")
Module 4: Data Retrieval (GraphQL)
The GraphQL endpoint at data.rcsb.org/graphql is the canonical way to retrieve structured metadata for known PDB IDs. One request can pull fields across the full data hierarchy (entry → polymer_entity → assembly → chem_comp).
import requests
DATA = "https://data.rcsb.org/graphql"
# Entry-level metadata
gql = """{ entry(entry_id: "4HHB") {
struct { title }
exptl { method }
rcsb_entry_info { resolution_combined deposited_atom_count polymer_entity_count nonpolymer_entity_count }
rcsb_accession_info { deposit_date initial_release_date }
} }"""
r = requests.post(DATA, json={"query": gql}, timeout=30)
entry = r.json()["data"]["entry"]
print(f"Title : {entry['struct']['title']}")
print(f"Method : {entry['exptl'][0]['method']}")
print(f"Resolution : {entry['rcsb_entry_info']['resolution_combined']} Å")
print(f"Atoms : {entry['rcsb_entry_info']['deposited_atom_count']}")
# Polymer entity (sequence, organism, MW)
gql = """{ polymer_entity(entry_id: "4HHB", entity_id: "1") {
entity_poly { pdbx_seq_one_letter_code }
rcsb_polymer_entity { formula_weight }
rcsb_entity_source_organism { scientific_name ncbi_taxonomy_id }
} }"""
r = requests.post(DATA, json={"query": gql}, timeout=30)
pe = r.json()["data"]["polymer_entity"]
print(f"Sequence (first 50): {pe['entity_poly']['pdbx_seq_one_letter_code'][:50]}")
print(f"Organism : {pe['rcsb_entity_source_organism'][0]['scientific_name']}")
print(f"MW : {pe['rcsb_polymer_entity']['formula_weight']}")
# Batch: pull metadata for many entries in one request
gql = """{ entries(entry_ids: ["4HHB", "1A3N", "1HHB"]) {
rcsb_id
struct { title }
exptl { method }
rcsb_entry_info { resolution_combined }
} }"""
r = requests.post(DATA, json={"query": gql}, timeout=30)
for e in r.json()["data"]["entries"]:
res = e["rcsb_entry_info"]["resolution_combined"]
print(f" {e['rcsb_id']}: {e['exptl'][0]['method']:<25} {res} Å — {e['struct']['title'][:40]}")
Module 5: File Download
Coordinate files (mmCIF, PDB, FASTA, assembly variants) are served directly from files.rcsb.org.
import requests
def download_structure(pdb_id, fmt="cif", output_dir="."):
"""Download mmCIF / PDB / FASTA. URLs: .pdb, .cif, /fasta/entry/{ID}, .pdb1 (assembly)."""
url = f"https://files.rcsb.org/download/{pdb_id}.{fmt}"
r = requests.get(url, timeout=60)
if r.status_code == 200:
path = f"{output_dir}/{pdb_id}.{fmt}"
# mmCIF / PDB are text; assemblies and biological units are also text
with open(path, "w") as f:
f.write(r.text)
print(f"Downloaded {path} ({len(r.text)/1024:.1f} KB)")
return path
print(f"HTTP {r.status_code} for {pdb_id}.{fmt}")
return None
download_structure("4HHB", fmt="cif")
download_structure("4HHB", fmt="pdb")
# FASTA sequence for an entry
r = requests.get("https://www.rcsb.org/fasta/entry/4HHB", timeout=30)
r.raise_for_status()
print(r.text[:400])
Module 6: Query Composition (group + logical_operator)
Combine terminal queries with type: "group" and a logical_operator of "and" / "or". Nested groups give arbitrary boolean expressions; negation is via "node_id" references with "operator": "negate" on the group (rare — usually expressed as the inverse attribute filter).
import requests, datetime
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
# AND: high-resolution human structures
q_and = {
"type": "group", "logical_operator": "and",
"nodes": [
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entity_source_organism.scientific_name",
"operator": "exact_match", "value": "Homo sapiens"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entry_info.resolution_combined",
"operator": "less", "value": 2.0}},
],
}
# OR: human or mouse
q_or = {
"type": "group", "logical_operator": "or",
"nodes": [
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entity_source_organism.scientific_name",
"operator": "exact_match", "value": "Homo sapiens"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entity_source_organism.scientific_name",
"operator": "exact_match", "value": "Mus musculus"}},
],
}
# Combined: recent (last 30 days) + high-quality
one_month_ago = (datetime.date.today() - datetime.timedelta(days=30)).isoformat()
today = datetime.date.today().isoformat()
q_recent_hq = {
"type": "group", "logical_operator": "and",
"nodes": [
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entry_info.resolution_combined",
"operator": "less", "value": 2.0}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "refine.ls_R_factor_R_free",
"operator": "less", "value": 0.25}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_accession_info.initial_release_date",
"operator": "range",
"value": {"from": one_month_ago, "to": today,
"include_lower": True, "include_upper": True}}},
],
}
payload = {"query": q_recent_hq, "return_type": "entry",
"request_options": {"paginate": {"rows": 5}}}
r = requests.post(SEARCH, json=payload, timeout=30)
print(f"Recent high-quality: {r.json()['total_count']} structures")
Module 7: Pagination + Batch with Rate Limiting
Search responses include total_count. Paginate with request_options.paginate.start and rows (max ~10000 per page in practice; 100–500 is a good batch size).
import requests, time
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
def search_all(query_node, return_type="entry", page=100, max_results=None, delay=0.3):
"""Paginate through every result; rate-limit between pages."""
out, start = [], 0
while True:
payload = {"query": query_node, "return_type": return_type,
"request_options": {"paginate": {"start": start, "rows": page}}}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
d = r.json()
batch = [h["identifier"] for h in d.get("result_set", [])]
if not batch:
break
out.extend(batch)
if max_results and len(out) >= max_results:
return out[:max_results]
if len(batch) < page or len(out) >= d.get("total_count", 0):
break
start += page
time.sleep(delay)
return out
# Example: every insulin entry
ids = search_all(
{"type": "terminal", "service": "full_text", "parameters": {"value": "insulin"}},
max_results=300,
)
print(f"Insulin entries collected: {len(ids)}")
# Batch metadata fetch via GraphQL `entries(...)` to avoid one round-trip per ID
DATA = "https://data.rcsb.org/graphql"
def batch_metadata(pdb_ids, chunk=50):
"""Fetch (title, method, resolution) for many entries with one POST per chunk."""
all_rows = []
for i in range(0, len(pdb_ids), chunk):
ids_arr = pdb_ids[i:i+chunk]
ids_str = ", ".join(f'"{p}"' for p in ids_arr)
gql = f"""{{ entries(entry_ids: [{ids_str}]) {{
rcsb_id
struct {{ title }}
exptl {{ method }}
rcsb_entry_info {{ resolution_combined }}
}} }}"""
r = requests.post(DATA, json={"query": gql}, timeout=60)
r.raise_for_status()
for e in r.json()["data"]["entries"]:
res = e["rcsb_entry_info"]["resolution_combined"]
all_rows.append({
"pdb_id": e["rcsb_id"],
"method": e["exptl"][0]["method"] if e["exptl"] else None,
"resolution": res[0] if isinstance(res, list) and res else res,
"title": e["struct"]["title"],
})
return all_rows
rows = batch_metadata(ids[:20])
for r in rows[:5]:
print(f" {r['pdb_id']}: {r['method']:<25} {r['resolution']} Å — {r['title'][:50]}")
Key Concepts
Search Service Cheat Sheet
| Service | Use case | Required parameters |
|---|---|---|
full_text |
Free-text keyword across all indexed fields | value (string) |
text |
Structured attribute filter | attribute, operator, value |
sequence |
MMseqs2 sequence similarity | target ∈ {pdb_protein_sequence, pdb_dna_sequence, pdb_rna_sequence}, value (sequence), evalue_cutoff, identity_cutoff |
seqmotif |
Pattern / regex / PROSITE motif | value (pattern), pattern_type ∈ {simple, prosite, regex} |
structure |
3D shape similarity (BioZernike) | value ({entry_id, assembly_id}), operator ∈ {strict_shape_match, relaxed_shape_match} |
strucmotif |
3D residue-arrangement motif | value (residue list), rmsd_cutoff |
chemical |
Ligand similarity by SMILES/InChI | value, match_type ∈ {graph-exact, graph-relaxed, fingerprint-similarity, sub-structure-stereo-relaxed} |
AttributeQuery Operators (service: "text")
| Operator | Value shape | Example |
|---|---|---|
exact_match |
string | "Homo sapiens" |
contains_words / contains_phrase |
string | "tyrosine kinase" |
equals / greater / less / greater_or_equal / less_or_equal |
number | 2.0 |
range |
{from, to, include_lower, include_upper} |
{"from": 1.5, "to": 2.5, "include_lower": True, "include_upper": True} |
exists |
(none) | — |
in |
array | ["X-RAY DIFFRACTION", "ELECTRON MICROSCOPY"] |
Return Types
return_type controls the granularity of identifiers in result_set:
return_type |
Identifier shape | Example |
|---|---|---|
entry |
4HHB |
One per PDB ID |
polymer_entity |
4HHB_1 |
One per polymer chain entity |
non_polymer_entity |
4HHB_2 |
Ligands, cofactors |
assembly |
4HHB-1 |
Biological unit |
polymer_instance |
4HHB.A |
Individual chain coordinates |
mol_definition |
HEM |
Chemical component (PDB ligand code) |
Common Data API GraphQL Roots
| Root | Identifier shape | Returns |
|---|---|---|
entry(entry_id: ...) |
"4HHB" |
Entry-level metadata |
entries(entry_ids: [...]) |
array | Batch entry lookup |
polymer_entity(entry_id: ..., entity_id: ...) |
"4HHB", "1" |
Sequence + organism |
polymer_entity_instance(entry_id: ..., asym_id: ...) |
"4HHB", "A" |
Chain-level coords/metadata |
assembly(entry_id: ..., assembly_id: ...) |
"4HHB", "1" |
Biological assembly |
chem_comp(comp_id: ...) |
"HEM" |
Small molecule reference |
File Formats
| Format | URL pattern | Notes |
|---|---|---|
| mmCIF | https://files.rcsb.org/download/{id}.cif |
Recommended; no atom-count limit |
| PDB | https://files.rcsb.org/download/{id}.pdb |
Legacy; 99,999 atom limit |
| Assembly (mmCIF) | https://files.rcsb.org/download/{id}-assembly{N}.cif |
Biological unit |
| FASTA | https://www.rcsb.org/fasta/entry/{id} |
Sequence only |
Common Workflows
Workflow 1: Drug Target Structure Set
Goal: Find high-resolution human EGFR structures with bound ligands.
import requests, time
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
DATA = "https://data.rcsb.org/graphql"
payload = {
"query": {
"type": "group", "logical_operator": "and",
"nodes": [
{"type": "terminal", "service": "full_text",
"parameters": {"value": "EGFR epidermal growth factor receptor"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entity_source_organism.scientific_name",
"operator": "exact_match", "value": "Homo sapiens"}},
{"type": "terminal", "service": "text",
"parameters": {"attribute": "rcsb_entry_info.resolution_combined",
"operator": "less", "value": 2.5}},
],
},
"return_type": "entry",
"request_options": {"paginate": {"rows": 50}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
r.raise_for_status()
pdb_ids = [h["identifier"] for h in r.json()["result_set"]]
print(f"EGFR ≤2.5 Å human structures: {len(pdb_ids)}")
# Filter to entries with bound ligands via batch GraphQL
ids_str = ", ".join(f'"{p}"' for p in pdb_ids[:20])
gql = f"""{{ entries(entry_ids: [{ids_str}]) {{
rcsb_id
struct {{ title }}
rcsb_entry_info {{ resolution_combined nonpolymer_entity_count }}
}} }}"""
r2 = requests.post(DATA, json={"query": gql}, timeout=60)
for e in r2.json()["data"]["entries"]:
n_lig = e["rcsb_entry_info"]["nonpolymer_entity_count"] or 0
if n_lig > 0:
res = e["rcsb_entry_info"]["resolution_combined"]
res_v = res[0] if isinstance(res, list) else res
print(f" {e['rcsb_id']}: {res_v} Å, ligands={n_lig} — {e['struct']['title'][:60]}")
time.sleep(0.05)
Workflow 2: Protein Family — Sequence-Similar Structures
Goal: Find all PDB structures with sequence similar to a query (KRAS), then summarize their resolution + experimental method.
import requests, time
SEARCH = "https://search.rcsb.org/rcsbsearch/v2/query"
DATA = "https://data.rcsb.org/graphql"
kras_seq = ("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGETCLLDILDTAGQ"
"EEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHHYREQIKRVKDSEDVPMVLVGNKCDLPS"
"RTVDTKQAQDLARSYGIPFIETSAKTRQGVDDAFYTLVREIRKHKEKMSK")
payload = {
"query": {
"type": "terminal", "service": "sequence",
"parameters": {"target": "pdb_protein_sequence", "value": kras_seq,
"evalue_cutoff": 1e-5, "identity_cutoff": 0.5},
},
"return_type": "polymer_entity",
"request_options": {"paginate": {"rows": 20}},
}
r = requests.post(SEARCH, json=payload, timeout=30)
hits = r.json()["result_set"]
print(f"KRAS family hits: {len(hits)}")
# Unique PDB IDs from polymer_entity identifiers (e.g., "4OBE_1" -> "4OBE")
entry_ids = sorted({h["identifier"].split("_")[0] for h in hits})
# Batch metadata
ids_str = ", ".join(f'"{p}"' for p in entry_ids)
gql = f"""{{ entries(entry_ids: [{ids_str}]) {{
rcsb_id
struct {{ title }}
exptl {{ method }}
rcsb_entry_info {{ resolution_combined }}
}} }}"""
r2 = requests.post(DATA, json={"query": gql}, timeout=60)
for e in sorted(r2.json()["data"]["entries"],
key=lambda x: (x["rcsb_entry_info"]["resolution_combined"] or [99])[0] if isinstance(x["rcsb_entry_info"]["resolution_combined"], list) else (x["rcsb_entry_info"]["resolution_combined"] or 99)):
res = e["rcsb_entry_info"]["resolution_combined"]
res_v = res[0] if isinstance(res, list) else res
print(f" {e['rcsb_id']}: {res_v} Å {e['exptl'][0]['method']:<25} {e['struct']['title'][:50]}")
Workflow 3: Download + Parse with BioPython
Goal: Download mmCIF, then enumerate chains with BioPython.
import requests
from Bio.PDB import MMCIFParser
pdb_id = "4HHB"
r = requests.get(f"https://files.rcsb.org/download/{pdb_id}.cif", timeout=60)
r.raise_for_status()
with open(f"{pdb_id}.cif", "w") as f:
f.write(r.text)
parser = MMCIFParser(QUIET=True)
structure = parser.get_structure(pdb_id, f"{pdb_id}.cif")
for model in structure:
for chain in model:
std_res = [r for r in chain if r.id[0] == " "]
atoms = sum(len(list(r.get_atoms())) for r in std_res)
print(f"Chain {chain.id}: {len(std_res)} residues, {atoms} atoms")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
value |
search sequence |
required | protein/DNA/RNA sequence string | Query sequence for MMseqs2 |
evalue_cutoff |
search sequence |
0.1 |
1e-10–10 |
E-value threshold |
identity_cutoff |
search sequence |
0.9 |
0.0–1.0 |
Minimum identity fraction |
target |
search sequence |
"pdb_protein_sequence" |
pdb_protein_sequence, pdb_dna_sequence, pdb_rna_sequence |
Sequence type |
operator |
search text (attribute) |
required | see Attribute Operators | Comparison kind |
operator |
search structure |
strict_shape_match |
strict_shape_match, relaxed_shape_match |
3D match stringency |
return_type |
all search | entry |
entry, polymer_entity, assembly, polymer_instance, mol_definition, … |
Identifier granularity |
paginate.start / paginate.rows |
request_options |
0 / 25 |
up to ~10000 rows/page in practice | Pagination window |
GraphQL field entries(entry_ids: [...]) |
data.rcsb.org/graphql |
— | array of PDB IDs | Batch entry metadata |
Best Practices
-
Search → fetch: Use the Search API to get a list of IDs, then GraphQL
entries(entry_ids: [...])for batch metadata. Avoid one GraphQL request per ID. -
Use
full_textvstextdeliberately: free-text keyword search needs"service": "full_text". Structured attribute filters need"service": "text". They are not interchangeable. -
mmCIF over PDB format: PDB format is being phased out and has a 99,999 atom limit. Always download
.ciffor new code. -
Set realistic
paginate.rows:rows: 100is a good default for batch work; the API may slow down beyond ~10000. Loop withpaginate.startfor full traversal. -
Rate limit with
time.sleep(0.2)in batch loops: No published hard cap, but the public infrastructure is shared. OnHTTP 429, back off exponentially. -
Inspect the payload before posting:
print(json.dumps(payload, indent=2))is the cheapest way to debugHTTP 400errors. -
entries(entry_ids: [...])does not validate every ID: if one ID is wrong, the whole array returnsnullentries. Validate IDs separately if you can't trust the source.
Common Recipes
Recipe: Get FASTA for a PDB ID
import requests
r = requests.get("https://www.rcsb.org/fasta/entry/4HHB", timeout=30)
print(r.text)
Recipe: All chains in an entry
import requests
DATA = "https://data.rcsb.org/graphql"
gql = """{ entry(entry_id: "4HHB") {
polymer_entities {
rcsb_id
rcsb_polymer_entity_container_identifiers { auth_asym_ids }
entity_poly { rcsb_entity_polymer_type pdbx_seq_one_letter_code_can }
}
} }"""
r = requests.post(DATA, json={"query": gql}, timeout=30)
for pe in r.json()["data"]["entry"]["polymer_entities"]:
chains = pe["rcsb_polymer_entity_container_identifiers"]["auth_asym_ids"]
seq = pe["entity_poly"]["pdbx_seq_one_letter_code_can"][:50]
print(f" {pe['rcsb_id']} chains={chains} type={pe['entity_poly']['rcsb_entity_polymer_type']} seq={seq}…")
Recipe: List all ligands in an entry
import requests
DATA = "https://data.rcsb.org/graphql"
gql = """{ entry(entry_id: "1IEP") {
nonpolymer_entities {
rcsb_id
nonpolymer_comp { chem_comp { id name formula } }
}
} }"""
r = requests.post(DATA, json={"query": gql}, timeout=30)
for npe in r.json()["data"]["entry"]["nonpolymer_entities"]:
cc = npe["nonpolymer_comp"]["chem_comp"]
print(f" {npe['rcsb_id']}: {cc['id']} ({cc['name']}) {cc['formula']}")
Recipe: Inspect available search attributes
The Search API exposes a JSON schema at https://search.rcsb.org/rcsbsearch/v2/metadata/schema. Use it to look up valid attribute paths.
import requests
r = requests.get("https://search.rcsb.org/rcsbsearch/v2/metadata/schema", timeout=30)
schema = r.json()
# Schema lists hundreds of attribute paths; sample a few
sample_paths = [k for k in schema if "resolution" in k.lower()][:5]
print(sample_paths)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 400 — Invalid request to the [ text ] service on a free-text query |
Wrong service name | Use "service": "full_text" for keyword search; "service": "text" is for structured attribute filters |
HTTP 400 with cryptic schema message |
Bad operator/value shape | Check the AttributeQuery Operators table; range needs the {from,to,include_lower,include_upper} dict |
Empty result_set |
Filters too strict | Relax filters one at a time; verify attribute names via the schema endpoint |
HTTP 404 on entries(entry_ids: ["XYZW"]) |
The entry doesn't exist | RCSB returns null rather than 404 inside the GraphQL response — check each data.entries[i] for null |
HTTP 429 Too Many Requests |
Burst pace | Add time.sleep(0.3) between requests; exponential backoff on 429 |
HTTP 500 from search |
Server-side glitch | Retry after 5–10 s; check status.rcsb.org |
Downloaded .pdb file truncated |
>99,999 atoms (legacy format limit) | Download .cif instead |
GraphQL response has errors array |
Field name typo or wrong root | Read the error message; the API is strict about field names — check the schema browser at https://data.rcsb.org/index.html#graphql-api |
Related Skills
- alphafold-database-access — AI-predicted structures; use when no experimental structure exists
- uniprot-protein-database — protein annotations, sequences, ID mapping (UniProt accession needed for AlphaFold)
- biopython-molecular-biology — parse downloaded PDB/mmCIF files, extract coordinates, compute distances
- autodock-vina-docking — downstream molecular docking using PDB structures as receptors
- rdkit-cheminformatics — analyze ligands extracted from PDB complexes
References
- RCSB PDB — main portal and web search
- Search API v2 docs — full JSON-payload reference and Swagger
- Data API GraphQL browser — schema explorer
- Search API attribute schema — JSON of every searchable attribute
- RCSB PDB Web APIs overview — index of all programmatic surfaces
- For SDK-based usage, see the
rcsb-apiPyPI package; this SKILL.md uses the underlying REST/GraphQL directly so no SDK install is needed.
skills/structural-biology-drug-discovery/pubchem-compound-search/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pubchem-compound-search -g -y
SKILL.md
Frontmatter
{
"name": "pubchem-compound-search",
"license": "CC-BY-4.0",
"description": "Query PubChem (110M+ compounds) directly via the PUG-REST\/JSON API with plain `requests` — no SDK install required. Search by name\/CID\/SMILES\/InChIKey\/formula, retrieve properties (MW, XLogP, TPSA, H-bond counts), do similarity\/substructure searches with async ListKey polling, fetch synonyms, descriptions, assay summaries, and download SDF\/PNG. For local cheminformatics use rdkit; for bioactivity-centric workflows use chembl-database-bioactivity."
}
PubChem Compound Search
Overview
PubChem (NCBI) is the largest freely available chemical database — 110M+ compounds, 280M+ substances, and millions of bioassay records. Its PUG-REST JSON API is the canonical programmatic surface, and every example here uses it directly via plain requests. The Python pubchempy wrapper is not required; the PUG-REST URL grammar is small enough that direct calls are more transparent, easier to retry/cache, and avoid sandbox dependency issues (the library is not in TOOL_STATUS.md).
The URL pattern is fixed and predictable:
https://pubchem.ncbi.nlm.nih.gov/rest/pug/<input>/<operation>/<output>
<input>=compound/{name,cid,smiles,inchikey,formula}/<value><operation>=cids,property/<list>,synonyms,description,assaysummary,JSON(full record),SDF,PNG<output>=JSON,CSV,TXT,SDF,PNG
For long-running operations (similarity, substructure, formula) the API returns HTTP 202 + {"Waiting": {"ListKey": "..."}}; poll compound/listkey/{key}/cids/JSON until it returns IdentifierList. The skill handles this pattern in Module 4.
When to Use
- Looking up a compound by name, SMILES, InChIKey, or formula to get its PubChem CID
- Retrieving molecular properties (molecular weight, XLogP, TPSA, H-bond donor/acceptor counts, rotatable bonds, formula, IUPAC name) for one or many CIDs in a single request
- Finding structurally similar compounds via Tanimoto similarity (async ListKey poll)
- Searching for compounds containing a substructure / pharmacophore motif
- Fetching every synonym / trade name / CAS number for a CID
- Pulling assay summary tables (active/inactive screening results) for a compound
- Converting between identifier formats (name ↔ CID ↔ SMILES ↔ InChI ↔ InChIKey) in one API call
- Downloading 2D SDF or PNG structures for figures or downstream RDKit work
- For local cheminformatics (fingerprints, descriptors, 3D conformers, scaffold extraction) use
rdkit - For deeper bioactivity / target-binding data (IC50, Ki against specific targets) use
chembl-database-bioactivity
Prerequisites
- Python packages:
requests,pandas— both already in standard environments - No API key required — PubChem is fully public
- Rate limits: max 5 requests/second and 400 requests/minute per IP. Throttle with
time.sleep(0.25)in loops; return code 503 means you tripped the limit.
If you are inside a pixi/conda environment that already provides requests and pandas, skip the install and invoke scripts with pixi run python ....
pip install requests pandas
Quick Start
import requests
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
# name → CID
cid = requests.get(f"{BASE}/compound/name/aspirin/cids/JSON").json()["IdentifierList"]["CID"][0]
# CID → properties (single call, many fields)
r = requests.get(
f"{BASE}/compound/cid/{cid}/property/"
"MolecularWeight,XLogP,TPSA,HBondDonorCount,HBondAcceptorCount,SMILES,IUPACName/JSON")
p = r.json()["PropertyTable"]["Properties"][0]
print(f"CID {cid} — {p['IUPACName']}")
print(f" MW={p['MolecularWeight']} XLogP={p['XLogP']} TPSA={p['TPSA']}")
print(f" HBD={p['HBondDonorCount']} HBA={p['HBondAcceptorCount']}")
print(f" SMILES={p['SMILES']}")
Core API
Module 1: Identifier lookup
Resolve any external identifier to a PubChem CID via /compound/{namespace}/{value}/cids/JSON. Namespaces: name, cid, smiles, inchikey, inchi, formula.
import requests
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
# By name (returns all matching CIDs as a list)
cids = requests.get(f"{BASE}/compound/name/caffeine/cids/JSON").json()["IdentifierList"]["CID"]
print(f"caffeine CIDs: {cids}")
# By canonical SMILES (URL-encode!)
smi = quote("CC(=O)OC1=CC=CC=C1C(=O)O", safe="")
cid = requests.get(f"{BASE}/compound/smiles/{smi}/cids/JSON").json()["IdentifierList"]["CID"][0]
print(f"aspirin SMILES → CID {cid}")
# By InChIKey (exact match, fastest if you already have one)
ikey = "BSYNRYMUTXBXSQ-UHFFFAOYSA-N"
cid = requests.get(f"{BASE}/compound/inchikey/{ikey}/cids/JSON").json()["IdentifierList"]["CID"][0]
print(f"InChIKey → CID {cid}")
Module 2: Property retrieval
/compound/cid/{cid_or_csv}/property/<csv-list>/JSON returns all requested properties in one round trip. CIDs and property names are both CSV-joinable — batch up to ~200 CIDs and many properties at once.
import requests
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
# Full property set for a single compound (ibuprofen CID 3672)
url = (f"{BASE}/compound/cid/3672/property/"
"MolecularWeight,XLogP,TPSA,HBondDonorCount,HBondAcceptorCount,"
"RotatableBondCount,SMILES,InChIKey,IUPACName,MolecularFormula/JSON")
p = requests.get(url).json()["PropertyTable"]["Properties"][0]
print(f"{p['IUPACName']} formula={p['MolecularFormula']}")
print(f" MW={p['MolecularWeight']} XLogP={p['XLogP']} TPSA={p['TPSA']}")
print(f" HBD={p['HBondDonorCount']} HBA={p['HBondAcceptorCount']} RotB={p['RotatableBondCount']}")
import requests, pandas as pd
# Batch: 4 CIDs, 3 properties — one request, one round trip
cids = "2244,3672,2157,2662" # aspirin, ibuprofen, naproxen, celecoxib
r = requests.get(
f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{cids}/property/"
"MolecularWeight,XLogP,TPSA/JSON")
df = pd.DataFrame(r.json()["PropertyTable"]["Properties"])
print(df.to_string(index=False))
Module 3: Synonyms and description
Synonyms (trade names, CAS numbers, alternative spellings) and curated descriptions live at /compound/{ns}/{value}/{synonyms|description}/JSON.
import requests
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
# Full synonym list (aspirin has ~700)
info = requests.get(f"{BASE}/compound/cid/2244/synonyms/JSON").json()["InformationList"]["Information"][0]
print(f"aspirin synonyms: {len(info['Synonym'])}")
for s in info["Synonym"][:8]:
print(f" {s}")
import requests
# Curated descriptions (NCBI MeSH, CAMEO, etc.)
r = requests.get("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/aspirin/description/JSON")
for item in r.json()["InformationList"]["Information"]:
if "Description" in item:
print(f"[{item.get('DescriptionSourceName','?')}]")
print(f" {item['Description'][:200]}…")
print()
Module 4: Similarity & substructure search (async ListKey pattern)
Structure searches return HTTP 202 + {"Waiting": {"ListKey": "..."}}. Poll /compound/listkey/{key}/cids/JSON every ~2s until it returns IdentifierList. Wrap this in a helper since it's used everywhere.
import requests, time
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
def poll_listkey(listkey, max_polls=10, interval=2.0):
"""Block until PubChem finishes async search; return CID list."""
for _ in range(max_polls):
time.sleep(interval)
j = requests.get(f"{BASE}/compound/listkey/{listkey}/cids/JSON", timeout=20).json()
if "IdentifierList" in j:
return j["IdentifierList"]["CID"]
raise TimeoutError(f"ListKey {listkey} did not complete")
# Tanimoto similarity (90% threshold, max 20 hits) — starting from aspirin SMILES
smi = quote("CC(=O)OC1=CC=CC=C1C(=O)O", safe="")
init = requests.get(
f"{BASE}/compound/similarity/smiles/{smi}/JSON?Threshold=90&MaxRecords=20").json()
cids = poll_listkey(init["Waiting"]["ListKey"]) if "Waiting" in init \
else init["IdentifierList"]["CID"]
print(f"aspirin @90% similarity: {len(cids)} hits, sample={cids[:5]}")
import requests
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
# Substructure search — all compounds containing a sulfonamide group
smi = quote("S(=O)(=O)N", safe="")
init = requests.get(
f"{BASE}/compound/substructure/smiles/{smi}/JSON?MaxRecords=20").json()
cids = poll_listkey(init["Waiting"]["ListKey"]) if "Waiting" in init \
else init["IdentifierList"]["CID"]
print(f"sulfonamide-containing CIDs: {len(cids)}, sample={cids[:5]}")
Module 5: Assay summary (bioactivity data)
/compound/cid/{cid}/assaysummary/JSON returns a Table of every PubChem BioAssay the compound appears in (assay AID, target, outcome, micromolar activity if available).
import requests, pandas as pd
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
r = requests.get(f"{BASE}/compound/cid/2244/assaysummary/JSON", timeout=30)
rows = r.json().get("Table", {}).get("Row", [])
cols = r.json().get("Table", {}).get("Columns", {}).get("Column", [])
print(f"aspirin appears in {len(rows)} bioassays")
# First few columns + rows as a DataFrame
df = pd.DataFrame([row["Cell"] for row in rows[:5]], columns=cols)
print(df.iloc[:, :6].to_string(index=False))
Module 6: Structure file download (SDF / PNG)
/compound/cid/{cid}/SDF returns 2D MOL/SDF; /compound/cid/{cid}/PNG returns a structure image (use ?image_size=large for higher resolution).
import requests
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
cid = 2519 # caffeine
# 2D SDF for downstream RDKit / OpenBabel
sdf = requests.get(f"{BASE}/compound/cid/{cid}/SDF", timeout=15).text
with open("caffeine.sdf", "w") as f:
f.write(sdf)
print(f"caffeine.sdf: {len(sdf)} chars, ends with M END={'M END' in sdf}")
# PNG structure image
png = requests.get(f"{BASE}/compound/cid/{cid}/PNG?image_size=large", timeout=15).content
with open("caffeine.png", "wb") as f:
f.write(png)
print(f"caffeine.png: {len(png)} bytes")
Key Concepts
Async ListKey pattern
Similarity, substructure, and formula searches are asynchronous — the API kicks off a background job and returns HTTP 202 with {"Waiting": {"ListKey": "<id>"}}. Poll /compound/listkey/{id}/cids/JSON every ~2 seconds until the response contains IdentifierList. Most searches finish in 5–15s; tighten polling for tiny searches, loosen for very large ones. Use the poll_listkey helper from Module 4 everywhere.
A small fraction of fast searches return IdentifierList directly on the first call (no Waiting field); check for both possibilities.
Property name reference
| API name | Meaning |
|---|---|
MolecularWeight |
Molecular weight (g/mol, string) |
MolecularFormula |
Hill-system formula |
SMILES |
Isomeric SMILES, with stereochemistry (2025+ name; was IsomericSMILES) |
ConnectivitySMILES |
Connectivity-only SMILES, no stereo (2025+ name; was CanonicalSMILES) |
IUPACName |
Curated IUPAC name |
InChI / InChIKey |
IUPAC InChI / InChIKey |
XLogP |
Computed logP (octanol/water) |
TPSA |
Topological polar surface area (Ų) |
HBondDonorCount |
Number of H-bond donors |
HBondAcceptorCount |
Number of H-bond acceptors |
RotatableBondCount |
Number of rotatable bonds |
HeavyAtomCount |
Non-hydrogen atom count |
Charge |
Formal charge |
CSV-join any subset in a single /property/<csv>/JSON URL. Note that MolecularWeight returns as a string; cast to float before arithmetic.
Response envelope shapes
cids/JSON→{"IdentifierList": {"CID": [int, ...]}}property/.../JSON→{"PropertyTable": {"Properties": [{...}, ...]}}(one dict per CID, in input order)synonyms/JSON→{"InformationList": {"Information": [{"CID": int, "Synonym": [str, ...]}]}}description/JSON→{"InformationList": {"Information": [{"CID": int, "Description": str, "DescriptionSourceName": str, ...}, ...]}}assaysummary/JSON→{"Table": {"Columns": {"Column": [...]}, "Row": [{"Cell": [...]}, ...]}}- async-init (similarity, substructure, formula) → 202 with
{"Waiting": {"ListKey": "..."}} - listkey poll →
{"IdentifierList": {"CID": [...]}}when ready
Common Workflows
Workflow 1: Property comparison across a drug panel
Goal: side-by-side physicochemical comparison of a small molecule set.
import requests, pandas as pd, time
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
drugs = ["aspirin", "ibuprofen", "naproxen", "celecoxib"]
# Resolve names → CIDs in a loop (rate-limited)
cids = []
for d in drugs:
cid = requests.get(f"{BASE}/compound/name/{quote(d)}/cids/JSON",
timeout=15).json()["IdentifierList"]["CID"][0]
cids.append(cid)
time.sleep(0.25)
# Single batched property pull
cid_csv = ",".join(str(c) for c in cids)
r = requests.get(
f"{BASE}/compound/cid/{cid_csv}/property/"
"MolecularWeight,XLogP,TPSA,HBondDonorCount,HBondAcceptorCount/JSON")
df = pd.DataFrame(r.json()["PropertyTable"]["Properties"])
df["Name"] = drugs
df = df[["Name", "CID", "MolecularWeight", "XLogP", "TPSA",
"HBondDonorCount", "HBondAcceptorCount"]]
print(df.to_string(index=False))
Workflow 2: Lead compound → similar analogs → properties
Goal: starting from a kinase inhibitor (gefitinib), find 85%-similar analogs and pull their properties.
import requests, time
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
def poll_listkey(listkey, max_polls=10, interval=2.0):
for _ in range(max_polls):
time.sleep(interval)
j = requests.get(f"{BASE}/compound/listkey/{listkey}/cids/JSON",
timeout=20).json()
if "IdentifierList" in j:
return j["IdentifierList"]["CID"]
raise TimeoutError("listkey timeout")
# 1. lead → CID → canonical SMILES
ref_cid = requests.get(
f"{BASE}/compound/name/gefitinib/cids/JSON").json()["IdentifierList"]["CID"][0]
ref_smi = requests.get(
f"{BASE}/compound/cid/{ref_cid}/property/SMILES/JSON"
).json()["PropertyTable"]["Properties"][0]["SMILES"]
print(f"gefitinib CID={ref_cid} SMILES={ref_smi}")
# 2. similarity search
smi_q = quote(ref_smi, safe="")
init = requests.get(
f"{BASE}/compound/similarity/smiles/{smi_q}/JSON?Threshold=85&MaxRecords=15"
).json()
sim_cids = poll_listkey(init["Waiting"]["ListKey"]) if "Waiting" in init \
else init["IdentifierList"]["CID"]
print(f" {len(sim_cids)} analogs @85% Tanimoto")
# 3. batch-pull properties for top 5 analogs
cid_csv = ",".join(str(c) for c in sim_cids[:5])
r = requests.get(
f"{BASE}/compound/cid/{cid_csv}/property/"
"MolecularWeight,XLogP,TPSA,RotatableBondCount/JSON")
for row in r.json()["PropertyTable"]["Properties"]:
print(f" CID {row['CID']}: MW={row['MolecularWeight']} XLogP={row['XLogP']}")
Workflow 3: Pharmacophore screen via substructure → bioactivity check
Goal: find compounds with a sulfonamide motif and check which have bioactivity records.
import requests, time
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
def poll_listkey(listkey, max_polls=10, interval=2.0):
for _ in range(max_polls):
time.sleep(interval)
j = requests.get(f"{BASE}/compound/listkey/{listkey}/cids/JSON",
timeout=20).json()
if "IdentifierList" in j:
return j["IdentifierList"]["CID"]
raise TimeoutError("listkey timeout")
smi = quote("S(=O)(=O)N", safe="")
init = requests.get(
f"{BASE}/compound/substructure/smiles/{smi}/JSON?MaxRecords=10").json()
cids = poll_listkey(init["Waiting"]["ListKey"]) if "Waiting" in init \
else init["IdentifierList"]["CID"]
print(f"sulfonamide CIDs: {cids}")
# Bioactivity row counts for the first few hits
for cid in cids[:3]:
rows = requests.get(f"{BASE}/compound/cid/{cid}/assaysummary/JSON",
timeout=30).json().get("Table", {}).get("Row", [])
print(f" CID {cid}: {len(rows)} assay rows")
time.sleep(0.3)
Key Parameters
| Parameter | Endpoint / Module | Default | Range / Options | Effect |
|---|---|---|---|---|
<namespace> |
/compound/{ns}/<value>/... |
— | name, cid, smiles, inchikey, inchi, formula |
Input identifier type |
<property csv> |
/.../property/<csv>/JSON |
— | any subset of property names (see table) | Which properties to return (one DB call per request) |
Threshold |
similarity (M4) | 90 |
0–100 |
Tanimoto cutoff (percent) |
MaxRecords |
similarity / substructure / formula | (server-side default) | 1–10000 |
Cap on async result list |
image_size |
/compound/cid/{cid}/PNG |
medium | small, large, WxH (e.g. 500x500) |
PNG output resolution |
record_type |
/compound/cid/{cid}/SDF |
2d |
2d, 3d |
SDF dimensionality (?record_type=3d) |
MaxAssayResults |
/compound/cid/{cid}/assaysummary/JSON |
— | int | Limit assay rows when compound has thousands of records |
Best Practices
-
Always go through
cids/JSONfirst when starting from a name or external identifier. The name→CID resolution and the CID→property lookup are separate calls; doing both at once vianame → propertyworks but throws away the canonical CID list that downstream queries need. -
Batch properties, never loop them.
compound/cid/2244,3672,2157,.../property/MolecularWeight,XLogP,.../JSONaccepts up to ~200 CIDs and any number of properties — one round trip instead of N. Loopingget_compoundsper name is the most common rate-limit trap. -
URL-encode every SMILES. Use
urllib.parse.quote(smiles, safe=""). Bare SMILES with=,#,(,),[,]will sometimes work but breaks unpredictably on+,/,\, or query-string-looking substrings. -
Treat similarity/substructure/formula as async. Branch on
"Waiting" in responseand polllistkeyrather than re-issuing the search. Re-issuing creates a new ListKey and wastes the server's job slot. -
Throttle ≤ 5 req/sec, ≤ 400/min. Insert
time.sleep(0.25)in any tight loop. HTTP 503 means you tripped the limit — wait 10s and reduce concurrency. -
Cast
MolecularWeighttofloat. It's returned as a string ("180.16") for full decimal fidelity. Comparing strings against numeric thresholds is a silent bug. -
For 100+ CIDs use POST. GET URLs over ~2000 chars get truncated by some HTTP proxies. PubChem also accepts
POSTwithcidin the form body:requests.post(f"{BASE}/compound/cid/property/MolecularWeight/JSON", data={"cid": cid_csv}). -
2025 SMILES property rename. PubChem renamed two SMILES properties in the 2025 PUG-REST schema: old
IsomericSMILES(with stereo) →SMILES, and oldCanonicalSMILES(connectivity only, no stereo) →ConnectivitySMILES. The URL path still accepts the legacy names as input (e.g./property/CanonicalSMILES/JSONreturns 200), but the response JSON is keyed with the new names. So a request succeeds and only the parse step breaks withKeyError. UseSMILES/ConnectivitySMILESin new code and read those keys.
Common Recipes
Recipe 1 — Lipinski's Rule of Five check
import requests
from urllib.parse import quote
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
def check_lipinski(name):
cid = requests.get(f"{BASE}/compound/name/{quote(name)}/cids/JSON"
).json()["IdentifierList"]["CID"][0]
p = requests.get(
f"{BASE}/compound/cid/{cid}/property/"
"MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount/JSON"
).json()["PropertyTable"]["Properties"][0]
mw, xlogp = float(p["MolecularWeight"]), p.get("XLogP", 0) or 0
hbd, hba = p["HBondDonorCount"], p["HBondAcceptorCount"]
rules = {"MW ≤ 500": mw <= 500, "XLogP ≤ 5": xlogp <= 5,
"HBD ≤ 5": hbd <= 5, "HBA ≤ 10": hba <= 10}
v = sum(1 for ok in rules.values() if not ok)
return rules, v
rules, v = check_lipinski("metformin")
print(f"violations: {v}/4 ({'PASS' if v <= 1 else 'FAIL'})")
for r, ok in rules.items(): print(f" {'✓' if ok else '✗'} {r}")
Recipe 2 — Pull every synonym / trade name
import requests
r = requests.get("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/aspirin/synonyms/JSON")
syns = r.json()["InformationList"]["Information"][0]["Synonym"]
print(f"{len(syns)} synonyms")
for s in syns[:10]:
print(f" {s}")
Recipe 3 — Download a structure PNG
import requests
cid = 2519 # caffeine
png = requests.get(
f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{cid}/PNG?image_size=large"
).content
with open("caffeine.png", "wb") as f:
f.write(png)
print(f"wrote caffeine.png ({len(png)} bytes)")
Recipe 4 — Robust session with retry / 429 handling
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
s.headers.update({"Accept": "application/json"})
s.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"])))
r = s.get(
"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/2244/property/MolecularWeight/JSON",
timeout=15)
r.raise_for_status()
print(r.json()["PropertyTable"]["Properties"][0]["MolecularWeight"])
Expected Outputs
- CID lookup (
/.../cids/JSON):{"IdentifierList": {"CID": [...]}}— list of integer CIDs, ordered by relevance for name searches. - Properties (
/.../property/.../JSON):{"PropertyTable": {"Properties": [{"CID": ..., "MolecularWeight": "180.16", ...}, ...]}}. One row per CID in input order;MolecularWeightis a string. - Synonyms:
{"InformationList": {"Information": [{"CID": 2244, "Synonym": ["aspirin", "ACETYLSALICYLIC ACID", "50-78-2", ...]}]}}. - Async search init: HTTP 202 with
{"Waiting": {"ListKey": "12345..."}}. Poll/compound/listkey/{key}/cids/JSONuntil it returnsIdentifierList. - Assay summary:
{"Table": {"Columns": {"Column": [...col names...]}, "Row": [{"Cell": [...]}, ...]}}. - SDF: plain text starting with the CID header line; ends with
M ENDfollowed by SDF property blocks. - PNG: binary
image/png, ~2–4 KB at default size, ~10–20 KB atimage_size=large.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 PUGREST.NotFound |
Name / SMILES / formula matched no record | Try a CAS number or InChIKey; check spelling in the PubChem web UI; canonical SMILES from RDKit often resolves where input SMILES doesn't |
HTTP 202 stuck in {"Waiting":...} for a similarity/substructure call |
Async job still running | Poll /compound/listkey/{key}/cids/JSON every 2s up to ~30s; reduce MaxRecords if it never completes |
HTTP 503 PUGREST.ServerBusy |
Tripped the 5-req/s or 400-req/min rate limit | Insert time.sleep(0.25) in loops; use the Retry session in Recipe 4; reduce concurrency |
| HTTP 400 on a SMILES URL | SMILES wasn't URL-encoded | Wrap in urllib.parse.quote(smi, safe="") — #, +, / and \ all break path parsing |
KeyError: 'CanonicalSMILES' / KeyError: 'IsomericSMILES' |
Requested old name; URL returns 200 but 2025 JSON is keyed ConnectivitySMILES / SMILES |
Read p["ConnectivitySMILES"] (connectivity, no stereo) or p["SMILES"] (with stereo); update the property CSV to the new names |
TypeError: '>' not supported between instances of 'str' and 'int' |
MolecularWeight is a string |
float(p["MolecularWeight"]) before any arithmetic comparison |
Batch cid/2244,3672,... returns only some rows |
URL exceeded server limit | Switch to requests.post(url, data={"cid": "2244,3672,..."}); same URL minus the value, body carries the CSV |
Empty assaysummary Table |
CID has no bioassay records | Not all compounds are assayed; verify on the PubChem web page |
XLogP is None for a valid CID |
Property not computed for that compound | Guard with p.get("XLogP", 0) or 0 before arithmetic |
Related Skills
chembl-database-bioactivity— IC50 / Ki / Kd target-binding data, deeper than PubChem's assay summariesrdkit-cheminformatics— local SMILES/MOL manipulation, fingerprints, descriptors, scaffold extractionpdb-database— protein structures co-crystallized with the small molecules found via PubChem CIDs
References
- PubChem PUG-REST documentation — Full URL grammar reference
- PUG-REST tutorial with worked examples — Step-by-step API walk-through
- PubChem property table — Authoritative list of
/property/<name>values and their semantics - PubChem main site — Web interface for spot-checks
- Kim et al. (2023) Nucleic Acids Res — PubChem 2023 update
skills/structural-biology-drug-discovery/pytdc-therapeutics-data-commons/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pytdc-therapeutics-data-commons -g -y
SKILL.md
Frontmatter
{
"name": "pytdc-therapeutics-data-commons",
"license": "MIT",
"description": "Therapeutics Data Commons (TDC) AI-ready drug discovery datasets. Curated ADME, toxicity, DTI, DDI with scaffold\/cold splits, standardized metrics, molecular oracles, and ADMET benchmarks for therapeutic ML and property prediction. For chemical database queries use chembl-database-bioactivity; for featurization use molfeat.\n"
}
PyTDC (Therapeutics Data Commons)
Overview
PyTDC is an open-science platform providing AI-ready datasets and benchmarks for drug discovery. It organizes therapeutics data into three categories: single-instance prediction (molecular/protein properties), multi-instance prediction (drug-target interactions), and generation (molecule design, retrosynthesis). All datasets come with standardized splits, evaluation metrics, and molecular oracles.
When to Use
- Loading curated ADME, toxicity, or bioactivity datasets for ML model training
- Benchmarking drug discovery models with standardized 5-seed evaluation protocols
- Predicting drug-target or drug-drug interactions with proper cold-split evaluation
- Generating novel molecules and scoring them with molecular oracles (QED, SA, DRD2, GSK3B)
- Accessing scaffold-based or temporal train/test splits for pharmaceutical ML
- Converting molecular representations (SMILES to PyG graphs, ECFP fingerprints, SELFIES)
- For chemical database queries (compound search, bioactivity), use
chembl-database-bioactivityinstead - For molecular featurization beyond format conversion, use
molfeatinstead
Prerequisites
uv pip install PyTDC
# Core deps: numpy, pandas, scikit-learn, tqdm, fuzzywuzzy
# Optional: rdkit (scaffold splits), torch-geometric (PyG conversion)
API Note: TDC downloads datasets on first access (~10-500 MB per dataset). Specify path='data/' to control download location. No API key required.
Quick Start
from tdc.single_pred import ADME
from tdc import Evaluator
# Load dataset with scaffold split
data = ADME(name='Caco2_Wang')
split = data.get_split(method='scaffold', seed=42, frac=[0.7, 0.1, 0.2])
train, valid, test = split['train'], split['valid'], split['test']
print(f"Train: {len(train)}, Valid: {len(valid)}, Test: {len(test)}")
# Train: ~640, Valid: ~91, Test: ~182
# Evaluate predictions
evaluator = Evaluator(name='MAE')
# score = evaluator(test['Y'].values, predictions)
Core API
Module 1: Single-Instance Prediction — Dataset Access
Load datasets for predicting properties of individual molecules or proteins.
from tdc.single_pred import ADME, Tox, HTS, QM
# ADME — pharmacokinetic properties
data = ADME(name='Caco2_Wang') # Intestinal permeability (regression)
data = ADME(name='BBB_Martins') # Blood-brain barrier (binary)
data = ADME(name='Lipophilicity_AstraZeneca') # LogD (regression)
data = ADME(name='Solubility_AqSolDB') # Aqueous solubility
# Toxicity — adverse effects
data = Tox(name='hERG') # Cardiotoxicity (binary)
data = Tox(name='AMES') # Mutagenicity (binary)
data = Tox(name='DILI') # Drug-induced liver injury
data = Tox(name='ClinTox') # Clinical trial toxicity
# Access data as DataFrame
df = data.get_data(format='df')
print(df.columns.tolist())
# ['Drug_ID', 'Drug', 'Y'] — Drug is SMILES, Y is target label
print(f"Dataset size: {len(df)}, Label range: [{df['Y'].min():.2f}, {df['Y'].max():.2f}]")
Other single-prediction tasks: HTS (screening), QM (quantum mechanics), Yields, Epitope, Develop, CRISPROutcome.
Module 2: Multi-Instance Prediction — Interaction Datasets
Load datasets for predicting interactions between pairs of biomedical entities.
from tdc.multi_pred import DTI, DDI, PPI
# Drug-Target Interaction — binding affinity
data = DTI(name='BindingDB_Kd') # 52,284 pairs, Kd values
data = DTI(name='DAVIS') # 30,056 pairs, kinase binding
data = DTI(name='KIBA') # 118,254 pairs, kinase bioactivity
# Drug-Drug Interaction — interaction type prediction
data = DDI(name='DrugBank') # 191,808 pairs, 86 interaction types
# Protein-Protein Interaction
data = PPI(name='HuRI')
# Multi-instance data format
df = data.get_data(format='df')
print(df.columns.tolist())
# ['Drug_ID', 'Drug', 'Target_ID', 'Target', 'Y']
# Drug=SMILES, Target=protein sequence, Y=binding affinity or class
Other multi-instance tasks: GDA, DrugRes, DrugSyn, PeptideMHC, AntibodyAff, MTI, Catalyst, TrialOutcome.
Module 3: Generation Tasks — Molecular Design
Load training sets and oracles for molecule generation and retrosynthesis.
from tdc.generation import MolGen, RetroSyn, PairMolGen
from tdc import Oracle
# Molecule generation — training data
data = MolGen(name='ChEMBL_V29') # 1.6M drug-like SMILES
split = data.get_split()
train_smiles = split['train']['Drug'].tolist()
# Oracle scoring — evaluate generated molecules
oracle = Oracle(name='GSK3B') # GSK3B inhibition predictor (0-1)
score = oracle('CC(C)Cc1ccc(cc1)C(C)C(O)=O')
print(f"GSK3B score: {score:.4f}")
# Batch evaluation
scores = oracle(['CCO', 'c1ccccc1', 'CC(=O)O'])
print(f"Batch scores: {scores}")
# Retrosynthesis — reaction prediction
data = RetroSyn(name='USPTO') # 1.9M reactions
split = data.get_split()
# Paired generation — prodrug design
data = PairMolGen(name='Prodrug')
Module 4: Data Splits and Evaluation
Apply meaningful data splits and standardized evaluation metrics.
from tdc.single_pred import ADME
from tdc.multi_pred import DTI
from tdc import Evaluator
# Scaffold split — ensures chemical diversity between sets
data = ADME(name='Caco2_Wang')
split = data.get_split(method='scaffold', seed=42, frac=[0.7, 0.1, 0.2])
# Cold splits — for DTI (unseen drugs/targets in test set)
data = DTI(name='BindingDB_Kd')
cold_drug = data.get_split(method='cold_drug', seed=1)
cold_target = data.get_split(method='cold_target', seed=1)
# Verify no overlap in cold split
train_drugs = set(cold_drug['train']['Drug_ID'])
test_drugs = set(cold_drug['test']['Drug_ID'])
print(f"Drug overlap: {len(train_drugs & test_drugs)}") # 0
# Evaluation metrics
eval_mae = Evaluator(name='MAE')
eval_auc = Evaluator(name='ROC-AUC')
eval_spearman = Evaluator(name='Spearman')
# score = eval_mae(y_true, y_pred)
Available split methods: random, scaffold (Bemis-Murcko), cold_drug, cold_target, cold_drug_target, temporal.
Available metrics: Classification — ROC-AUC, PR-AUC, F1, Accuracy, Kappa. Regression — RMSE, MAE, R2, MSE. Ranking — Spearman, Pearson. Multi-label — Micro-F1, Macro-F1.
Module 5: Benchmark Groups
Run standardized multi-seed evaluation protocols for model comparison.
from tdc.benchmark_group import admet_group
# Load ADMET benchmark (22 datasets)
group = admet_group(path='data/')
# Standard 5-seed evaluation protocol
benchmark = group.get('Caco2_Wang')
predictions = {}
for seed in [1, 2, 3, 4, 5]:
train_df = benchmark['train']
valid_df = benchmark['valid']
test_df = benchmark['test']
# Train your model on train_df, tune on valid_df
# predictions[seed] = model.predict(test_df['Drug'])
predictions[seed] = test_df['Y'].values # placeholder
# Get benchmark results
results = group.evaluate(predictions)
print(f"Mean MAE: {results['Caco2_Wang'][0]:.4f} ± {results['Caco2_Wang'][1]:.4f}")
Key Concepts
Dataset Organization
| Category | Import Path | Task Examples | Data Format |
|---|---|---|---|
| Single-Instance | tdc.single_pred |
ADME, Tox, HTS, QM | Drug (SMILES) + Y (label) |
| Multi-Instance | tdc.multi_pred |
DTI, DDI, PPI, DrugSyn | Drug + Target + Y |
| Generation | tdc.generation |
MolGen, RetroSyn | SMILES collections |
| Benchmark | tdc.benchmark_group |
admet_group | Curated splits |
Oracle Categories
| Category | Examples | Speed | Output Range |
|---|---|---|---|
| Biochemical | DRD2, GSK3B, JNK3, 5HT2A | Medium (ML) | 0-1 probability |
| Physicochemical | QED, SA, LogP, MW | Fast (rule-based) | Varies by metric |
| Composite | Isomer_Meta, Median1/2, Rediscovery | Medium | 0-1 combined |
| Specialized | ASKCOS, Docking, Vina | Slow (external) | Varies |
Data Processing Utilities
| Utility | Function | Example |
|---|---|---|
| Format conversion | MolConvert(src, dst) |
SMILES → PyG, ECFP, SELFIES, DGL |
| Molecule filters | MolFilter(filters) |
PAINS, BMS, Glaxo, drug-likeness |
| Label binarization | label_transform() |
Continuous → binary at threshold |
| Unit conversion | label_transform(from_unit, to_unit) |
nM → pIC50 |
| ID resolution | cid2smiles(), uniprot2seq() |
PubChem CID → SMILES |
| Dataset listing | retrieve_dataset_names(task) |
List all ADME datasets |
Common Workflows
Workflow 1: Multi-Seed ADME Model Evaluation
from tdc.single_pred import ADME
from tdc import Evaluator
import numpy as np
data = ADME(name='Caco2_Wang')
evaluator = Evaluator(name='MAE')
results = []
for seed in [1, 2, 3, 4, 5]:
split = data.get_split(method='scaffold', seed=seed)
train, valid, test = split['train'], split['valid'], split['test']
# model.fit(train['Drug'], train['Y'])
# preds = model.predict(test['Drug'])
preds = test['Y'].values + np.random.normal(0, 0.1, len(test)) # placeholder
score = evaluator(test['Y'].values, preds)
results.append(score)
print(f"Seed {seed}: MAE = {score:.4f}")
print(f"Mean MAE: {np.mean(results):.4f} ± {np.std(results):.4f}")
Workflow 2: Multi-Objective Molecular Scoring
from tdc import Oracle
import numpy as np
# Define multi-objective scoring
oracles = {
'QED': (Oracle(name='QED'), 0.3), # drug-likeness
'SA': (Oracle(name='SA'), 0.3), # synthetic accessibility
'GSK3B': (Oracle(name='GSK3B'), 0.4), # target activity
}
test_smiles = ['CC(C)Cc1ccc(cc1)C(C)C(O)=O', 'c1ccc2c(c1)cc1ccc3cccc4ccc2c1c34']
for smi in test_smiles:
scores = {}
weighted_sum = 0
for name, (oracle, weight) in oracles.items():
score = oracle(smi)
scores[name] = score
weighted_sum += score * weight
print(f"SMILES: {smi[:30]}...")
print(f" Scores: {scores}")
print(f" Weighted: {weighted_sum:.4f}")
Workflow 3: Cold-Split DTI Evaluation (text-only)
- Load DTI dataset —
DTI(name='BindingDB_Kd')(Core API Module 2) - Apply cold_drug split —
data.get_split(method='cold_drug', seed=seed)(Core API Module 4) - Verify zero drug overlap between train and test sets (Module 4 overlap check)
- Train model on train set, predict on test set
- Evaluate with Spearman correlation —
Evaluator(name='Spearman')(Module 4) - Repeat for 5 seeds and report mean ± std (same pattern as Workflow 1)
Key Parameters
| Parameter | Function/Module | Default | Range/Options | Effect |
|---|---|---|---|---|
method |
get_split() |
'scaffold' |
random, scaffold, cold_drug, cold_target, temporal |
Split strategy for train/test |
seed |
get_split() |
42 |
1-5 for benchmarks | Reproducibility; use 5 seeds for benchmarks |
frac |
get_split() |
[0.7, 0.1, 0.2] |
Sum must equal 1.0 | Train/valid/test proportions |
name |
Evaluator() |
— | MAE, RMSE, ROC-AUC, Spearman, etc. |
Evaluation metric |
name |
Oracle() |
— | QED, SA, GSK3B, DRD2, etc. |
Scoring function for molecules |
src/dst |
MolConvert() |
— | SMILES, SELFIES, PyG, DGL, ECFP4 |
Molecular representation formats |
path |
admet_group() |
'data/' |
Any directory | Dataset download/cache location |
format |
get_data() |
'df' |
'df', 'dict' |
Output data format |
Best Practices
- Always use scaffold splits for molecular property prediction — random splits leak structural information and inflate performance metrics
- Report 5-seed evaluations with mean ± std — single-seed results are unreliable for method comparison
- Use cold splits for interaction prediction —
cold_drugtests generalization to unseen drugs,cold_targetto unseen targets - Filter molecules early with
MolFilter(PAINS, drug-likeness) before training to remove problematic compounds - Normalize oracles appropriately — QED returns 0-1, SA returns 1-10 (lower is better), binding scores vary. Check oracle documentation before combining
- Cache datasets by specifying a persistent
path— avoids re-downloading large datasets across sessions
Common Recipes
Recipe 1: Dataset Exploration and Statistics
from tdc.single_pred import ADME
from tdc.utils import retrieve_dataset_names
# List all ADME datasets
datasets = retrieve_dataset_names('ADME')
print(f"Available ADME datasets: {datasets}")
# Load and inspect
data = ADME(name='Caco2_Wang')
df = data.get_data(format='df')
print(f"Size: {len(df)}")
print(f"Label stats: mean={df['Y'].mean():.2f}, std={df['Y'].std():.2f}")
print(f"SMILES example: {df['Drug'].iloc[0]}")
Recipe 2: Molecule Format Conversion Pipeline
from tdc.chem_utils import MolConvert
# SMILES to multiple representations
smiles = 'CC(C)Cc1ccc(cc1)C(C)C(O)=O'
converter_ecfp = MolConvert(src='SMILES', dst='ECFP4')
converter_selfies = MolConvert(src='SMILES', dst='SELFIES')
ecfp = converter_ecfp(smiles)
selfies = converter_selfies(smiles)
print(f"ECFP4 shape: {ecfp.shape}") # (1024,) binary fingerprint
print(f"SELFIES: {selfies}")
Recipe 3: Custom Oracle with Constraint Satisfaction
from tdc import Oracle
# Define property constraints
constraints = {
'QED': (Oracle(name='QED'), 0.5, 1.0), # min, max
'SA': (Oracle(name='SA'), 1.0, 4.0),
'LogP': (Oracle(name='LogP'), -0.5, 5.0),
}
def check_constraints(smiles):
"""Check if molecule satisfies all property constraints."""
results = {}
all_pass = True
for name, (oracle, lo, hi) in constraints.items():
score = oracle(smiles)
passed = lo <= score <= hi
results[name] = {'score': score, 'passed': passed}
all_pass = all_pass and passed
return all_pass, results
passed, details = check_constraints('CC(C)Cc1ccc(cc1)C(C)C(O)=O')
for name, info in details.items():
status = "PASS" if info['passed'] else "FAIL"
print(f"{name}: {info['score']:.3f} [{status}]")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ModuleNotFoundError: tdc |
Package not installed | uv pip install PyTDC |
| Scaffold split fails | Missing RDKit dependency | uv pip install rdkit for scaffold decomposition |
| Dataset download timeout | Large dataset or slow connection | Set path='data/' for persistent cache; retry |
KeyError on dataset name |
Wrong name or task category | Use retrieve_dataset_names('ADME') to list valid names |
| Oracle returns NaN | Invalid SMILES or RDKit parse failure | Validate SMILES with RDKit MolFromSmiles() first |
| Cold split empty test set | Too few unique entities | Use frac=[0.7, 0.1, 0.2] with larger datasets |
| Benchmark evaluation error | Wrong prediction format | Pass dict with seeds as keys: {1: preds1, 2: preds2, ...} |
| Memory error on large dataset | Full dataset loaded to memory | Process in chunks or use smaller split fractions |
| PyG conversion fails | torch-geometric not installed | uv pip install torch-geometric for graph conversion |
Bundled Resources
references/datasets_catalog.md
Covers: complete catalog of all TDC datasets organized by task category (single-instance, multi-instance, generation) with dataset names, sizes, label types, and data sources. Relocated inline: top ADME/Tox/DTI datasets with code examples consolidated into Core API Modules 1-2. Omitted: None — all dataset entries preserved in catalog format.
references/oracles_utilities.md
Covers: detailed oracle documentation (all 17+ oracles with parameters, speed tiers, output ranges, custom oracle template) and data processing utilities (format conversion targets, molecule filter types, label transformation, entity resolution). Consolidated from original oracles.md + utilities.md. Relocated inline: Quick Start oracle usage pattern, top evaluation metrics, split methods, MolConvert pattern → Core API Modules 3-4. Omitted: distribution learning KS-test example — niche statistical comparison; leaderboard submission guide — platform-specific.
Script Disposition
load_and_split_data.py(215 lines): scaffold/cold split patterns → Core API Module 4; custom split fractions → Key Parameters; evaluation examples → Workflow 1. Thin wrappers aroundget_split()andEvaluator().benchmark_evaluation.py(328 lines): 5-seed protocol → Core API Module 5 + Workflow 1; multi-dataset evaluation → Workflow 1 pattern; leaderboard guide → omitted (platform-specific).molecular_generation.py(405 lines): single/batch oracle usage → Core API Module 3; multi-objective scoring → Workflow 2; constraint satisfaction → Recipe 3; distribution learning → omitted (niche).
Related Skills
- chembl-database-bioactivity — for querying ChEMBL compound/target/activity data directly
- molfeat — for advanced molecular featurization beyond TDC's built-in MolConvert
- rdkit-cheminformatics — for molecular manipulation, substructure search, descriptor calculation
References
- TDC Official Website: https://tdcommons.ai
- TDC Documentation: https://tdc.readthedocs.io
- GitHub Repository: https://github.com/mims-harvard/TDC
- Huang et al. "Therapeutics Data Commons" NeurIPS 2021 Datasets and Benchmarks
skills/structural-biology-drug-discovery/rdkit-cheminformatics/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill rdkit-cheminformatics -g -y
SKILL.md
Frontmatter
{
"name": "rdkit-cheminformatics",
"license": "BSD-3-Clause",
"description": "Cheminformatics toolkit for molecular analysis and virtual screening: SMILES\/SDF parsing, descriptors (MW, LogP, TPSA), fingerprints (Morgan\/ECFP, MACCS), Tanimoto similarity, SMARTS substructure filtering, Lipinski drug-likeness, reaction enumeration, 2D\/3D coordinates. For simpler API use datamol; use RDKit for fine-grained sanitization, custom fingerprints, or SMARTS\/reaction control."
}
RDKit Cheminformatics Toolkit
Overview
RDKit is the standard open-source cheminformatics library for Python, providing comprehensive APIs for molecular parsing, descriptor calculation, fingerprinting, substructure searching, and chemical reactions. This skill walks through a complete compound library profiling and virtual screening workflow — from loading molecules through drug-likeness filtering, similarity screening, and result visualization.
When to Use
- Calculate molecular properties (MW, LogP, TPSA, HBD/HBA) for a compound set
- Screen a library against a reference compound using fingerprint similarity
- Filter compounds by substructure (SMARTS patterns) for functional group analysis
- Assess drug-likeness using Lipinski's Rule of Five or custom filters
- Generate 2D depictions or 3D conformers for downstream docking
- Enumerate chemical libraries using reaction SMARTS (combinatorial chemistry)
- Cluster compounds by structural similarity for diversity analysis
- Standardize and deduplicate molecular datasets (canonical SMILES, InChI)
- Use
datamol-cheminformaticsinstead for a higher-level RDKit wrapper with batching and error handling; useopenbabelinstead for multi-format conversion (MOL2, XYZ, PDB)
Prerequisites
- Python packages:
rdkit-pypi(orrdkitvia conda),pandas,matplotlib,numpy - Data requirements: Molecular structures as SMILES strings, SDF files, or MOL files
- Environment: Python 3.8+; conda recommended for full RDKit installation
# Option 1: pip (lightweight)
pip install rdkit-pypi pandas matplotlib numpy
# Option 2: conda (full features including cartridge)
conda install -c conda-forge rdkit pandas matplotlib numpy
Workflow
Step 1: Load and Validate Molecules
Read molecular structures from SMILES or SDF and validate parsing.
from rdkit import Chem
import pandas as pd
# --- From SMILES list ---
smiles_list = [
"CC(=O)Oc1ccccc1C(=O)O", # Aspirin
"CC12CCC3C(C1CCC2O)CCC4=CC(=O)CCC34C", # Testosterone
"c1ccc2[nH]c(-c3ccccn3)nc2c1", # Benzimidazole derivative
"CC(C)Cc1ccc(C(C)C(=O)O)cc1", # Ibuprofen
"INVALID_SMILES", # Will fail
]
mols = []
failed = []
for smi in smiles_list:
mol = Chem.MolFromSmiles(smi)
if mol is not None:
mol.SetProp("_SMILES", smi)
mols.append(mol)
else:
failed.append(smi)
print(f"Successfully parsed: {len(mols)}/{len(smiles_list)}")
print(f"Failed: {failed}")
# --- From SDF file ---
# suppl = Chem.SDMolSupplier("library.sdf")
# mols = [mol for mol in suppl if mol is not None]
# print(f"Loaded {len(mols)} molecules from SDF")
Step 2: Standardize and Deduplicate
Canonicalize SMILES and remove duplicates to ensure a clean dataset.
from rdkit.Chem.MolStandardize import rdMolStandardize
# Standardize: neutralize charges, remove fragments, canonicalize
uncharger = rdMolStandardize.Uncharger()
chooser = rdMolStandardize.LargestFragmentChooser()
standardized = []
seen_smiles = set()
for mol in mols:
# Keep largest fragment (remove salts/counterions)
mol = chooser.choose(mol)
# Neutralize charges
mol = uncharger.uncharge(mol)
# Canonical SMILES for deduplication
canon_smi = Chem.MolToSmiles(mol)
if canon_smi not in seen_smiles:
seen_smiles.add(canon_smi)
mol.SetProp("canonical_smiles", canon_smi)
standardized.append(mol)
print(f"After standardization: {len(standardized)} unique molecules")
print(f"Removed {len(mols) - len(standardized)} duplicates/salts")
Step 3: Calculate Molecular Descriptors
Compute physicochemical properties for each molecule.
from rdkit.Chem import Descriptors
records = []
for mol in standardized:
desc = {
"SMILES": Chem.MolToSmiles(mol),
"MW": round(Descriptors.MolWt(mol), 2),
"LogP": round(Descriptors.MolLogP(mol), 2),
"TPSA": round(Descriptors.TPSA(mol), 2),
"HBD": Descriptors.NumHDonors(mol),
"HBA": Descriptors.NumHAcceptors(mol),
"RotBonds": Descriptors.NumRotatableBonds(mol),
"AromaticRings": Descriptors.NumAromaticRings(mol),
"HeavyAtoms": mol.GetNumHeavyAtoms(),
"RingCount": Descriptors.RingCount(mol),
}
records.append(desc)
df = pd.DataFrame(records)
print(df.to_string(index=False))
print(f"\nDescriptor summary:\n{df.describe().round(2)}")
Step 4: Apply Drug-Likeness Filters
Filter compounds using Lipinski's Rule of Five and Veber criteria.
def lipinski_filter(row):
"""Lipinski Ro5: MW<=500, LogP<=5, HBD<=5, HBA<=10"""
return (row["MW"] <= 500 and row["LogP"] <= 5 and
row["HBD"] <= 5 and row["HBA"] <= 10)
def veber_filter(row):
"""Veber: RotBonds<=10, TPSA<=140"""
return row["RotBonds"] <= 10 and row["TPSA"] <= 140
df["Lipinski"] = df.apply(lipinski_filter, axis=1)
df["Veber"] = df.apply(veber_filter, axis=1)
df["DrugLike"] = df["Lipinski"] & df["Veber"]
print(f"Lipinski pass: {df['Lipinski'].sum()}/{len(df)}")
print(f"Veber pass: {df['Veber'].sum()}/{len(df)}")
print(f"Drug-like: {df['DrugLike'].sum()}/{len(df)}")
drug_like_mols = [standardized[i] for i in df[df["DrugLike"]].index]
print(f"\n{len(drug_like_mols)} drug-like compounds retained")
Step 5: Generate Fingerprints and Similarity Search
Compute Morgan fingerprints and screen against a reference compound.
from rdkit.Chem import AllChem
from rdkit import DataStructs
# Reference compound (e.g., known active)
ref_smi = "CC(=O)Oc1ccccc1C(=O)O" # Aspirin
ref_mol = Chem.MolFromSmiles(ref_smi)
ref_fp = AllChem.GetMorganFingerprintAsBitVect(ref_mol, radius=2, nBits=2048)
# Screen library
results = []
for mol in drug_like_mols:
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=2048)
tanimoto = DataStructs.TanimotoSimilarity(ref_fp, fp)
results.append({
"SMILES": Chem.MolToSmiles(mol),
"Tanimoto": round(tanimoto, 3),
})
sim_df = pd.DataFrame(results).sort_values("Tanimoto", ascending=False)
print("Similarity ranking:")
print(sim_df.to_string(index=False))
# Filter by threshold
threshold = 0.3
hits = sim_df[sim_df["Tanimoto"] >= threshold]
print(f"\n{len(hits)} compounds with Tanimoto >= {threshold}")
Step 6: Substructure Filtering with SMARTS
Filter compounds containing specific functional groups.
# Define SMARTS patterns for functional groups of interest
patterns = {
"Carboxylic acid": "[CX3](=O)[OX2H1]",
"Amide": "[CX3](=[OX1])[NX3]",
"Aromatic ring": "c1ccccc1",
"Hydroxyl": "[OX2H]",
"Ester": "[CX3](=O)[OX2][C]",
}
print("Substructure matches:")
for name, smarts in patterns.items():
query = Chem.MolFromSmarts(smarts)
match_count = sum(1 for mol in drug_like_mols if mol.HasSubstructMatch(query))
print(f" {name}: {match_count}/{len(drug_like_mols)} compounds")
# Get specific matches with atom indices
query = Chem.MolFromSmarts("[CX3](=O)[OX2H1]") # Carboxylic acid
for mol in drug_like_mols:
matches = mol.GetSubstructMatches(query)
if matches:
smi = Chem.MolToSmiles(mol)
print(f"\n{smi}: {len(matches)} carboxylic acid group(s)")
for match in matches:
print(f" Atom indices: {match}")
Step 7: 2D Visualization and Grid Plots
Generate publication-quality molecular depictions.
from rdkit.Chem import Draw
from rdkit.Chem.Draw import rdMolDraw2D
# Grid image of top hits
legends = [f"Tan={row['Tanimoto']}" for _, row in sim_df.head(4).iterrows()]
top_mols = [Chem.MolFromSmiles(smi) for smi in sim_df.head(4)["SMILES"]]
img = Draw.MolsToGridImage(
top_mols,
molsPerRow=2,
subImgSize=(300, 300),
legends=legends,
)
img.save("top_hits_grid.png")
print("Saved top_hits_grid.png")
# Highlight substructure in a molecule
mol = top_mols[0]
query = Chem.MolFromSmarts("[CX3](=O)[OX2H1]")
match = mol.GetSubstructMatch(query)
if match:
highlight_img = Draw.MolToImage(mol, size=(400, 400), highlightAtoms=match)
highlight_img.save("substructure_highlight.png")
print("Saved substructure_highlight.png")
Step 8: Export Results
Save the profiling results and filtered compounds.
import os
os.makedirs("results", exist_ok=True)
# Save descriptor table
df.to_csv("results/descriptors.csv", index=False)
print(f"Saved descriptors for {len(df)} compounds to results/descriptors.csv")
# Save drug-like compounds as SDF
writer = Chem.SDWriter("results/drug_like_compounds.sdf")
for i, mol in enumerate(drug_like_mols):
# Attach descriptors as SDF properties
row = df[df["DrugLike"]].iloc[i]
mol.SetProp("MW", str(row["MW"]))
mol.SetProp("LogP", str(row["LogP"]))
mol.SetProp("TPSA", str(row["TPSA"]))
writer.write(mol)
writer.close()
print(f"Saved {len(drug_like_mols)} drug-like compounds to results/drug_like_compounds.sdf")
# Save similarity results
sim_df.to_csv("results/similarity_results.csv", index=False)
print(f"Saved similarity rankings to results/similarity_results.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
MolFromSmiles(sanitize=) |
True |
True, False |
Automatic validation and aromaticity perception on parsing |
Morgan radius |
2 |
1-3 |
Fingerprint radius; 2 ≈ ECFP4, 3 ≈ ECFP6 |
Morgan nBits |
2048 |
1024-4096 |
Fingerprint bit length; higher = fewer collisions |
Tanimoto threshold |
0.7 |
0.3-0.9 |
Similarity cutoff; lower = more permissive |
Lipinski MW cutoff |
500 |
300-600 |
Max molecular weight for drug-likeness |
Lipinski LogP cutoff |
5 |
3-6 |
Max lipophilicity |
Veber RotBonds cutoff |
10 |
7-15 |
Max rotatable bonds for oral bioavailability |
Veber TPSA cutoff |
140 |
120-160 |
Max polar surface area (Ų) |
EmbedMolecule(randomSeed=) |
None |
Any integer | Seed for reproducible 3D conformer generation |
Butina distThresh |
0.3 |
0.2-0.5 |
Distance cutoff for Butina clustering |
Common Recipes
Recipe: Butina Clustering for Diversity Selection
When to use: select a diverse subset from a large compound library.
from rdkit.ML.Cluster import Butina
from rdkit.Chem import AllChem
from rdkit import DataStructs, Chem
# Generate fingerprints
fps = [AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=2048)
for mol in standardized]
# Build distance matrix (lower triangle)
dists = []
for i in range(1, len(fps)):
sims = DataStructs.BulkTanimotoSimilarity(fps[i], fps[:i])
dists.extend([1 - s for s in sims])
# Cluster
clusters = Butina.ClusterData(dists, len(fps), distThresh=0.3, isDistData=True)
print(f"{len(clusters)} clusters from {len(fps)} compounds")
# Pick centroid from each cluster (first element = centroid)
diverse_indices = [c[0] for c in clusters]
diverse_mols = [standardized[i] for i in diverse_indices]
print(f"Selected {len(diverse_mols)} diverse representatives")
Recipe: Reaction Enumeration (Amide Coupling)
When to use: generate a combinatorial library from building blocks via reaction SMARTS.
from rdkit.Chem import AllChem, Chem
# Amide coupling: carboxylic acid + amine → amide
rxn = AllChem.ReactionFromSmarts(
"[C:1](=[O:2])[OH].[N:3]([H])([H])[C:4]>>[C:1](=[O:2])[N:3][C:4]"
)
acids = [Chem.MolFromSmiles(s) for s in ["OC(=O)c1ccccc1", "OC(=O)CC"]]
amines = [Chem.MolFromSmiles(s) for s in ["NCC", "NC1CCCCC1"]]
products = []
for acid in acids:
for amine in amines:
ps = rxn.RunReactants((acid, amine))
for product_set in ps:
for prod in product_set:
Chem.SanitizeMol(prod)
products.append(Chem.MolToSmiles(prod))
print(f"Generated {len(products)} products:")
for p in products:
print(f" {p}")
Recipe: 3D Conformer Generation and MMFF Optimization
When to use: prepare molecules for docking or 3D pharmacophore analysis.
from rdkit import Chem
from rdkit.Chem import AllChem
mol = Chem.MolFromSmiles("CC(=O)Oc1ccccc1C(=O)O")
mol = Chem.AddHs(mol) # Required for 3D embedding
# Generate multiple conformers
params = AllChem.ETKDGv3()
params.randomSeed = 42
params.numThreads = 0 # Use all available cores
conf_ids = AllChem.EmbedMultipleConfs(mol, numConfs=10, params=params)
print(f"Generated {len(conf_ids)} conformers")
# Optimize with MMFF94 force field
energies = []
for conf_id in conf_ids:
result = AllChem.MMFFOptimizeMolecule(mol, confId=conf_id)
ff = AllChem.MMFFGetMoleculeForceField(mol, AllChem.MMFFGetMoleculeProperties(mol), confId=conf_id)
energy = ff.CalcEnergy()
energies.append((conf_id, energy))
print(f" Conformer {conf_id}: {energy:.2f} kcal/mol (converged={result == 0})")
# Get lowest energy conformer
best_id = min(energies, key=lambda x: x[1])[0]
print(f"\nBest conformer: {best_id} ({min(e for _, e in energies):.2f} kcal/mol)")
# Save to SDF
writer = Chem.SDWriter("conformers.sdf")
for conf_id, energy in energies:
mol.SetProp("Energy", f"{energy:.2f}")
writer.write(mol, confId=conf_id)
writer.close()
Recipe: Molecular Visualization with Atom Indices and Custom Drawing
When to use: debug SMARTS matches, annotate atom positions for reports.
from rdkit import Chem
from rdkit.Chem.Draw import rdMolDraw2D
mol = Chem.MolFromSmiles("CC(=O)Oc1ccccc1C(=O)O")
AllChem.Compute2DCoords(mol)
# Custom drawer with atom indices and stereo annotations
drawer = rdMolDraw2D.MolDraw2DCairo(500, 400)
opts = drawer.drawOptions()
opts.addAtomIndices = True
opts.addStereoAnnotation = True
opts.bondLineWidth = 2.0
drawer.DrawMolecule(mol)
drawer.FinishDrawing()
with open("annotated_molecule.png", "wb") as f:
f.write(drawer.GetDrawingText())
print("Saved annotated_molecule.png with atom indices")
Expected Outputs
results/descriptors.csv— Tabular descriptors (SMILES, MW, LogP, TPSA, HBD, HBA, RotBonds, Lipinski, Veber, DrugLike)results/drug_like_compounds.sdf— Filtered compounds in SDF format with attached propertiesresults/similarity_results.csv— Tanimoto similarity rankings against reference compoundtop_hits_grid.png— 2D grid image of top similar compoundssubstructure_highlight.png— Molecule image with highlighted functional groupconformers.sdf— 3D conformer ensemble with MMFF energiesannotated_molecule.png— Atom-indexed 2D depiction
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
MolFromSmiles returns None |
Invalid SMILES string or valence error | Check SMILES validity; use Chem.MolFromSmiles(smi, sanitize=False) then DetectChemistryProblems() to diagnose |
Kekulization error |
Invalid aromatic ring system | Check for non-standard aromaticity; try Chem.SanitizeMol(mol, sanitizeOps=Chem.SANITIZE_ALL ^ Chem.SANITIZE_KEKULIZE) |
EmbedMolecule returns -1 |
3D embedding failed (ring strain, steric clash) | Use AllChem.EmbedMolecule(mol, maxAttempts=50, useRandomCoords=True) |
MMFF has null force field |
Missing MMFF parameters for atom types | Switch to UFF: AllChem.UFFOptimizeMolecule(mol) |
| Wrong descriptor values | Missing explicit hydrogens | Call Chem.AddHs(mol) before descriptor calculation for H-dependent properties |
ForwardSDMolSupplier empty |
File not found or wrong format | Verify path; use Chem.SDMolSupplier(path, sanitize=False) to skip problematic molecules |
| Slow fingerprint computation on large library | Sequential processing | Use AllChem.GetMorganFingerprintAsBitVect with pre-allocated arrays; consider rdkit.Chem.MultithreadedSDMolSupplier |
| SMARTS pattern no matches | Incorrect SMARTS syntax or aromaticity mismatch | Test pattern with simple SMILES first; use [#6] instead of C for any carbon |
MemoryError on large SDF |
Loading entire file into memory | Use ForwardSDMolSupplier for streaming; process in batches |
| Inconsistent canonical SMILES | Different RDKit versions | Pin RDKit version; use Chem.MolToSmiles(mol, canonical=True) explicitly |
Bundled Resources
This skill includes reference files in the references/ subdirectory:
references/api_reference.md— Key RDKit modules and function lookup organized by capability (I/O, descriptors, fingerprints, drawing, reactions)references/descriptors_guide.md— Complete list of 200+ molecular descriptors with names, descriptions, and typical rangesreferences/smarts_patterns.md— Common SMARTS patterns for functional group detection, organized by chemical class
References
- RDKit Documentation — Official documentation and Getting Started guide
- RDKit Cookbook — Recipes and common workflow patterns
- Getting Started with RDKit in Python — Comprehensive tutorial
- Lipinski, C.A. et al. (2001) Advanced Drug Delivery Reviews 46:3-26 — Rule of Five
- Veber, D.F. et al. (2002) J. Med. Chem. 45:2615-2623 — Oral bioavailability criteria
- Morgan, H.L. (1965) J. Chem. Doc. 5:107-113 — Circular fingerprint algorithm
skills/structural-biology-drug-discovery/sar-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill sar-analysis -g -y
SKILL.md
Frontmatter
{
"name": "sar-analysis",
"license": "open",
"description": "Structure-activity relationship (SAR) analysis guide for drug discovery including molecular descriptor analysis, scaffold analysis, and activity cliff detection."
}
SAR Analysis
Metadata
Short Description: Comprehensive guide for performing Structure-Activity Relationship (SAR) analysis using RDKit.
Authors: Ohagent Team
Version: 1.0
Last Updated: December 2025
License: CC BY 4.0
Commercial Use: ✅ Allowed
Overview
Structure-Activity Relationship (SAR) analysis is a core medicinal-chemistry workflow that relates systematic structural variations of a chemical series to changes in biological activity. The goal is to (1) identify a common scaffold (Maximum Common Substructure, MCS) shared by a series of analogues, (2) decompose each molecule into the scaffold plus its R-group substituents, (3) align all molecules so substituents at equivalent positions are visually comparable, and (4) connect substituent variation to potency to derive testable design hypotheses.
This guide formalizes a reproducible RDKit-based SAR workflow that produces an interactive HTML report (compound table with aligned core/R-groups and an activity heatmap) and a written SAR narrative that explicitly contrasts substituents at the same R-position. It is intended for use on activity tables containing SMILES, a compound identifier, and a numeric potency value (IC50, Ki, EC50, %inhibition, etc.).
Key Concepts
Maximum Common Substructure (MCS)
MCS is the largest connected substructure shared by all (or a configurable threshold of) molecules in a set. RDKit's rdFMCS.FindMCS searches for this scaffold under tunable atom/bond comparison rules. For SAR, MCS provides the anchor template against which every analogue is decomposed and aligned. A threshold=0.8 allows MCS to be defined when only 80% of molecules contain the candidate substructure, which is more robust to outliers than threshold=1.0. ringMatchesRingOnly=True and completeRingsOnly=True prevent partial-ring fragments that look chemically meaningless.
R-Group Decomposition
R-group decomposition (rdRGroupDecomposition.RGroupDecompose) maps each molecule onto the MCS core and assigns the non-core fragments to enumerated R-positions (R1, R2, …). The output is a per-molecule dictionary {Core, R1, R2, …}. Constant R-positions (where every molecule carries the same fragment) are uninformative for SAR and should be pruned from the report so attention focuses on the variable positions that actually drive activity.
Substructure Alignment for Comparable 2D Depiction
For SAR visualization to be interpretable, the core and each R-group must be drawn in the same orientation as the parent molecule. The recommended pattern uses three fall-back strategies in order: (1) a direct GetSubstructMatch, (2) a re-match after AdjustQueryProperties(makeDummiesQueries=True) so R-group dummy atoms are treated as queries, and (3) a final attempt with useChirality=False. Once a match is found, atom coordinates are copied from the parent conformer onto the fragment. Without this, R-group cells are drawn in arbitrary canonical orientations and visual SAR is essentially impossible to read.
Activity Heatmap and Comparative Analysis
A logarithmic-scale color gradient (green = high potency / low IC50, red = low potency / high IC50) on the activity column lets a reader spot trends across the series at a glance. The accompanying narrative must justify every claim about a substituent's effect by explicit pairwise contrast at the same R-position — the unit of SAR evidence is "compound A (R1=X, IC50=…) vs compound B (R1=Y, IC50=…)", never an unsupported generalization.
Decision Framework
SAR analysis pipeline
└── Have SMILES + activity for >= 4 analogues?
├── No -> Insufficient data; collect more analogues first
└── Yes -> Run rdFMCS.FindMCS(threshold=0.8, ringMatchesRingOnly=True)
├── MCS too small (<5 atoms) -> Series is too diverse;
│ cluster first, then run SAR per cluster
└── MCS reasonable -> RGroupDecompose
└── For each fragment alignment to parent:
├── Strategy 1: GetSubstructMatch(direct) -- works for canonical cases
│ └── No match -> Strategy 2
├── Strategy 2: AdjustQueryProperties(makeDummiesQueries=True)
│ -- handles dummy R-group atoms
│ └── No match -> Strategy 3
├── Strategy 3: GetSubstructMatch(useChirality=False)
│ -- handles stereochemistry mismatches
│ └── No match -> Compute2DCoords as fallback (lose alignment)
└── Drop constant R-positions, build HTML, draw with DrawMoleculeACS1996
| Situation | Recommended choice | Rationale |
|---|---|---|
| Standard congeneric series with a clear scaffold | MCS threshold=0.8, ringMatchesRingOnly=True, completeRingsOnly=True |
Tolerates a small minority of outliers while keeping rings intact |
| Highly diverse set (e.g., HTS hit list) | Cluster (Tanimoto/Murcko) first, then SAR per cluster | A single MCS will be too small to be useful across diverse chemotypes |
| Stereoisomers in the series | Try Strategy 1 first; fall back to Strategy 3 (useChirality=False) |
Chirality differences should not break depiction alignment |
| Analogues with R-group attachment dummies in queries | Strategy 2 with AdjustQueryProperties(makeDummiesQueries=True) |
Dummy atoms are treated as queries so they match real heavy atoms |
| One R-position constant across all analogues | Drop from report and from core depiction | Constant positions are uninformative and clutter the table |
| Activity spans many orders of magnitude | Color heatmap on log10(activity) |
Linear scale collapses the dynamic range visually |
| Drawing for publication or report | DrawMoleculeACS1996 via MolDraw2DSVG |
ACS1996 is the de facto standard for medicinal chemistry figures |
Best Practices
- Inspect the dataframe before assuming column names. Real-world activity tables vary; auto-detect the SMILES, activity, and ID columns from
df.head()rather than hard-coding names. This avoids silent failures on user data. - Add explicit hydrogens before MCS.
Chem.AddHslets MCS reason correctly about heavy-atom valence and ring closures; without it, otherwise-identical scaffolds can be missed. - Prune constant R-positions. Any R-position whose fragment is identical across every analogue contributes no SAR information; remove that column from the table and remove the constant attachment point from the core depiction so the variable positions stand out.
- Always align fragments to the parent molecule, not the other way around. Copy coordinates from the parent onto each fragment via the matched atom map. Drawing the parent canonically and then re-drawing fragments from scratch loses comparability between rows.
- Use a log-scale activity heatmap. Potency typically spans 2–4 orders of magnitude; a linear color scale collapses the interesting low-IC50 region. Map green to low IC50 (high potency) and red to high IC50 (low potency).
- Justify every SAR claim with a pairwise contrast at the same R-position. Statements like "small electron-withdrawing groups improve activity at R1" must be backed by a direct comparison such as "compound 7 (R1=F, IC50=0.5 µM) vs compound 1 (R1=Me, IC50=5.2 µM)". Unsupported generalizations are not acceptable evidence.
- Test 3-4 analogues per design hypothesis. A single substitution change can be confounded by experimental noise; multiple analogues at the same position give a defensible trend.
- Render with
DrawMoleculeACS1996. ACS1996 styling produces consistent bond lengths, atom labels, and font choices that match medicinal-chemistry publication norms; avoid mixing styles within a single report.
Common Pitfalls
-
Pitfall: Hard-coding column names like
"SMILES"or"IC50". Different vendors and ELNs export different headers; the script breaks on the first user that usesSmilesorStandard Value.- How to avoid: Inspect
df.columnsanddf.head()and detect the SMILES/activity/ID columns by content (valid SMILES parse rate, numeric values, unique strings).
- How to avoid: Inspect
-
Pitfall: Skipping
Chem.AddHsbefore MCS. Implicit-H molecules can yield a smaller-than-expected MCS because valence and ring perception differ.- How to avoid: Always preprocess with
mols_for_mcs = [Chem.AddHs(m) for m in mols]before callingrdFMCS.FindMCS.
- How to avoid: Always preprocess with
-
Pitfall: Setting
threshold=1.0on a noisy series. A single outlier with an unusual scaffold collapses the MCS to a tiny fragment and ruins R-group decomposition for everyone else.- How to avoid: Use
threshold=0.8(or lower) so the MCS is defined when 80% of the series contains it; review the outlier(s) separately.
- How to avoid: Use
-
Pitfall: Drawing each fragment with
Compute2DCoordsindependently. Each fragment receives its own canonical 2D layout, so equivalent atoms appear in different positions across rows and visual SAR becomes unreadable.- How to avoid: Match each fragment to the parent (with the 3-strategy fallback) and copy coordinates from the parent's conformer onto the fragment's conformer.
-
Pitfall: Failing on dummy R-group atoms.
GetSubstructMatchreturns no match when the fragment contains R-group dummy atoms (*) because dummies are not treated as queries by default.- How to avoid: Apply
AdjustQueryProperties(params)withmakeDummiesQueries=Truebefore retrying the match (Strategy 2).
- How to avoid: Apply
-
Pitfall: Reporting only a single error metric (e.g., mean only). A trend reported without dispersion is not interpretable; equally, claims about substituent effects without same-position contrasts are not SAR.
- How to avoid: For every R-position, list each unique substituent and the activities of the compounds carrying it; derive every claim from a pairwise comparison.
-
Pitfall: Using a linear-scale heatmap on IC50 in nM. Most of the interesting potency range collapses into one or two color bins.
- How to avoid: Color by
log10(IC50)orpIC50 = -log10(IC50_in_M); this gives uniform color separation across orders of magnitude.
- How to avoid: Color by
-
Pitfall: Treating every column of the R-group decomposition as a SAR axis. Constant R-positions (every analogue has the same fragment) and the core itself are not SAR variables.
- How to avoid: After decomposition, programmatically drop columns where every entry is identical and remove those attachment points from the core image.
Workflow
You are an expert in Cheminformatics and Python. Perform a SAR (Structure-Activity Relationship) analysis using RDKit.
Task Requirements:
-
Data Loading: Load the CSV file. Do not assume fixed column names. Instead, inspect the dataframe (e.g., using
df.head()) to automatically identify columns for Compound Key (e.g., 'Compound Key', 'ID', 'Name'), Activity (e.g., 'Standard Value', 'IC50', 'Activity'), and SMILES (e.g., 'Smiles', 'SMILES', 'Structure'). -
Core Identification (MCS):
- Use
rdFMCS.FindMCSto find a significant common scaffold. - Pre-processing: Apply
Chem.AddHsto molecules before finding MCS. - Reference Code: Use the following parameter settings for robust core identification:
mols_for_mcs = [Chem.AddHs(m) for m in mols] mcs_res = rdFMCS.FindMCS( mols_for_mcs, threshold=0.8, ringMatchesRingOnly=True, completeRingsOnly=True, atomCompare=rdFMCS.AtomCompare.CompareElements, bondCompare=rdFMCS.BondCompare.CompareOrder ) core_mol = Chem.MolFromSmarts(mcs_res.smartsString) AllChem.Compute2DCoords(core_mol)
- Use
-
R-Group Decomposition & Refinement:
- Perform decomposition based on the Core.
- Refinement: Exclude any R-group columns that are identical (constant) across all molecules. Remove these constant points from the Core visualization as well.
-
Image Generation & Alignment (Strict Coordinate Extraction):
-
Goal: Ensure Core and R-groups are visually perfectly superimposed on the Original Molecule.
-
Drawing Style: When drawing molecules, always use DrawMoleculeACS1996 for consistent and professional visualization:
from rdkit.Chem.Draw import rdMolDraw2D drawer = rdMolDraw2D.MolDraw2DSVG(-1, -1) rdMolDraw2D.DrawMoleculeACS1996(drawer, mol) drawer.FinishDrawing() svg = drawer.GetDrawingText() svg = svg.replace("width='", "width='100%' data-original-width='") svg = svg.replace("height='", "height='100%' data-original-height='") -
Reference Implementation: Use this specific alignment logic to guarantee perfect overlay:
matches, unmatched_indices = rdRGroupDecomposition.RGroupDecompose([core_mol], mols, asSmiles=False, asRows=False)def align_substructure_to_parent(sub, parent): if not sub or not parent: return False try: # Strategy 1: Direct match match = parent.GetSubstructMatch(sub) # Strategy 2: Convert dummies to queries (handle R-group attachment points) if not match: params = Chem.AdjustQueryParameters() params.makeDummiesQueries = True params.adjustDegree = False params.adjustRingCount = False sub_query = Chem.AdjustQueryProperties(sub, params) match = parent.GetSubstructMatch(sub_query) # Strategy 3: Try without chirality if not match: match = parent.GetSubstructMatch(sub, useChirality=False) if match: conf_parent = parent.GetConformer() conf_sub = Chem.Conformer(sub.GetNumAtoms()) for sub_idx, parent_idx in enumerate(match): pos = conf_parent.GetAtomPosition(parent_idx) conf_sub.SetAtomPosition(sub_idx, pos) sub.RemoveAllConformers() sub.AddConformer(conf_sub) return True except: pass return False # Usage in loop: # 1. Align Original Molecule to Core template try: AllChem.GenerateDepictionMatching2DStructure(m, core_mol) except: AllChem.Compute2DCoords(m) # 2. Align fragments (Core/R-groups) to Original Molecule # Copy coords FROM original molecule TO fragment if not align_substructure_to_parent(fragment, m): AllChem.Compute2DCoords(fragment)match_core = matches['Core'][i] align_substructure_to_parent(this_core, mol) core_img = mol_to_base64(this_core)
-
-
HTML Output (
sar_analysis_report.html):- Design: Create a clean, modern, and visually appealing HTML page using CSS styling. Use modern CSS features (e.g., subtle shadows, smooth transitions, clean typography, proper color schemes, responsive design) to enhance readability and visual appeal. Crucially, ensure that the table column widths are large enough to display structures clearly. Set a
min-widthof at least 300px (e.g.,min-width: 300px;) for the columns containing images (Original, Core, R-groups) so that the molecules are not shrunk and remain easily recognizable. - Table Structure:
Compound Key,Activity,Original Molecule,Core, and variable R-groups. - Activity Heatmap: Apply a background color gradient to Activity cells using a logarithmic scale (Green for low values/high potency, Red for high values/low potency).
- Image Handling:
- Convert molecules to SVG (preferred) or Base64 PNG strings.
- Validation: Check if image generation was successful. Only embed valid images; otherwise, use a text placeholder (
<td>No Image</td>).
- Interactive Sorting:
- Add a "Toggle Sort Order" button to the HTML page.
- Functionality: Clicking the button cycles through three views: Default View (original CSV order), Activity Ascending View (sorted by Activity value from low to high), and Activity Descending View (sorted by Activity value from high to low).
- Implementation: Use JavaScript to handle the sorting logic on the client side. Ensure the Activity column values are parsed as numbers for correct sorting.
- Summary: Include a brief text summary of SAR findings (correlation between R-groups and activity).
- Design: Create a clean, modern, and visually appealing HTML page using CSS styling. Use modern CSS features (e.g., subtle shadows, smooth transitions, clean typography, proper color schemes, responsive design) to enhance readability and visual appeal. Crucially, ensure that the table column widths are large enough to display structures clearly. Set a
-
Analysis Text Output:
-
Based on the analysis results, generate a concise text analysis of the SAR findings.
-
Output Format: Print this text directly in the conversation (do not save to a file).
-
Instructions: Follow these strict guidelines for the analysis text:
You are a scientific assistant specializing in Structure-Activity Relationship (SAR) analysis. Your task is to analyze the provided molecular data and generate a concise SAR report. The report MUST contain molecule ids to help the user understand the SAR analysis.
Analyze the SAR for the following molecules based on the provided data.
Core Instructions:
-
Identify the Scaffold and Substituents:
- Determine the common core structure and label the variable positions as R1, R2, etc. Use these labels consistently.
-
Perform a Comparative Analysis:
- 🚨 CRITICAL REQUIREMENT: You MUST justify ALL claims about substituent impact by explicitly contrasting with other substituents at the SAME position that resulted in different activity. Every activity trend you describe MUST be supported by direct comparisons between the compounds. Unsupported generalizations are not acceptable. 🚨
-
Infer Mechanisms:
- Propose plausible reasons for activity changes, considering steric, electronic, and potential intermolecular interactions (e.g., H-bonding, hydrophobic).
-
Evaluate Data Completeness and Propose Analogues (Mandatory Evaluation Step):
-
As the final mandatory step of your analysis, you must critically evaluate the completeness of the provided SAR data.
-
If, and only if, you identify a significant ambiguity where a key compound lacks a clear counterpart for a robust SAR conclusion, you must propose a new analogue to resolve it.
-
The justification for any proposal must still follow the specific logic:
-
Identify the Ambiguity: Name the specific compound and its data that leads to uncertainty.
-
State the Missing Counterpart: Explain what comparison is needed but cannot be made.
-
Propose the Solution: Suggest the exact analogue that would resolve the ambiguity.
-
If you conclude that the data is sufficient, you will simply state this in the dedicated section below.
-
-
-
Conclude:
- Summarize the key SAR findings and identify the most promising analogue(s).
Output Formatting and Style:
- Be Direct: Begin the analysis immediately. Do not use conversational openings like "I will analyze..." or "Here is the analysis...".
- Opening Statement: Start with a single sentence summarizing the main structural modifications and the key finding.
- Scientific Tone: Use precise, speculative language (e.g., "suggests that...", "likely due to...").
- Format: Use Markdown for clarity (e.g., bolding, bullet points).
- Dedicated Suggestions Section: At the end of your analysis, you must include a separate section titled
### Suggestions for Further Study.- In this section, present the analogues you propose based on Instruction #4.
- If you conclude that the provided data is sufficient and no new analogues are needed, you must still include the section and state: "The provided analogues offer sufficient comparative data for a robust initial SAR analysis at the explored positions." This ensures the step is never skipped.
- Conciseness: Provide only the requested SAR analysis.
- Proactive Follow-up: At the very end of your response (after the Conclusion), you must explicitly suggest a follow-up step or analysis in the form of a direct question to the user (e.g., "Would you like me to...?").
Example Output Structure:
The SAR analysis of the provided compounds indicates that a small, electron-withdrawing group at the R1 position is crucial for antibacterial activity. For instance, analogue 7 (R1=F, IC50 = 0.5 µM) showed a 10-fold improvement over the parent compound 1 (R1=Me, IC50 = 5.2 µM), suggesting a key interaction within a sterically confined space. In contrast, bulky substituents at R1, such as the phenyl group in analogue 12, abolished activity entirely.
Suggestions for Further Study
To validate the hypothesis that steric bulk at R1 is detrimental, synthesizing an analogue with a simple hydrogen at that position (the des-methyl version of compound 1) is recommended. This would establish a baseline activity for the unsubstituted scaffold and confirm the size constraints of the binding pocket.
Would you like me to design a synthesis pathway for the proposed des-methyl analogue?
-
-
Output:
- Provide the final
sar_analysis_report.htmlfile. - Print the Analysis Text in the chat.
References
- RDKit documentation — Maximum Common Substructure: https://www.rdkit.org/docs/source/rdkit.Chem.rdFMCS.html
- RDKit documentation — R-Group Decomposition: https://www.rdkit.org/docs/source/rdkit.Chem.rdRGroupDecomposition.html
- RDKit documentation —
MolDraw2DandDrawMoleculeACS1996: https://www.rdkit.org/docs/source/rdkit.Chem.Draw.rdMolDraw2D.html - Dalke A, Hastings J. "FMCS: a novel algorithm for the multiple MCS problem." J Cheminform. 2013;5(Suppl 1):O6. https://doi.org/10.1186/1758-2946-5-S1-O6
- Lewell XQ, Judd DB, Watson SP, Hann MM. "RECAP — Retrosynthetic Combinatorial Analysis Procedure." J Chem Inf Comput Sci. 1998;38(3):511-522. https://doi.org/10.1021/ci970429i
- Stumpfe D, Bajorath J. "Exploring activity cliffs in medicinal chemistry." J Med Chem. 2012;55(7):2932-2942. https://doi.org/10.1021/jm201706b
- Allen FH, Bellard S, Brice MD, et al. ACS document standards (ACS1996 drawing style reference): https://pubs.acs.org/doi/10.1021/ci00027a005
skills/structural-biology-drug-discovery/torchdrug/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill torchdrug -g -y
SKILL.md
Frontmatter
{
"name": "torchdrug",
"license": "Apache-2.0",
"description": "PyTorch-based ML platform for drug discovery: graph molecular representation learning, property prediction (ADMET, activity), retrosynthesis, drug-target interaction (DTI), and pretraining on large molecular datasets. Provides GNN layers (GraphConv, GAT, MPNN), pretrained models, and benchmark datasets."
}
torchdrug
Overview
TorchDrug is a comprehensive machine learning framework for drug discovery built on PyTorch. It provides graph-based molecular representations (atoms as nodes, bonds as edges), a library of graph neural network (GNN) architectures, benchmark datasets, and pretrained models for tasks including molecular property prediction, drug-target interaction, retrosynthesis, and generative molecular design. TorchDrug integrates with PyTorch Lightning and standard ML tooling, making it accessible to both computational chemists and ML practitioners.
When to Use
- Molecular property prediction: Training or fine-tuning GNN models to predict ADMET properties (solubility, toxicity, permeability) or bioactivity (IC50, Ki) from molecular graphs.
- Drug-target interaction (DTI) prediction: Building models that predict binding affinity between a compound (SMILES) and a protein (sequence or structure).
- Retrosynthesis prediction: Identifying plausible synthetic routes for a target molecule using template-based or template-free models.
- Pretraining on large molecular datasets: Leveraging pretrained GNN representations on ChEMBL or ZINC for transfer learning to small datasets.
- Molecular generation: Training graph-based generative models (GCPN, GraphAF) to design novel molecules with desired properties.
- Benchmarking GNN architectures: Comparing GraphConv, MPNN, GAT, AttentiveFP on standard MoleculeNet tasks.
- For fast fingerprint-based property prediction without deep learning, use RDKit + scikit-learn instead.
- For protein structure tasks (folding, docking), use ESMFold or DiffDock rather than TorchDrug.
Prerequisites
- Python packages:
torchdrug,torch,torch-geometric,rdkit - Environment: Python 3.8+, CUDA-compatible GPU recommended for training
- Data requirements: SMILES strings or molecular SDF files; protein sequences for DTI tasks
pip install torch torchvision --extra-index-url https://download.pytorch.org/whl/cu118
pip install torch-geometric
pip install torchdrug
pip install rdkit
Quick Start
import torch
from torchdrug import data, datasets, models, tasks, core
# Load a benchmark dataset and train a GNN for property prediction
dataset = datasets.BBBP("~/data/bbbp", node_feature="default", edge_feature="default")
print(f"Dataset: {len(dataset)} molecules, task: BBBP (blood-brain barrier penetration)")
# Define model: GIN encoder
model = models.GIN(
input_dim=dataset.node_feature_dim,
hidden_dims=[256, 256],
short_cut=True,
batch_norm=True,
concat_hidden=True,
)
# Define training task
task = tasks.PropertyPrediction(
model, task=dataset.tasks,
criterion="bce", metric=("auprc", "auroc"),
)
# Train with the Solver
optimizer = torch.optim.Adam(task.parameters(), lr=1e-3)
solver = core.Engine(task, dataset, None, None, optimizer, gpus=[0])
solver.train(num_epoch=50)
print("Training complete")
Core API
Module 1: Molecular Graph Representation
TorchDrug represents molecules as typed graphs. data.Molecule is the core data structure.
from torchdrug import data
from rdkit import Chem
# Create a molecule from SMILES
smiles = "CC(=O)Oc1ccccc1C(=O)O" # aspirin
mol = data.Molecule.from_smiles(smiles, node_feature="default", edge_feature="default")
print(f"Atoms: {mol.num_node}")
print(f"Bonds: {mol.num_edge}")
print(f"Node feature dim: {mol.node_feature.shape}") # (N_atoms, feature_dim)
print(f"Edge feature dim: {mol.edge_feature.shape}") # (N_bonds*2, feature_dim)
# Convert a MoleculeNet / custom SMILES list to a dataset
from torchdrug import data as td_data
import pandas as pd
df = pd.read_csv("compounds.csv") # columns: smiles, label
molecules = [td_data.Molecule.from_smiles(s) for s in df["smiles"] if s]
print(f"Loaded {len(molecules)} valid molecules")
# Check feature dimensions
print(f"Default atom feature dim: {molecules[0].node_feature.shape[1]}")
Module 2: GNN Architectures
TorchDrug provides GIN, RGCN, GraphSAGE, GAT, MPNN, AttentiveFP, and more.
from torchdrug import models, datasets
dataset = datasets.ESOL("~/data/esol", node_feature="default", edge_feature="default")
feature_dim = dataset.node_feature_dim
# Graph Isomorphism Network (GIN) — good default for property prediction
gin = models.GIN(
input_dim=feature_dim,
hidden_dims=[256, 256, 256],
short_cut=True,
batch_norm=True,
concat_hidden=True, # concatenate layer representations
)
print(f"GIN output_dim: {gin.output_dim}")
from torchdrug import models
# Message Passing Neural Network (MPNN) — captures edge features
mpnn = models.MPNN(
input_dim=feature_dim,
hidden_dim=256,
edge_input_dim=16, # edge feature dimension
num_layer=4,
num_gru_layer=1,
)
# Graph Attention Network (GAT) — attention-weighted neighbors
gat = models.GAT(
input_dim=feature_dim,
hidden_dims=[256, 256],
edge_input_dim=16,
num_head=8,
batch_norm=True,
)
print(f"MPNN output_dim: {mpnn.output_dim}, GAT output_dim: {gat.output_dim}")
Module 3: Molecular Property Prediction
Wrap a GNN encoder with a prediction head for classification or regression.
import torch
from torchdrug import datasets, models, tasks, core
# Regression example: ESOL aqueous solubility
dataset = datasets.ESOL("~/data/esol", node_feature="default", edge_feature="default")
train, val, test = dataset.split()
print(f"Train: {len(train)}, Val: {len(val)}, Test: {len(test)}")
model = models.GIN(
input_dim=dataset.node_feature_dim,
hidden_dims=[300, 300],
short_cut=True,
batch_norm=True,
concat_hidden=True,
)
task = tasks.PropertyPrediction(
model,
task=dataset.tasks, # list of property names
criterion="mse", # "mse" for regression, "bce" for classification
metric=("mae", "rmse"),
num_mlp_layer=2,
)
optimizer = torch.optim.Adam(task.parameters(), lr=1e-3, weight_decay=1e-5)
solver = core.Engine(task, train, val, test, optimizer,
batch_size=32, log_interval=50)
solver.train(num_epoch=100)
# Evaluate on test set
metrics = solver.evaluate("test")
print(f"Test RMSE: {metrics['rmse']:.4f}")
print(f"Test MAE: {metrics['mae']:.4f}")
Module 4: Drug-Target Interaction (DTI) Prediction
Predict binding affinity between molecules and protein sequences.
from torchdrug import datasets, models, tasks, core
import torch
# Load a DTI dataset (e.g., Davis kinase binding affinities)
dataset = datasets.Davis("~/data/davis",
mol_node_feature="default",
mol_edge_feature="default")
train, val, test = dataset.split()
# Molecule encoder
mol_model = models.GIN(
input_dim=dataset.mol_node_feature_dim,
hidden_dims=[256, 256],
short_cut=True,
batch_norm=True,
concat_hidden=True,
)
# Protein encoder (CNN on sequence)
prot_model = models.ProteinCNN(
input_dim=21, # amino acid vocabulary size
hidden_dims=[128, 128, 128],
kernel_size=3,
)
task = tasks.InteractionPrediction(
mol_model, prot_model,
task=dataset.tasks,
criterion="mse",
metric=("rmse", "pearsonr"),
)
optimizer = torch.optim.Adam(task.parameters(), lr=1e-3)
solver = core.Engine(task, train, val, test, optimizer,
batch_size=64, log_interval=100)
solver.train(num_epoch=50)
metrics = solver.evaluate("test")
print(f"DTI Test RMSE: {metrics['rmse']:.4f}")
print(f"DTI Pearson r: {metrics['pearsonr']:.4f}")
Module 5: Retrosynthesis Prediction
Predict one-step retrosynthetic disconnections to find plausible building blocks.
from torchdrug import datasets, models, tasks, core
import torch
# USPTO-50k retrosynthesis benchmark
dataset = datasets.USPTO50k("~/data/uspto50k",
as_synthon=False,
atom_feature="default",
bond_feature="default")
train, val, test = dataset.split()
# Reaction-predicting GNN
model = models.RGCN(
input_dim=dataset.node_feature_dim,
hidden_dims=[256, 256, 256],
num_relation=dataset.num_bond_type,
batch_norm=True,
)
task = tasks.CenterIdentification(
model,
feature=("graph", "atom", "bond"),
)
optimizer = torch.optim.Adam(task.parameters(), lr=1e-4)
solver = core.Engine(task, train, val, test, optimizer,
batch_size=64, log_interval=100)
solver.train(num_epoch=50)
metrics = solver.evaluate("test")
print(f"Retrosynthesis top-1 accuracy: {metrics.get('accuracy', 'N/A')}")
Module 6: Pretrained Models and Transfer Learning
Use TorchDrug's pretrained GNN representations as features for downstream tasks.
from torchdrug import models
# Load a GNN pretrained on ChEMBL with context-prediction self-supervised learning
pretrained_gin = models.GIN(
input_dim=39,
hidden_dims=[300, 300, 300, 300, 300],
short_cut=False,
batch_norm=True,
concat_hidden=False,
)
# Load pretrained weights (download from TorchDrug model zoo)
import torch
ckpt = torch.load("gin_supervised_contextpred.pth", map_location="cpu")
pretrained_gin.load_state_dict(ckpt)
pretrained_gin.eval()
print(f"Pretrained GIN loaded, output_dim={pretrained_gin.output_dim}")
print("Use as encoder in PropertyPrediction task for transfer learning")
Key Concepts
Graph-Based Molecular Representation
Molecules are represented as attributed graphs: atoms are nodes with features (atomic number, degree, charge, aromaticity) and bonds are edges with features (bond type, ring membership). All TorchDrug models operate on these graph representations rather than SMILES strings or fingerprints.
from torchdrug import data
mol = data.Molecule.from_smiles("c1ccccc1") # benzene
print(f"Atoms: {mol.num_node}, Bonds: {mol.num_edge // 2}")
print(f"Atom features (first atom): {mol.node_feature[0]}")
Engine and Solver Pattern
TorchDrug uses a core.Engine (also called Solver) to handle the training loop, logging, checkpointing, and multi-GPU setup. Pass the task, train/val/test splits, and optimizer to the Engine rather than writing a manual training loop.
# Engine handles: batch iteration, loss backward, logging, checkpointing
solver = core.Engine(
task, train_set, valid_set, test_set, optimizer,
batch_size=32,
log_interval=100,
gpus=[0, 1], # multi-GPU support
)
solver.train(num_epoch=100)
solver.save("checkpoint.pth")
Common Workflows
Workflow 1: End-to-End ADMET Property Prediction
Goal: Train a GIN model to predict blood-brain barrier penetration from SMILES, then predict on new compounds.
import torch
import pandas as pd
from torchdrug import data, datasets, models, tasks, core
# 1. Load dataset
dataset = datasets.BBBP("~/data/bbbp", node_feature="default", edge_feature="default")
train, val, test = dataset.split()
print(f"BBBP: {len(train)} train, {len(val)} val, {len(test)} test molecules")
# 2. Build model
model = models.GIN(
input_dim=dataset.node_feature_dim,
hidden_dims=[256, 256],
short_cut=True, batch_norm=True, concat_hidden=True,
)
task = tasks.PropertyPrediction(
model, task=dataset.tasks,
criterion="bce", metric=("auroc", "auprc"),
)
# 3. Train
optimizer = torch.optim.Adam(task.parameters(), lr=1e-3)
solver = core.Engine(task, train, val, test, optimizer,
batch_size=32, log_interval=50)
solver.train(num_epoch=100)
metrics = solver.evaluate("test")
print(f"Test AUROC: {metrics['auroc']:.4f}")
# 4. Predict on new SMILES
new_smiles = ["CC(=O)Oc1ccccc1C(=O)O", "c1ccc(cc1)N"]
task.eval()
with torch.no_grad():
for smi in new_smiles:
mol = data.Molecule.from_smiles(smi, node_feature="default", edge_feature="default")
batch = data.Batch.from_data_list([mol])
pred = task.predict(batch)
print(f" {smi}: BBB penetration probability = {pred.sigmoid().item():.3f}")
Workflow 2: Multi-Task Property Prediction on Tox21
Goal: Simultaneously predict 12 toxicity endpoints using a shared GNN encoder.
import torch
from torchdrug import datasets, models, tasks, core
# Tox21: 12 toxicity assays, multi-label classification
dataset = datasets.Tox21("~/data/tox21", node_feature="default", edge_feature="default")
train, val, test = dataset.split()
print(f"Tox21 tasks ({len(dataset.tasks)}): {dataset.tasks}")
model = models.GIN(
input_dim=dataset.node_feature_dim,
hidden_dims=[300, 300, 300],
short_cut=True, batch_norm=True, concat_hidden=True,
)
# Multi-task: one output head per toxicity assay
task = tasks.PropertyPrediction(
model, task=dataset.tasks,
criterion="bce",
metric=("auroc",),
num_mlp_layer=2,
)
optimizer = torch.optim.Adam(task.parameters(), lr=1e-3)
solver = core.Engine(task, train, val, test, optimizer, batch_size=64)
solver.train(num_epoch=100)
metrics = solver.evaluate("test")
for name, val_score in metrics.items():
print(f" {name}: {val_score:.4f}")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
hidden_dims |
GIN/MPNN/GAT | [256, 256] |
list of int | Width and depth of GNN layers |
short_cut |
GIN | False |
True, False |
Add residual connection between layers |
batch_norm |
GIN/MPNN | False |
True, False |
Apply batch normalization after each layer |
concat_hidden |
GIN | False |
True, False |
Concatenate all layer outputs as final representation |
num_mlp_layer |
PropertyPrediction | 1 |
1–4 |
Depth of MLP prediction head after GNN |
criterion |
PropertyPrediction | "mse" |
"mse", "bce", "ce" |
Loss function: regression, binary/multi-label classification |
batch_size |
Engine | 32 |
8–512 |
Training batch size |
Best Practices
-
Use
concat_hidden=Truefor GIN on small datasets: Concatenating all layer outputs provides a richer molecular representation and often improves performance when training data is limited (<10,000 molecules). -
Apply
batch_norm=Truefor training stability: Batch normalization reduces sensitivity to learning rate and initialization, especially with deep GNNs (3+ layers). -
Start with pretrained GNN weights for small datasets: TorchDrug's model zoo provides GINs pretrained on ChEMBL via self-supervised learning. Fine-tuning from these outperforms random initialization on datasets <1,000 molecules.
-
Validate on scaffold splits, not random splits: Random train/test splits overestimate generalization because structurally similar molecules appear in both sets. Use
dataset.split(test_scaffold_ratio=0.1)for more realistic evaluation. -
Handle missing labels in multi-task datasets: Many MoleculeNet datasets (Tox21, SIDER) have missing assay values. TorchDrug's
PropertyPredictiontask handles NaN labels automatically, but verify that missing rates are not too high for rare assays.
Common Recipes
Recipe: Generate Molecular Embeddings for Clustering
When to use: Visualize a molecular library in embedding space or use GNN features in scikit-learn models.
import torch
import numpy as np
from torchdrug import data, models
model = models.GIN(input_dim=39, hidden_dims=[300, 300], concat_hidden=True)
model.eval()
smiles_list = ["CC(=O)O", "c1ccccc1", "CCN", "CC(=O)Oc1ccccc1C(=O)O"]
embeddings = []
with torch.no_grad():
for smi in smiles_list:
mol = data.Molecule.from_smiles(smi, node_feature="default")
batch = data.Batch.from_data_list([mol])
graph_feat = model(batch, batch.node_feature.float())["graph_feature"]
embeddings.append(graph_feat.squeeze(0).numpy())
emb_matrix = np.stack(embeddings)
print(f"Embedding matrix: {emb_matrix.shape}") # (N_mols, embed_dim)
Recipe: Custom Molecular Dataset from CSV
When to use: Training on proprietary assay data rather than benchmark datasets.
from torchdrug import data
import torch
class CustomDataset(data.MoleculeDataset):
def __init__(self, csv_path, smiles_col="smiles", label_col="activity"):
import pandas as pd
df = pd.read_csv(csv_path).dropna(subset=[smiles_col])
smiles_list = df[smiles_col].tolist()
targets = df[label_col].tolist()
self.load_smiles(smiles_list, {"activity": targets},
node_feature="default", edge_feature="default")
self.tasks = ["activity"]
dataset = CustomDataset("assay_data.csv", smiles_col="smiles", label_col="pIC50")
print(f"Custom dataset: {len(dataset)} molecules")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: torchdrug |
Package not installed | pip install torchdrug after installing PyTorch |
CUDA error: device-side assert |
Label dtype mismatch | Ensure regression labels are float, classification labels are long |
| Poor test metrics with small dataset | Overfitting | Use pretrained weights, add dropout, or reduce model depth |
KeyError: task name in dataset.tasks |
Task name mismatch | Print dataset.tasks to see exact task names; pass the same list to PropertyPrediction |
RuntimeError: Expected all tensors on same device |
Mixed CPU/GPU tensors | Use solver = core.Engine(..., gpus=[0]) to ensure consistent device placement |
| Slow training | CPU-only mode | Install CUDA-compatible PyTorch; set gpus=[0] in Engine |
| Missing assay values cause NaN loss | Dataset has missing labels | Set criterion="bce" — TorchDrug masks NaN labels during loss computation |
Related Skills
rdkit— molecular fingerprints and cheminformatics preprocessing before TorchDrugdiffdock— structure-based docking complementary to TorchDrug's ligand-based prediction
References
- TorchDrug Documentation — official docs and tutorials
- TorchDrug GitHub (DeepGraphLearning/torchdrug) — source code
- Zhu et al. (2022), arXiv — TorchDrug paper — original platform paper
- MoleculeNet Benchmark — standardized datasets used in TorchDrug
skills/structural-biology-drug-discovery/unichem-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill unichem-database -g -y
SKILL.md
Frontmatter
{
"name": "unichem-database",
"license": "Apache-2.0",
"description": "Cross-reference compound IDs across 20+ databases (ChEMBL, DrugBank, PubChem, ChEBI, PDB, SureChEMBL, HMDB, DrugCentral, BindingDB) via UniChem REST API. Resolve InChIKeys to source IDs, translate between source-specific IDs, find structurally related compounds by connectivity. POST with a JSON body for all cross-reference queries; only \/sources is GET. No auth required."
}
UniChem Database
Overview
UniChem is a chemical structure cross-referencing service from EMBL-EBI that links compound records across 20+ public chemistry databases using InChI-based identifiers. It maps a single chemical entity to its corresponding IDs in ChEMBL, DrugBank, PubChem, ChEBI, PDB (RCSB and PDBe), SureChEMBL, HMDB, DrugCentral, BindingDB, and others. Access is via a free REST API at https://www.ebi.ac.uk/unichem/api/v1/ - no API key required. Important: every cross-reference query is sent as POST with a JSON body; only the catalogue endpoint GET /sources is implemented as a GET.
When to Use
- Translating a ChEMBL compound ID to a PubChem CID, DrugBank accession, or ChEBI ID for cross-database analysis
- Resolving an InChIKey to all database sources where a compound appears
- Finding all structurally related compounds (same connectivity, different stereochemistry/salts) across databases using connectivity search
- Validating compound identity across sources before merging datasets from multiple databases
- Building a compound cross-reference table for a drug discovery project (linking bioactivity data in ChEMBL to structural data in PDB)
- Checking if a synthesized compound or a vendor compound exists in any public database by InChIKey
- For full bioactivity profiles (IC50, Ki) use chembl-database-bioactivity; UniChem provides only ID cross-references, not experimental data
- For compound property prediction or substructure searching use pubchem-compound-search; UniChem is for identifier translation only
Prerequisites
- Python packages: requests, pandas, matplotlib
- Data requirements: compound InChIKeys (standard 27-character XXXXXXXXXXXXXX-XXXXXXXXXX-X), source-specific IDs (e.g. CHEMBL25), or PubChem CIDs as starting points
- Environment: internet connection; no API key required
- Rate limits: ~10 requests/second; add time.sleep(0.1) between requests in batch loops; no daily quota
pip install requests pandas matplotlib
Quick Start
The UniChem /compounds endpoint is POST-only - GET returns 405 Method Not Allowed. Submit a JSON body {type: inchikey, compound: KEY} and read per-database hits from compounds[0][sources]. Each source record carries an id (numeric database ID) and a compoundId (the ID in that database).
import requests
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def unichem_post(endpoint: str, body: dict) -> dict:
"""POST request to UniChem API; raise on HTTP errors."""
r = requests.post(f"{UNICHEM_API}/{endpoint}", json=body, timeout=20)
r.raise_for_status()
return r.json()
# Find all database sources for aspirin by InChIKey
inchikey = "BSYNRYMUTXBXSQ-UHFFFAOYSA-N" # aspirin
result = unichem_post("compounds", {"type": "inchikey", "compound": inchikey})
compounds = result.get("compounds", [])
print(f"Found {len(compounds)} compound record(s) for {inchikey}")
if compounds:
sources = compounds[0].get("sources", [])
print(f" Present in {len(sources)} database records")
seen = set()
for src in sources:
if src["id"] in seen:
continue
seen.add(src["id"])
print(f" source id={src['id']:>3} ({src['shortName']:>12}): {src['compoundId']}")
if len(seen) >= 5:
break
# Found 1 compound record(s) for BSYNRYMUTXBXSQ-UHFFFAOYSA-N
# Present in many database records
# source id= 1 ( chembl): CHEMBL25
# source id= 2 ( drugbank): DB00945
# source id= 3 ( rcsb_pdb): AIN
Core API
Query 1: InChIKey Lookup - All Sources
Search for a compound by its standard InChIKey and retrieve all database records. This is the primary cross-reference method. The endpoint is POST /compounds; the response carries one entry in compounds (if found), each with a sources list whose records use id (source database) and compoundId (the ID in that database).
import requests, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
# Common source IDs (verify with the /sources endpoint - see Query 4)
SOURCE_NAMES = {
1: "ChEMBL", 2: "DrugBank", 3: "RCSB PDB", 4: "GtoPdb", 5: "PDBe",
7: "ChEBI", 14: "FDA SRS", 15: "SureChEMBL", 18: "HMDB", 22: "PubChem",
31: "BindingDB", 32: "CompTox", 33: "LIPID MAPS", 34: "DrugCentral",
37: "BRENDA", 38: "Rhea", 41: "SwissLipids", 49: "Probes-and-Drugs",
}
def lookup_by_inchikey(inchikey: str) -> pd.DataFrame:
"""Return all database cross-references for an InChIKey."""
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": inchikey}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if not compounds:
return pd.DataFrame()
rows = []
for src in compounds[0].get("sources", []):
rows.append({
"source_id": src["id"],
"source_name": SOURCE_NAMES.get(src["id"], src.get("shortName", "")),
"compound_id": src["compoundId"],
"url": src.get("url", ""),
})
return pd.DataFrame(rows).sort_values(["source_id", "compound_id"])
# Triclosan cross-references
df = lookup_by_inchikey("XEFQLINVKFYRCS-UHFFFAOYSA-N")
print(f"Triclosan found in {df['source_id'].nunique()} distinct databases ({len(df)} records):")
print(df[["source_name", "compound_id"]].head(8).to_string(index=False))
# Triclosan found in 16 distinct databases
# ChEMBL CHEMBL849
# DrugBank DB08604
# RCSB PDB TCL
# ChEBI CHEBI:164200
# Extract specific source IDs from cross-reference table
def get_id_for_source(inchikey: str, source_id: int) -> str | None:
"""Return the compound ID in a specific database, or None if not found."""
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": inchikey}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if not compounds:
return None
for src in compounds[0].get("sources", []):
if src["id"] == source_id:
return src["compoundId"]
return None
triclosan = "XEFQLINVKFYRCS-UHFFFAOYSA-N"
chembl_id = get_id_for_source(triclosan, source_id=1) # ChEMBL
pubchem_id = get_id_for_source(triclosan, source_id=22) # PubChem
drugbank_id = get_id_for_source(triclosan, source_id=2) # DrugBank
print(f"Triclosan: ChEMBL={chembl_id}, PubChem={pubchem_id}, DrugBank={drugbank_id}")
# Triclosan: ChEMBL=CHEMBL849, PubChem=5564, DrugBank=DB08604
Query 2: Compound Lookup by Source-Specific ID
Given a known compound ID in a specific source database (e.g., a ChEMBL ID), retrieve all cross-references. Use type: sourceID with the compound s source ID alongside the numeric sourceID in the body. Returns the same data shape as the InChIKey lookup.
import requests
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def get_sources_for_compound(compound_id: str, source_id: int) -> list:
"""Get all database cross-references for a compound identified in a specific source.
Args:
compound_id: The ID in the source database (e.g., CHEMBL192)
source_id: UniChem source ID (1=ChEMBL, 2=DrugBank, 22=PubChem, 7=ChEBI)
"""
body = {"type": "sourceID", "compound": compound_id, "sourceID": source_id}
r = requests.post(f"{UNICHEM_API}/compounds", json=body, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if not compounds:
return []
return compounds[0].get("sources", [])
# Sildenafil (Viagra): look up starting from ChEMBL ID
sources = get_sources_for_compound("CHEMBL192", source_id=1)
distinct_dbs = {s["id"] for s in sources}
print(f"Sildenafil (CHEMBL192): {len(sources)} source records across {len(distinct_dbs)} databases")
seen = set()
for s in sources:
if s["id"] in seen:
continue
seen.add(s["id"])
print(f" [{s['id']:>3}] {s['shortName']:>15}: {s['compoundId']}")
if len(seen) >= 8:
break
# Sildenafil (CHEMBL192): 272 source records across 19 databases
Query 3: Connectivity Search - Structural Relatives
Find compounds with the same core structure but different stereochemistry, salt forms, isotopic labeling, or protonation. The endpoint is POST /connectivity and accepts a full standard InChIKey (the API rejects 14-character fragments with 404 Not found). Internally UniChem uses the connectivity layer for matching but returns hits that may differ at the stereo/charge/isotope layers.
Unlike /compounds, the connectivity response returns a flat sources list (one entry per database hit across all relatives) plus the queried searchedCompound and totalCompounds/totalSources summary fields. Each hit s comparison dict shows which InChI layers matched.
import requests, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def connectivity_search(inchikey: str) -> dict:
"""Find all compounds related by InChI connectivity (same skeleton, possibly different stereo/salts).
Pass a FULL standard InChIKey (27 chars). The server rejects 14-char fragments.
"""
body = {"type": "inchikey", "compound": inchikey}
r = requests.post(f"{UNICHEM_API}/connectivity", json=body, timeout=30)
r.raise_for_status()
return r.json()
# Warfarin: find all stereoforms, racemates, and salt forms
warfarin_inchikey = "PJVWKTKQMONHTI-UHFFFAOYSA-N"
data = connectivity_search(warfarin_inchikey)
print(f"Warfarin connectivity relatives: {data['totalCompounds']} unique compounds, "
f"{data['totalSources']} database records")
hits = data.get("sources", [])
by_source = {}
for h in hits:
by_source.setdefault(h["shortName"], []).append(h["compoundId"])
for name, ids in sorted(by_source.items(), key=lambda kv: -len(kv[1]))[:8]:
print(f" {name:>15}: {len(ids):>4} IDs (e.g. {ids[0]})")
# Warfarin connectivity relatives: 13 unique compounds, 353 database records
# Compare source coverage across connectivity relatives
def compare_coverage(inchikey: str) -> pd.DataFrame:
"""Show connectivity relatives split by their source-database coverage."""
data = connectivity_search(inchikey)
rows = []
for src in data.get("sources", []):
rows.append({
"source_id": src["id"],
"source_name": src["shortName"],
"compound_id": src["compoundId"],
"stereo_match": src["comparison"].get("stereoSp3", False),
"salt_match": src["comparison"].get("protonation", False),
})
return pd.DataFrame(rows)
df = compare_coverage("PJVWKTKQMONHTI-UHFFFAOYSA-N")
print(f"Total records: {len(df)}")
print(df.head(10).to_string(index=False))
print(f"Records with stereo mismatch (skeleton matches, stereo differs): {(~df['stereo_match']).sum()}")
Query 4: List All Data Sources
Retrieve the full list of UniChem data sources with their IDs, names, descriptions, and website URLs. This is the only endpoint served by GET. The response uses sourceID (capital ID) inside each source entry.
import requests, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def list_sources() -> pd.DataFrame:
"""Return all UniChem data sources as a DataFrame."""
r = requests.get(f"{UNICHEM_API}/sources", timeout=15)
r.raise_for_status()
sources = r.json().get("sources", [])
rows = []
for s in sources:
rows.append({
"source_id": s["sourceID"],
"name": s.get("nameLong") or s.get("nameLabel", ""),
"label": s.get("nameLabel", ""),
"short_name": s.get("name", ""),
"uci_count": s.get("UCICount"),
"url": s.get("baseIdUrl", ""),
})
return pd.DataFrame(rows).sort_values("source_id")
sources_df = list_sources()
print(f"Total UniChem sources: {len(sources_df)}")
print(sources_df[["source_id", "label", "uci_count"]].to_string(index=False))
# Total UniChem sources: 23
# 1 ChEMBL 2854815
# 2 DrugBank 14622
# 22 PubChem 123392679
Query 5: Per-Compound Loop (No Batch Endpoint)
UniChem does not support a list-batch shape - the /compounds POST accepts only a single compound per request. Submitting {compounds: [...]} or {inchikeys: [...]} returns 400 illegal_argument_exception. For multiple inputs, iterate with a small sleep to respect the ~10 req/s rate limit.
import requests, time, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def batch_translate(inchikeys: list[str],
target_source_ids=(1, 2, 7, 22)) -> pd.DataFrame:
"""Translate a list of InChIKeys to IDs in multiple target databases.
Loops one POST per InChIKey (UniChem has no list-batch endpoint).
"""
SOURCE_NAMES = {1: "chembl", 2: "drugbank", 3: "pdb", 7: "chebi",
14: "fda_srs", 22: "pubchem", 34: "drugcentral"}
rows = []
for ik in inchikeys:
row = {"inchikey": ik}
for sid in target_source_ids:
row[SOURCE_NAMES.get(sid, f"src_{sid}")] = None
try:
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": ik}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if compounds:
for src in compounds[0].get("sources", []):
if src["id"] in target_source_ids:
col = SOURCE_NAMES.get(src["id"], f"src_{src['id']}")
if row[col] is None:
row[col] = src["compoundId"]
except requests.RequestException as e:
row["error"] = str(e)
rows.append(row)
time.sleep(0.1) # respect ~10 req/s rate limit
return pd.DataFrame(rows)
# Translate a set of NSAIDs by InChIKey
nsaid_inchikeys = [
"BSYNRYMUTXBXSQ-UHFFFAOYSA-N", # aspirin
"HEFNNWSXXWATRW-UHFFFAOYSA-N", # ibuprofen
"CMWTZPSULFXXJA-VIFPVBQESA-N", # naproxen
"DCOPUUMXTXDBNB-UHFFFAOYSA-N", # diclofenac (free acid)
]
df = batch_translate(nsaid_inchikeys, target_source_ids=[1, 2, 7, 22])
print(df.to_string(index=False))
df.to_csv("nsaid_xrefs.csv", index=False)
print(f"Saved nsaid_xrefs.csv ({len(df)} compounds)")
Query 6: Per-Compound Loop with Source-ID Inputs
When the starting identifiers are not InChIKeys but source-specific IDs (e.g., a list of ChEMBL IDs from a bioactivity table), use type=sourceID and loop, again one POST per ID.
import requests, time
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def translate_source_ids(ids: list[str], from_source: int,
to_sources=(2, 22, 34)) -> list[dict]:
"""Translate IDs in one source database to IDs in target databases.
Args:
ids: list of compound IDs in the from_source database
from_source: source ID of the input list (1=ChEMBL, 22=PubChem, ...)
to_sources: iterable of target source IDs to extract
"""
out = []
for cid in ids:
body = {"type": "sourceID", "compound": cid, "sourceID": from_source}
r = requests.post(f"{UNICHEM_API}/compounds", json=body, timeout=20)
row = {"input": cid}
if r.ok:
compounds = r.json().get("compounds", [])
if compounds:
row["inchikey"] = compounds[0].get("standardInchiKey")
hits = {s["id"]: s["compoundId"] for s in compounds[0].get("sources", [])}
for tsid in to_sources:
row[f"src_{tsid}"] = hits.get(tsid)
out.append(row)
time.sleep(0.1)
return out
# Three kinase inhibitors known by ChEMBL ID
chembl_inputs = ["CHEMBL535", "CHEMBL553", "CHEMBL941"] # nilotinib, dasatinib, imatinib
rows = translate_source_ids(chembl_inputs, from_source=1, to_sources=(2, 22, 34))
for row in rows:
ik = (row.get("inchikey") or "?")[:14]
print(f"{row['input']:>10} ik={ik}... "
f"DrugBank={row.get('src_2')}, PubChem={row.get('src_22')}, "
f"DrugCentral={row.get('src_34')}")
Key Concepts
InChI vs InChIKey
UniChem uses the InChI (IUPAC International Chemical Identifier) and its hashed form the InChIKey as the canonical compound identity. The InChIKey is a 27-character string split into three blocks: the first 14 characters encode the connectivity layer (heavy atoms and bonds), the next 8 encode stereochemistry and charge, and the last character is a version flag. UniChem cross-references compounds by requiring identical standard InChIKeys, ensuring the same chemical entity across databases.
Source ID Reference Table (verified live against /sources)
| Source ID | Database | Scope |
|---|---|---|
| 1 | ChEMBL | Bioactive molecules, drug discovery |
| 2 | DrugBank | Approved drugs, pharmacology |
| 3 | RCSB PDB | Ligands in crystal structures (US) |
| 4 | Guide to Pharmacology | Pharmacology targets/ligands |
| 5 | PDBe | Ligands in crystal structures (Europe) |
| 7 | ChEBI | Chemical ontology, metabolites |
| 14 | FDA SRS | FDA Substance Registration System |
| 15 | SureChEMBL | Patent chemistry |
| 18 | HMDB | Human Metabolome Database |
| 22 | PubChem | General compound repository |
| 31 | BindingDB | Binding affinity data |
| 32 | CompTox | Environmental tox dashboard |
| 33 | LIPID MAPS | Lipid structures |
| 34 | DrugCentral | Approved drugs + pharmacology |
| 37 | BRENDA | Enzyme substrates/products |
| 38 | Rhea | Biochemical reactions |
| 41 | SwissLipids | Lipid structures |
| 49 | Probes-and-Drugs | Chemical probes |
Field Naming: id vs sourceID
This is the single most common error when scripting UniChem. The two endpoints use different field names for the source database identifier:
- GET /sources lists databases as objects with a sourceID field (capital ID).
- POST /compounds and POST /connectivity return per-database hits inside sources lists, where the source identifier is a plain id field. There is no sourceID or sourceId key on these per-hit records.
Always use src["id"] when iterating compound or connectivity responses, and use s["sourceID"] when iterating the /sources catalogue.
Connectivity vs Standard InChIKey Matching
POST /compounds returns exact InChIKey matches (same stereo, salt, isotopes). POST /connectivity returns all compounds sharing the bond topology - useful for finding racemates, stereoisomers, free acids/bases, and co-crystal partners. The connectivity response includes a comparison dict per hit indicating which InChI layers matched (stereoSp3, protonation, isotope, etc.); use it to filter for same skeleton, different stereo only relatives.
Common Workflows
Workflow 1: Drug Compound Cross-Reference Report
Goal: Given a list of drug names (or ChEMBL IDs), resolve each to all major database IDs and export to CSV.
import requests, time, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
CHEMBL_API = "https://www.ebi.ac.uk/chembl/api/data"
SOURCE_NAMES = {1: "chembl", 2: "drugbank", 3: "rcsb_pdb",
7: "chebi", 22: "pubchem", 14: "fda_srs", 34: "drugcentral"}
def chembl_to_inchikey(chembl_id: str) -> str | None:
"""Look up the standard InChIKey for a ChEMBL compound ID."""
r = requests.get(f"{CHEMBL_API}/molecule/{chembl_id}.json", timeout=15)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json().get("molecule_structures", {}).get("standard_inchi_key")
def inchikey_to_sources(inchikey: str) -> dict:
"""Return source_id -> compound_id dict for an InChIKey (first hit per source)."""
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": inchikey}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if not compounds:
return {}
out = {}
for s in compounds[0].get("sources", []):
out.setdefault(s["id"], s["compoundId"])
return out
# Example: top cardiovascular drugs
drug_chembl_ids = {
"atorvastatin": "CHEMBL1487",
"lisinopril": "CHEMBL1237",
"metoprolol": "CHEMBL13",
"amlodipine": "CHEMBL1491",
"warfarin": "CHEMBL1464",
}
rows = []
for name, chembl_id in drug_chembl_ids.items():
ik = chembl_to_inchikey(chembl_id)
row = {"drug": name, "chembl_id": chembl_id, "inchikey": ik}
if ik:
srcs = inchikey_to_sources(ik)
for sid, col in SOURCE_NAMES.items():
row[col] = srcs.get(sid)
rows.append(row)
time.sleep(0.2)
df = pd.DataFrame(rows)
df.to_csv("drug_xrefs.csv", index=False)
print(df[["drug", "chembl", "drugbank", "pubchem", "chebi"]].to_string(index=False))
print(f"Saved drug_xrefs.csv ({len(df)} drugs)")
Workflow 2: Structural Relatives Discovery and Visualization
Goal: Find all structural relatives of a compound, summarize their database coverage, and plot a bar chart showing source distribution.
import requests, pandas as pd
import matplotlib.pyplot as plt
from collections import Counter
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
SOURCE_NAMES = {1: "ChEMBL", 2: "DrugBank", 3: "RCSB PDB", 5: "PDBe",
7: "ChEBI", 14: "FDA SRS", 15: "SureChEMBL",
22: "PubChem", 31: "BindingDB", 34: "DrugCentral"}
# Aspirin connectivity relatives (covers acetylsalicylate salts)
query_inchikey = "BSYNRYMUTXBXSQ-UHFFFAOYSA-N"
r = requests.post(f"{UNICHEM_API}/connectivity",
json={"type": "inchikey", "compound": query_inchikey}, timeout=30)
r.raise_for_status()
data = r.json()
hits = data.get("sources", [])
print(f"Aspirin connectivity relatives: {data['totalCompounds']} unique compounds, "
f"{len(hits)} database records")
# Count how often each named database appears
source_counter = Counter()
for h in hits:
if h["id"] in SOURCE_NAMES:
source_counter[SOURCE_NAMES[h["id"]]] += 1
labels = [k for k, _ in source_counter.most_common()]
counts = [v for _, v in source_counter.most_common()]
fig, ax = plt.subplots(figsize=(9, 4))
bars = ax.bar(labels, counts, color="#2E86AB", edgecolor="white")
ax.bar_label(bars, padding=2)
ax.set_xlabel("Database")
ax.set_ylabel("Number of Source Records (relatives x hits)")
ax.set_title("UniChem Connectivity Records - Aspirin Skeleton")
plt.xticks(rotation=30, ha="right")
plt.tight_layout()
plt.savefig("unichem_connectivity_coverage.png", dpi=150, bbox_inches="tight")
print("Saved unichem_connectivity_coverage.png")
plt.close(fig)
# DataFrame summary by source
df = pd.DataFrame(source_counter.most_common(), columns=["database", "records"])
print(df.to_string(index=False))
Workflow 3: Merge ChEMBL Bioactivity with PubChem CIDs
Goal: Augment a ChEMBL bioactivity table with PubChem CIDs for downstream analysis in tools that use PubChem identifiers.
import requests, time, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def add_pubchem_cids(df: pd.DataFrame,
inchikey_col: str = "standard_inchi_key") -> pd.DataFrame:
"""Add pubchem_cid column to a DataFrame that has an InChIKey column."""
unique_keys = df[inchikey_col].dropna().unique()
mapping = {}
for ik in unique_keys:
try:
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": ik}, timeout=15)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if compounds:
for src in compounds[0].get("sources", []):
if src["id"] == 22: # PubChem
mapping[ik] = src["compoundId"]
break
except requests.RequestException:
pass
time.sleep(0.1)
df = df.copy()
df["pubchem_cid"] = df[inchikey_col].map(mapping)
return df
# Simulate a small ChEMBL activity table
chembl_data = pd.DataFrame({
"compound_name": ["aspirin", "ibuprofen", "naproxen"],
"standard_inchi_key": [
"BSYNRYMUTXBXSQ-UHFFFAOYSA-N",
"HEFNNWSXXWATRW-UHFFFAOYSA-N",
"CMWTZPSULFXXJA-VIFPVBQESA-N",
],
"ic50_nm": [2500.0, 13000.0, 1600.0],
})
enriched = add_pubchem_cids(chembl_data)
print(enriched[["compound_name", "ic50_nm", "pubchem_cid"]].to_string(index=False))
enriched.to_csv("chembl_with_pubchem.csv", index=False)
print("Saved chembl_with_pubchem.csv")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
| type (body) | POST /compounds, POST /connectivity | - | inchikey, sourceID | Selects the kind of identifier in compound. inchikey requires a 27-char standard InChIKey; sourceID also requires the numeric sourceID field. |
| compound (body) | POST /compounds, POST /connectivity | - | One string (no list) | The identifier to look up. UniChem does NOT support a list-batch shape; loop one POST per compound. |
| sourceID (body) | POST /compounds (with type=sourceID) | - | Integer DB id from /sources | The source database the input compound belongs to (1=ChEMBL, 2=DrugBank, 22=PubChem, ...). |
| Per-hit id (response) | POST /compounds, POST /connectivity | - | Integer | Source database id of each hit in sources. Use src["id"], NOT src["sourceID"] for these per-hit records. |
| sourceID (response) | GET /sources | - | Integer | Numeric ID of each database entry in the catalogue (capital ID). |
| timeout | All requests | 20s | Any positive integer | Seconds before request fails; raise to 30s for /connectivity on common skeletons. |
Best Practices
-
Use POST everywhere except /sources: GET /compounds returns 405 Method Not Allowed; GET /compounds/{src}/{id} and GET /connectivity/{key} return 404. The only GET endpoint is /sources. Always POST with a JSON body.
-
Use the correct field - id on hits, sourceID on /sources: Inside the sources list returned by POST /compounds and POST /connectivity, each record s source database is in the id key. Reading src["sourceID"] raises KeyError. The capital-ID sourceID field only appears in the catalogue returned by GET /sources.
-
Use standard (not non-standard) InChIKeys: UniChem indexes standard InChIKeys. Non-standard InChIKeys will return no results. Verify with: from rdkit.Chem.inchi import MolToInchiKey; MolToInchiKey(mol).
-
Fall back to connectivity search when exact match fails: If a compound is in DrugBank as a salt (e.g., hydrochloride) but you have the free base InChIKey, the standard lookup will miss it. Run a connectivity search as a fallback for drug cross-referencing.
-
Pass the full InChIKey to /connectivity: The endpoint expects a complete 27-character standard InChIKey. Submitting only the 14-char connectivity fragment returns {response: Not found}. UniChem strips the stereo/charge layers internally.
-
Cache the source list on startup: Call /sources once and build a {sourceID: nameLabel} dict rather than hard-coding IDs.
def load_source_map() -> dict:
r = requests.get(f"{UNICHEM_API}/sources", timeout=15)
r.raise_for_status()
return {s["sourceID"]: s.get("nameLabel", str(s["sourceID"]))
for s in r.json().get("sources", [])}
- No batch endpoint exists - loop with sleep: Submitting {compounds: [...]} or {inchikeys: [...]} returns 400 illegal_argument_exception. Iterate POSTs with time.sleep(0.1) between calls (~10 req/s).
Common Recipes
Recipe: Resolve Any Compound ID to InChIKey
When to use: You have a PubChem CID or ChEBI ID and need the InChIKey to query UniChem or other services.
import requests
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
PUBCHEM_API = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
def pubchem_cid_to_inchikey(cid) -> str | None:
"""Resolve a PubChem CID to a standard InChIKey via PubChem."""
r = requests.get(f"{PUBCHEM_API}/compound/cid/{cid}/property/InChIKey/JSON", timeout=10)
if r.status_code == 404:
return None
r.raise_for_status()
props = r.json()["PropertyTable"]["Properties"]
return props[0]["InChIKey"] if props else None
def cid_to_all_sources(cid) -> list:
"""PubChem CID -> InChIKey -> UniChem cross-references."""
ik = pubchem_cid_to_inchikey(cid)
if not ik:
return []
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": ik}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
return compounds[0].get("sources", []) if compounds else []
sources = cid_to_all_sources(2244) # PubChem CID for aspirin
distinct = {s["id"] for s in sources}
print(f"Aspirin (CID=2244) is in {len(distinct)} UniChem databases ({len(sources)} records)")
seen = set()
for s in sources:
if s["id"] in seen:
continue
seen.add(s["id"])
print(f" [{s['id']:>3}] {s['shortName']:>15}: {s['compoundId']}")
if len(seen) >= 6:
break
Recipe: Check If a Compound Is an Approved Drug
When to use: Quickly flag whether a compound appears in DrugBank (source 2) using its InChIKey.
import requests
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
def is_approved_drug(inchikey: str) -> tuple[bool, str | None]:
"""Check if compound appears in DrugBank (id=2). Returns (is_drug, DrugBank_ID)."""
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": inchikey}, timeout=20)
r.raise_for_status()
compounds = r.json().get("compounds", [])
if not compounds:
return False, None
for src in compounds[0].get("sources", []):
if src["id"] == 2: # DrugBank
return True, src["compoundId"]
return False, None
# Test a few compounds
test_compounds = {
"aspirin": "BSYNRYMUTXBXSQ-UHFFFAOYSA-N",
"triclosan": "XEFQLINVKFYRCS-UHFFFAOYSA-N",
"sildenafil": "BNRNXUUZRGQAQC-UHFFFAOYSA-N",
}
for name, ik in test_compounds.items():
is_drug, db_id = is_approved_drug(ik)
status = f"DrugBank:{db_id}" if is_drug else "not in DrugBank"
print(f"{name:>12}: {status}")
Recipe: Source Coverage Summary for a Compound Set
When to use: Audit which databases cover your compound list - useful before choosing which database to use for downstream analysis.
import requests, time, pandas as pd
UNICHEM_API = "https://www.ebi.ac.uk/unichem/api/v1"
SOURCE_NAMES = {1: "ChEMBL", 2: "DrugBank", 3: "RCSB PDB", 7: "ChEBI",
14: "FDA SRS", 22: "PubChem", 34: "DrugCentral"}
def source_coverage_matrix(inchikeys: list[str]) -> pd.DataFrame:
"""Return a boolean matrix: rows=compounds, columns=databases."""
rows = []
for ik in inchikeys:
r = requests.post(f"{UNICHEM_API}/compounds",
json={"type": "inchikey", "compound": ik}, timeout=15)
row = {"inchikey": ik}
for sid, name in SOURCE_NAMES.items():
row[name] = False
if r.ok:
compounds = r.json().get("compounds", [])
if compounds:
ids_present = {s["id"] for s in compounds[0].get("sources", [])}
for sid, name in SOURCE_NAMES.items():
row[name] = sid in ids_present
rows.append(row)
time.sleep(0.1)
return pd.DataFrame(rows)
sample_keys = [
"BSYNRYMUTXBXSQ-UHFFFAOYSA-N", # aspirin
"HEFNNWSXXWATRW-UHFFFAOYSA-N", # ibuprofen
"XEFQLINVKFYRCS-UHFFFAOYSA-N", # triclosan
]
coverage = source_coverage_matrix(sample_keys)
print(coverage.to_string(index=False))
print("Coverage per database:")
for col in list(SOURCE_NAMES.values()):
print(f" {col}: {coverage[col].sum()}/{len(coverage)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| 405 Method Not Allowed on /compounds | Hitting the endpoint with GET | UniChem /compounds is POST-only. Send requests.post(url, json={type:inchikey, compound:ik}). |
| KeyError: sourceID (or sourceId) on per-hit records | Reading the wrong field name | Use src["id"] for hits inside compounds[0][sources] and the sources list from /connectivity. sourceID only exists in the /sources catalogue. |
| 400 illegal_argument_exception field name is null or empty | Tried {compounds: [...]} or {inchikeys: [...]} batch | No batch endpoint exists. Loop with time.sleep(0.1) between POSTs. |
| {response: Not found} from /connectivity | Passed only the 14-char fragment | Send the full 27-char standard InChIKey; UniChem strips the stereo layers internally. |
| Empty compounds list for a known compound | Non-standard InChIKey, salt-form mismatch, or compound missing | Verify the InChIKey with RDKit; try POST /connectivity to catch salt/stereo variants. |
| Too many records from connectivity | Same skeleton matches many SureChEMBL patent IDs | Filter by src["id"] to drop source 15 (SureChEMBL) or by comparison.stereoSp3 == True. |
| requests.exceptions.Timeout | Slow API response under load | Increase timeout to 30s for /connectivity; retry once with exponential backoff. |
| Source URL field is empty | Not all sources provide URL templates | Use baseIdUrl from the /sources endpoint combined with compoundId to construct links manually. |
Related Skills
- chembl-database-bioactivity - Query ChEMBL for bioactivity data (IC50, Ki) using the compound IDs resolved via UniChem
- pubchem-compound-search - Full compound property and bioassay queries using PubChem CIDs from UniChem
- pdb-database - Look up 3D ligand structures using PDB ligand codes (source 3 or 5) resolved via UniChem
- drugbank-database-access - Detailed pharmacology, ADMET, and drug interaction data using DrugBank IDs from UniChem
References
- UniChem API documentation - Official REST API reference with all endpoint descriptions
- UniChem home page - Interactive search interface and database listing
- Chambers et al., J Cheminform 2013 - Original UniChem publication describing the InChI-based cross-referencing methodology
- InChI Trust - InChI standard specification and algorithm documentation
skills/structural-biology-drug-discovery/zinc-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill zinc-database -g -y
SKILL.md
Frontmatter
{
"name": "zinc-database",
"license": "CC-BY-4.0",
"description": "Query ZINC15\/ZINC22 virtual compound libraries (1.4B compounds, 750M purchasable). Search lead\/fragment\/drug-like compounds by MW, logP, reactivity, or SMILES similarity; download 3D sets for docking. For bioactivity use chembl-database-bioactivity; for approved drugs use drugbank-database-access."
}
ZINC Chemical Library Database
Overview
ZINC (ZINC Is Not Commercial) is a free database of commercially available compounds curated for virtual screening. ZINC22 contains over 1.4 billion compounds (ZINC20: 1.4B, including purchasable 3D conformers), organized by molecular property filters (lead-like, fragment-like, drug-like) and reactivity class. The REST API enables SMILES-based searches, property-filtered downloads, and compound subset exports for docking campaigns.
When to Use
- Downloading a purchasable, drug-like or lead-like compound library for virtual screening or docking campaigns
- Filtering compounds by Lipinski/lead-like properties (MW, logP, HBD, HBA) to build focused screening sets
- Searching ZINC for commercially available analogs of a query molecule via SMILES similarity
- Retrieving purchasable fragments (MW < 300, logP < 3) for fragment-based drug discovery
- Building compound diversity libraries for high-throughput screening (HTS) campaigns
- For known drug bioactivity data use
chembl-database-bioactivity; for approved drug structures usedrugbank-database-access; for RDKit property calculation userdkit-cheminformatics
Prerequisites
- Python packages:
requests,pandas - Data requirements: SMILES strings, MW/logP ranges, or ZINC subset IDs
- Environment: internet connection; no API key needed for ZINC15; large downloads may take minutes
- Rate limits: reasonable use; avoid crawling all 1.4B records in automated loops
pip install requests pandas
Quick Start
import requests
# Search ZINC15 REST API for drug-like compounds
BASE = "https://zinc15.docking.org"
r = requests.get(f"{BASE}/substances.json",
params={"mwt__gte": 250, "mwt__lte": 350,
"logp__gte": 0, "logp__lte": 3,
"availability": "for-sale", "count": 5})
r.raise_for_status()
compounds = r.json()
print(f"Returned {len(compounds)} compounds")
for c in compounds[:3]:
print(f" ZINC: {c['zinc_id']:20s} MW: {c['mwt']:.1f} logP: {c['logp']:.2f} SMILES: {c['smiles'][:40]}")
Core API
Query 1: Property-Filtered Compound Search
Search ZINC15 by molecular property ranges (Lipinski, lead-like, fragment-like criteria).
import requests, pandas as pd
BASE = "https://zinc15.docking.org"
def zinc_search(params, max_results=500):
"""Search ZINC15 with property filters. Returns DataFrame."""
all_results = []
params = dict(params)
params["count"] = min(100, max_results)
r = requests.get(f"{BASE}/substances.json", params=params)
r.raise_for_status()
compounds = r.json()
all_results.extend(compounds)
return pd.DataFrame(all_results)
# Lead-like set: MW 250-350, logP 1-3, HBD ≤ 3
df_leads = zinc_search({
"mwt__gte": 250, "mwt__lte": 350,
"logp__gte": 1, "logp__lte": 3,
"hbd__lte": 3, "hba__lte": 7,
"availability": "for-sale",
})
print(f"Lead-like compounds: {len(df_leads)}")
print(df_leads[["zinc_id", "mwt", "logp", "smiles"]].head())
# Fragment-like set: MW < 300, logP < 3 (Rule of Three)
df_frags = zinc_search({
"mwt__lte": 300,
"logp__lte": 3,
"hbd__lte": 3,
"availability": "for-sale",
})
print(f"\nFragment-like compounds: {len(df_frags)}")
print(df_frags[["zinc_id", "mwt", "logp", "smiles"]].head())
Query 2: Retrieve Compound by ZINC ID
Fetch full compound data for a known ZINC identifier.
import requests
BASE = "https://zinc15.docking.org"
zinc_id = "ZINC000000029632"
r = requests.get(f"{BASE}/substances/{zinc_id}.json")
r.raise_for_status()
c = r.json()
print(f"ZINC ID : {c['zinc_id']}")
print(f"SMILES : {c['smiles']}")
print(f"MW : {c['mwt']:.2f}")
print(f"logP : {c['logp']:.2f}")
print(f"HBD : {c['hbd']}")
print(f"HBA : {c['hba']}")
print(f"TPSA : {c.get('tpsa', 'n/a')}")
print(f"Rotatable: {c.get('rotatable_bonds', 'n/a')}")
print(f"Suppliers: {len(c.get('suppliers', []))}")
Query 3: Download Compound Subsets (Tranches)
ZINC organizes compounds into "tranches" by MW and logP. Download pre-built SDF/SMILES files.
import requests
# ZINC15 tranche download (MW 200-250, logP 1-2 range)
# Tranche naming: letters encode MW range (A-K) and logP range (A-J)
# See http://zinc15.docking.org/tranches/home
def download_zinc_tranche(tranche_name, dest_file, fmt="smi"):
"""Download a ZINC tranche SMILES file."""
url = f"https://zinc15.docking.org/tranches/{tranche_name}.{fmt}"
r = requests.get(url, stream=True)
r.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Downloaded {dest_file}")
# Download one tranche as SMILES
download_zinc_tranche("AABA", "zinc_AABA.smi", fmt="smi")
Query 4: SMILES Similarity Search
Find ZINC compounds similar to a query molecule.
import requests, pandas as pd
BASE = "https://zinc15.docking.org"
query_smiles = "c1ccc(NC(=O)c2ccccc2)cc1" # benzanilide analog
r = requests.get(f"{BASE}/substances.json",
params={
"smiles": query_smiles,
"similarity": 0.6, # Tanimoto similarity threshold
"count": 20,
"availability": "for-sale"
})
r.raise_for_status()
results = r.json()
print(f"Similar compounds found: {len(results)}")
df = pd.DataFrame(results)[["zinc_id", "smiles", "mwt", "logp"]]
print(df.head())
Query 5: Catalog and Supplier Information
Retrieve purchasability and supplier catalog data for compounds.
import requests
BASE = "https://zinc15.docking.org"
# Check purchasability and catalog info
zinc_id = "ZINC000000029632"
r = requests.get(f"{BASE}/substances/{zinc_id}/suppliers.json")
r.raise_for_status()
suppliers = r.json()
print(f"Suppliers for {zinc_id}: {len(suppliers)}")
for sup in suppliers[:5]:
print(f" {sup.get('name', 'n/a'):30s} | Catalog: {sup.get('catalognum', 'n/a')}")
Query 6: Bulk Download via ZINC Slices
For large-scale virtual screening, download entire ZINC subsets as compressed SMILES.
import requests, gzip, io, pandas as pd
# ZINC15 drug-like purchasable slice (public URL pattern)
# Full drug-like: https://zinc15.docking.org/substances/subsets/drug-like.smi.gz
def download_zinc_subset(subset_name, max_lines=1000):
"""Download a ZINC subset SMILES file and return a DataFrame sample."""
url = f"https://zinc15.docking.org/substances/subsets/{subset_name}.smi.gz"
r = requests.get(url, stream=True)
r.raise_for_status()
lines = []
with gzip.open(r.raw, "rt") as f:
for i, line in enumerate(f):
if i >= max_lines:
break
lines.append(line.strip().split())
df = pd.DataFrame(lines, columns=["smiles", "zinc_id"] + [f"col{i}" for i in range(max(0, len(lines[0])-2))])
return df[["smiles", "zinc_id"]]
# Load first 1000 from lead-like subset
df_sample = download_zinc_subset("lead-like", max_lines=1000)
print(f"Loaded {len(df_sample)} compounds from lead-like subset")
print(df_sample.head())
Key Concepts
ZINC Tranches
Compounds are organized into a 2D grid of "tranches" based on MW (rows A–K: <200 to >600 Da) and logP (columns A–J: <-1 to >5). Each tranche can be downloaded as a SMILES or SDF file. This tranching enables targeted downloads of specific property spaces for docking.
Availability Classes
- for-sale: Purchasable from ≥1 supplier
- in-stock: Available for immediate purchase
- wait-ok: Longer lead time acceptable
- on-demand: Custom synthesis required
Common Workflows
Workflow 1: Build a Focused Docking Library
Goal: Curate a purchasable, lead-like compound library within specific property ranges, deduplicate, and export for docking.
import requests, pandas as pd
BASE = "https://zinc15.docking.org"
# Fetch lead-like purchasable compounds with Lipinski compliance
params = {
"mwt__gte": 200, "mwt__lte": 500,
"logp__gte": -1, "logp__lte": 5,
"hbd__lte": 5, "hba__lte": 10,
"rotatable_bonds__lte": 10,
"availability": "for-sale",
"count": 200,
}
r = requests.get(f"{BASE}/substances.json", params=params)
r.raise_for_status()
compounds = r.json()
df = pd.DataFrame(compounds)[["zinc_id", "smiles", "mwt", "logp", "hbd", "hba"]]
df = df.drop_duplicates(subset=["smiles"])
print(f"Curated library: {len(df)} unique compounds")
# Export as SMILES for docking input
df[["smiles", "zinc_id"]].to_csv("docking_library.smi", sep=" ", index=False, header=False)
print("Saved: docking_library.smi")
print(df.head())
Workflow 2: Fragment Library for FBDD
Goal: Download fragment-like (Rule of Three) compounds for fragment-based drug discovery.
import requests, pandas as pd
BASE = "https://zinc15.docking.org"
# Rule of Three: MW ≤ 300, logP ≤ 3, HBD ≤ 3, HBA ≤ 3, RotB ≤ 3
params = {
"mwt__lte": 300,
"logp__lte": 3,
"hbd__lte": 3,
"hba__lte": 3,
"rotatable_bonds__lte": 3,
"availability": "for-sale",
"count": 200,
}
r = requests.get(f"{BASE}/substances.json", params=params)
fragments = r.json()
df = pd.DataFrame(fragments)[["zinc_id", "smiles", "mwt", "logp"]]
print(f"Fragment library: {len(df)} compounds (Rule of Three)")
df.to_csv("fragment_library.smi", sep=" ", index=False, header=False)
print("Saved: fragment_library.smi")
df.describe()
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
mwt__gte / mwt__lte |
Search | — | numeric (Da) | Molecular weight lower/upper bound |
logp__gte / logp__lte |
Search | — | numeric | logP (lipophilicity) range |
hbd__lte |
Search | — | integer | Max hydrogen bond donors |
hba__lte |
Search | — | integer | Max hydrogen bond acceptors |
rotatable_bonds__lte |
Search | — | integer | Max rotatable bonds |
availability |
Search | all | "for-sale", "in-stock", "on-demand" |
Purchasability filter |
count |
Search | 10 | 1–1000 |
Max compounds returned per request |
similarity |
Similarity | — | 0.0–1.0 |
Tanimoto similarity threshold |
Best Practices
-
Use tranches for large docking campaigns: Downloading entire MW/logP tranches as pre-built SDF files is faster than paginating the API. Use the ZINC tranches page to identify the subset of property space you need.
-
Apply reactivity filters: ZINC marks reactive compounds with "reactivity" flags. Exclude compounds with reactive groups (
reactivity: "clean"filter) for cell-based assays. -
Deduplicate by SMILES: API results may contain duplicates across supplier catalog entries. Canonical SMILES deduplication with RDKit (
Chem.MolToSmiles(Chem.MolFromSmiles(smi))) before docking. -
Combine with RDKit filtering: After downloading, apply additional filters (PAINS, Brenk alerts) using
rdkit-cheminformaticsormedchembefore investing compute in docking. -
Cache SMILES downloads: ZINC data is updated periodically. Cache downloads with a date-stamped filename and avoid re-downloading within a project.
Common Recipes
Recipe: Lookup ZINC ID from SMILES
When to use: Find the ZINC ID for a known compound to check purchasability.
import requests
BASE = "https://zinc15.docking.org"
smiles = "CC(=O)Nc1ccc(O)cc1" # paracetamol / acetaminophen
r = requests.get(f"{BASE}/substances.json",
params={"smiles": smiles, "count": 3})
for c in r.json():
print(f"ZINC: {c['zinc_id']} | MW: {c['mwt']:.1f} | In stock: {c.get('availability')}")
Recipe: Export SDF for Docking
When to use: Download 3D SDF conformers for a list of ZINC IDs for use in docking software.
import requests
BASE = "https://zinc15.docking.org"
zinc_ids = ["ZINC000000029632", "ZINC000001532592"]
for zid in zinc_ids:
r = requests.get(f"{BASE}/substances/{zid}.sdf")
if r.ok:
with open(f"{zid}.sdf", "w") as f:
f.write(r.text)
print(f"Downloaded {zid}.sdf")
else:
print(f"Not available: {zid}")
Recipe: Property Distribution of a Library
When to use: Quickly assess the property coverage of a downloaded compound set.
import pandas as pd
df = pd.read_csv("docking_library.smi", sep=" ", names=["smiles", "zinc_id"])
print(f"Library size: {len(df)}")
# If you have the full ZINC metadata:
# df = pd.DataFrame(compounds)[["mwt", "logp", "hbd", "hba"]]
# print(df.describe())
# import matplotlib.pyplot as plt
# df[["mwt", "logp"]].hist(bins=30, figsize=(10, 4)); plt.show()
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 for compound ID |
ZINC ID format incorrect | Use full 12-digit ZINC ID (e.g., ZINC000000029632) |
| Empty results for property search | Filters too restrictive | Relax ranges; check mwt__gte < mwt__lte is not inverted |
| Similarity search returns nothing | SMILES invalid or unusual scaffold | Validate SMILES with RDKit first; try lower similarity threshold |
| Tranche file download fails | Tranche code wrong | Verify tranche naming at zinc15.docking.org/tranches/home |
| API returns HTML error page | Server maintenance | Retry after a few minutes; check ZINC status |
| Slow large downloads | Large compound sets | Download tranche files via FTP/HTTP bulk download instead of API pagination |
Related Skills
rdkit-cheminformatics— Compute additional properties and apply PAINS filters on downloaded ZINC compoundsautodock-vina-docking— Use downloaded ZINC SMILES/SDF files for molecular docking campaignschembl-database-bioactivity— Bioactivity data for compounds identified in ZINC virtual screensmedchem— Apply medicinal chemistry filters (Lipinski, PAINS, NIBR) on ZINC libraries
References
- ZINC15 website — Main ZINC15 database and API
- ZINC15 REST API reference — Query parameters and endpoint documentation
- ZINC22 update paper — Irwin et al., J. Chem. Inf. Model. 2022
- ZINC tranches download page — Bulk compound subset downloads by MW/logP
skills/systems-biology-multiomics/brenda-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill brenda-database -g -y
SKILL.md
Frontmatter
{
"name": "brenda-database",
"license": "CC-BY-4.0",
"description": "BRENDA Enzyme DB SOAP\/REST queries: kinetic parameters (Km, Vmax, kcat, Ki), EC classes, substrate specificity, inhibitors, cofactors, organism data. 80K+ enzymes, 7M+ values. Free academic registration. For metabolic modeling use cobrapy-metabolic-modeling; metabolites use hmdb-database."
}
BRENDA Enzyme Database
Overview
BRENDA (BRaunschweig ENzyme DAtabase) is the world's most comprehensive enzyme information system, containing 80,000+ enzyme entries covering all classified enzymes (EC numbers). It holds 7M+ experimentally measured kinetic parameters (Km, Vmax, kcat, Ki, inhibition constants), substrate specificity data, cofactor requirements, tissue expression, and organism-specific enzyme variants from 200,000+ literature references. Programmatic access is via a SOAP-based web service (Python zeep library) with free academic registration.
When to Use
- Retrieving kinetic parameters (Km, kcat, Vmax, Ki) for a specific enzyme and substrate combination
- Comparing kinetic parameters across organisms or mutant variants for an enzyme
- Finding natural substrates, inhibitors, and cofactors for an EC number
- Building kinetic models for metabolic simulations requiring Michaelis-Menten parameters
- Identifying enzyme-specific structural data (recommended pH, temperature optima)
- Cross-referencing EC numbers with UniProt accessions and organism taxonomy
- For metabolic network simulation use
cobrapy-metabolic-modeling; for metabolite structures usehmdb-database
Prerequisites
- Python packages:
zeep(SOAP client),pandas,requests - Data requirements: EC numbers (e.g.,
1.1.1.1), enzyme names, or organism names - Environment: internet connection; free academic registration at https://www.brenda-enzymes.org/register.php
- Rate limits: no explicit limit stated; avoid bulk automated queries; space requests with sleep
pip install zeep pandas requests
# Register at https://www.brenda-enzymes.org/register.php to obtain API credentials
Quick Start
from zeep import Client
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = "your_sha256_hashed_password" # Use hashlib.sha256
# Get Km values for lactate dehydrogenase (EC 1.1.1.27) and pyruvate
ec_number = "1.1.1.27"
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "substrate*pyruvate", "", "", "", "", "")
result = client.service.getKmValue(*params)
print(f"Km values for LDH with pyruvate: {len(result)} records")
for r in result[:3]:
print(f" Km={r.kmValue} {r.kmValueMaximum or ''} mM | org: {r.organism} | PMID: {r.literature}")
Core API
Query 1: Km Values for Enzyme-Substrate Pair
Retrieve Michaelis constant (Km) values for a specific enzyme and substrate.
from zeep import Client
import hashlib, pandas as pd
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD = "your_password"
PASSWORD_SHA256 = hashlib.sha256(PASSWORD.encode()).hexdigest()
def get_km_values(ec_number, substrate=""):
"""Retrieve Km values for an EC number, optionally filtered by substrate."""
substrate_param = f"substrate*{substrate}" if substrate else ""
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", substrate_param, "", "", "", "", "")
return client.service.getKmValue(*params)
# Km for glucokinase (EC 2.7.1.2) with glucose
results = get_km_values("2.7.1.2", substrate="glucose")
print(f"Km (glucose, glucokinase): {len(results)} measurements")
rows = []
for r in results[:10]:
rows.append({
"km_value": r.kmValue,
"km_max": r.kmValueMaximum,
"unit": "mM",
"organism": r.organism,
"commentary": r.commentary[:80] if r.commentary else "",
"pmid": r.literature,
})
df = pd.DataFrame(rows)
print(df.to_string(index=False))
# Get ALL Km values (all substrates) for an EC number
all_km = get_km_values("1.1.1.1") # Alcohol dehydrogenase
print(f"\nAlcohol dehydrogenase - total Km records: {len(all_km)}")
substrate_counts = {}
for r in all_km:
sub = r.substrate or "unknown"
substrate_counts[sub] = substrate_counts.get(sub, 0) + 1
top_substrates = sorted(substrate_counts.items(), key=lambda x: -x[1])[:5]
print("Top substrates by measurement count:")
for sub, cnt in top_substrates:
print(f" {sub}: {cnt} measurements")
Query 2: kcat (Turnover Number) Values
Retrieve catalytic rate constants (kcat) for an enzyme.
from zeep import Client
import hashlib, pandas as pd
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
def get_kcat_values(ec_number, substrate=""):
substrate_param = f"substrate*{substrate}" if substrate else ""
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", substrate_param, "", "", "", "", "")
return client.service.getTurnoverNumber(*params)
results = get_kcat_values("1.1.1.27") # Lactate dehydrogenase
print(f"kcat records for LDH: {len(results)}")
rows = []
for r in results[:10]:
rows.append({
"kcat": r.turnoverNumber,
"unit": "1/s",
"substrate": r.substrate,
"organism": r.organism,
})
df = pd.DataFrame(rows)
print(df.head())
Query 3: Substrates and Products
Retrieve natural substrates and products for an enzyme.
from zeep import Client
import hashlib, pandas as pd
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
def get_substrates_products(ec_number):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", "", "", "", "", "")
return client.service.getSubstrates(*params)
results = get_substrates_products("4.2.1.1") # Carbonic anhydrase
print(f"Substrates for carbonic anhydrase (EC 4.2.1.1):")
substrates_seen = set()
for r in results[:10]:
if r.substrate not in substrates_seen:
print(f" {r.substrate} | organism: {r.organism}")
substrates_seen.add(r.substrate)
# Get inhibitors
def get_inhibitors(ec_number):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", "", "", "", "", "")
return client.service.getInhibitors(*params)
inhibitors = get_inhibitors("4.2.1.1")
print(f"\nInhibitors of carbonic anhydrase: {len(inhibitors)} records")
inhib_names = list(set(r.inhibitor for r in inhibitors if r.inhibitor))
print("Sample inhibitors:", inhib_names[:8])
Query 4: Organism-Specific Enzyme Data
Query kinetic parameters filtered by organism.
from zeep import Client
import hashlib
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
def get_km_by_organism(ec_number, organism):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", f"organism*{organism}", "", "", "", "")
return client.service.getKmValue(*params)
# Human GAPDH Km values
human_km = get_km_by_organism("1.2.1.12", "Homo sapiens")
print(f"Human GAPDH (EC 1.2.1.12) Km values: {len(human_km)} records")
for r in human_km[:5]:
print(f" Substrate: {r.substrate:30s} Km={r.kmValue} mM")
Query 5: pH and Temperature Optima
Retrieve optimal pH and temperature data for an enzyme.
from zeep import Client
import hashlib
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
def get_ph_optimum(ec_number):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", "", "", "", "", "")
return client.service.getPhOptimum(*params)
def get_temp_optimum(ec_number):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", "", "", "", "", "")
return client.service.getTemperatureOptimum(*params)
ec = "3.4.21.4" # Trypsin
ph_data = get_ph_optimum(ec)
temp_data = get_temp_optimum(ec)
print(f"Trypsin (EC {ec}):")
ph_values = [r.phOptimum for r in ph_data[:10] if r.phOptimum]
temp_values = [r.temperatureOptimum for r in temp_data[:10] if r.temperatureOptimum]
if ph_values:
print(f" pH optima: {sorted(ph_values)}")
if temp_values:
print(f" Temperature optima (°C): {sorted(temp_values)}")
Query 6: EC Number to UniProt Cross-Reference
Map EC numbers to UniProt accession numbers.
from zeep import Client
import hashlib
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
def get_uniprot_accessions(ec_number):
params = (EMAIL, PASSWORD_SHA256,
f"ecNumber*{ec_number}", "", "", "", "", "", "")
return client.service.getUniprotAccession(*params)
results = get_uniprot_accessions("1.1.1.27") # LDH
print(f"UniProt accessions for LDH (EC 1.1.1.27):")
seen = set()
for r in results[:10]:
acc = r.uniprotAccessionNumber
org = r.organism
if acc and acc not in seen:
print(f" {acc:12s} ({org})")
seen.add(acc)
Key Concepts
SOAP Interface and Authentication
BRENDA uses SOAP (not REST) via a WSDL definition. The zeep Python library parses the WSDL and generates typed method calls. Authentication requires a SHA256-hashed password (not plain text). Each service method takes (email, password_sha256, param1, param2, ..., "") arguments with pipe-delimited field filters.
EC Number Classification
Enzyme Commission (EC) numbers follow the format X.X.X.X where each level specifies the reaction class (oxidoreductases=1, transferases=2, hydrolases=3, lyases=4, isomerases=5, ligases=6, translocases=7). BRENDA organizes all data by EC number.
Common Workflows
Workflow 1: Kinetic Parameter Extraction for Metabolic Modeling
Goal: For a set of enzymes in a metabolic pathway, extract Km and kcat values to parameterize a kinetic model.
from zeep import Client
import hashlib, pandas as pd, time
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
# Glycolysis enzymes
enzymes = {
"Hexokinase": "2.7.1.1",
"Phosphoglucose isomerase": "5.3.1.9",
"Phosphofructokinase": "2.7.1.11",
"Aldolase": "4.1.2.13",
}
rows = []
for name, ec in enzymes.items():
params = (EMAIL, PASSWORD_SHA256, f"ecNumber*{ec}", "", "organism*Homo sapiens", "", "", "", "")
try:
km_results = client.service.getKmValue(*params)
kcat_results = client.service.getTurnoverNumber(*params)
km_vals = [r.kmValue for r in km_results if r.kmValue]
kcat_vals = [r.turnoverNumber for r in kcat_results if r.turnoverNumber]
rows.append({
"enzyme": name,
"ec": ec,
"n_km_records": len(km_vals),
"km_median_mM": pd.Series(km_vals).median() if km_vals else None,
"n_kcat_records": len(kcat_vals),
"kcat_median_1_s": pd.Series(kcat_vals).median() if kcat_vals else None,
})
except Exception as e:
rows.append({"enzyme": name, "ec": ec, "error": str(e)})
time.sleep(0.5)
df = pd.DataFrame(rows)
df.to_csv("glycolysis_kinetics.csv", index=False)
print(df.to_string(index=False))
Workflow 2: Inhibitor Comparison Across Enzyme Family
Goal: Compare inhibitor landscape across a set of related enzymes for drug discovery prioritization.
from zeep import Client
import hashlib, pandas as pd, time
from collections import Counter
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
# Carbonic anhydrase isoforms
ca_ecs = ["4.2.1.1"] # All carbonic anhydrases share this EC
rows = []
for ec in ca_ecs:
params = (EMAIL, PASSWORD_SHA256, f"ecNumber*{ec}", "", "", "", "", "", "")
try:
inhib_results = client.service.getInhibitors(*params)
for r in inhib_results[:30]:
rows.append({
"ec": ec,
"inhibitor": r.inhibitor,
"organism": r.organism,
"ic50": r.ic50Value if hasattr(r, "ic50Value") else None,
})
except Exception as e:
print(f"Error for {ec}: {e}")
time.sleep(0.5)
df = pd.DataFrame(rows)
print(f"Total inhibitor records: {len(df)}")
top_inhib = Counter(df["inhibitor"]).most_common(10)
print("\nMost reported inhibitors:")
for inhib, count in top_inhib:
print(f" {inhib}: {count} records")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
ecNumber* |
All queries | required | EC number string | Filter by enzyme class |
substrate* |
Km, kcat | — | substrate name | Filter by substrate |
organism* |
All queries | — | species name | Filter by organism (e.g., "Homo sapiens") |
commentary* |
All queries | — | text substring | Filter by comment text |
ligandStructureId* |
Compound-based | — | BRENDA structure ID | Filter by ligand ID |
| Password | Auth | required | SHA256 hash | Authentication (hashlib.sha256) |
Best Practices
-
Hash your password correctly: BRENDA requires SHA256 hash of the plain-text password, not the password itself. Use
hashlib.sha256("your_password".encode()).hexdigest(). -
Store credentials in environment variables: Never hard-code credentials. Use
os.environ["BRENDA_EMAIL"]andos.environ["BRENDA_PASSWORD"]patterns. -
Add
time.sleep()between queries: BRENDA's SOAP service may be slow; space large batch queries with 0.5–1 second sleeps to avoid timeouts. -
Filter by organism for modeling: Kinetic parameters vary dramatically between organisms; always filter by the organism relevant to your model (e.g.,
organism*Homo sapiens). -
Use median/IQR for parameter aggregation: Multiple literature measurements for the same substrate often span an order of magnitude; use median + IQR rather than mean to summarize distributions.
Common Recipes
Recipe: Get All Substrates for an EC Number
When to use: Understand the substrate scope of an enzyme for pathway analysis.
from zeep import Client
import hashlib
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
ec = "1.1.1.1" # Alcohol dehydrogenase
params = (EMAIL, PASSWORD_SHA256, f"ecNumber*{ec}", "", "", "", "", "", "")
results = client.service.getSubstrates(*params)
substrates = list(set(r.substrate for r in results if r.substrate))
print(f"Substrates of EC {ec} ({len(substrates)} unique): {substrates[:10]}")
Recipe: kcat/Km Efficiency Ratio
When to use: Compute catalytic efficiency (kcat/Km) from BRENDA data.
import pandas as pd
# After fetching km_results and kcat_results for same ec + substrate
# km_values = [r.kmValue for r in km_results if r.kmValue] # mM
# kcat_values = [r.turnoverNumber for r in kcat_results if r.turnoverNumber] # 1/s
km_median = 0.1 # mM (example)
kcat_median = 500 # s^-1 (example)
efficiency = kcat_median / (km_median * 1e-3) # Convert Km to M
print(f"Catalytic efficiency (kcat/Km): {efficiency:.2e} M^-1 s^-1")
# Diffusion limit ≈ 10^8-10^9 M^-1 s^-1
Recipe: Find EC Number from Enzyme Name
When to use: Resolve enzyme common name to EC number for BRENDA queries.
from zeep import Client
import hashlib
WSDL = "https://www.brenda-enzymes.org/soap/brenda_zeep.wsdl"
client = Client(WSDL)
EMAIL = "your@email.com"
PASSWORD_SHA256 = hashlib.sha256("your_password".encode()).hexdigest()
# Search enzymes by name
params = (EMAIL, PASSWORD_SHA256, "recommendedName*lactate dehydrogenase", "", "", "", "", "", "")
results = client.service.getEcNumber(*params)
print(f"EC numbers for 'lactate dehydrogenase':")
for r in results[:5]:
print(f" EC {r.ecNumber}: {r.recommendedName}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
zeep.exceptions.Fault: Authentication failed |
Wrong password or SHA256 format | Ensure hashlib.sha256(password.encode()).hexdigest() — hexdigest not digest |
| Empty result list | EC number or substrate not found | Verify EC format (X.X.X.X with dots); try without substrate filter first |
| SOAP timeout | Large query or slow connection | Use organism filter to reduce result set; set zeep transport timeout |
AttributeError on result field |
Field not available for this query | Use getattr(r, "field", None) to safely access optional fields |
| Slow response for popular enzymes | Large datasets (TP53 = 10K+ records) | Filter by organism and substrate to reduce data transfer |
zeep.exceptions.TransportError |
Network connectivity issue | Check VPN, retry after 30 seconds |
Related Skills
cobrapy-metabolic-modeling— Constraint-based metabolic modeling using Km/Vmax from BRENDA as kinetic constraintshmdb-database— Metabolite structure and biological context for BRENDA substrateskegg-database— Pathway context for BRENDA enzymes via EC number cross-referencesuniprot-protein-database— Protein sequence and structure data for enzymes found in BRENDA
References
- BRENDA database — Main BRENDA portal and manual search
- BRENDA web service documentation — SOAP API reference and parameter descriptions
- zeep Python SOAP client — Python library for SOAP web services
- Chang et al. (2021) BRENDA update — BRENDA 2021 database update paper
skills/systems-biology-multiomics/cellchat-cell-communication/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cellchat-cell-communication -g -y
SKILL.md
Frontmatter
{
"name": "cellchat-cell-communication",
"license": "MIT",
"description": "Infer and visualize intercellular communication from scRNA-seq with CellChat (R). Build CellChat from Seurat\/counts → subset CellChatDB ligand-receptor pairs → over-expressed genes per group → communication probabilities → pathway signaling → network centrality (senders\/receivers\/influencers) → chord\/heatmap\/bubble plots → cross-condition compare. Human, mouse. Use liana for pure-Python."
}
CellChat — Cell-Cell Communication Analysis
Overview
CellChat is an R package that infers and visualizes intercellular signaling networks from single-cell RNA-seq data. Starting from a normalized expression matrix and cluster labels, CellChat identifies ligand-receptor interactions supported by CellChatDB — a manually curated database of over 2,000 validated ligand-receptor pairs in human and mouse. Communication probability is modeled using the law of mass action, combining expression levels of ligands, receptors, and cofactors. CellChat aggregates pair-level probabilities into pathway-level signaling networks and quantifies each cell group's role as a signal sender, receiver, mediator, or influencer. The result is a rich, interpretable picture of which cell types talk to which, through which signaling pathways, and how these patterns change between conditions.
When to Use
- Characterizing which cell types are the dominant senders or receivers of paracrine and autocrine signals in a tissue atlas or disease sample
- Identifying specific ligand-receptor pairs mediating communication between a cell population of interest (e.g., tumor cells → T cells, fibroblasts → epithelial cells)
- Comparing intercellular signaling networks between two conditions (e.g., healthy vs. diseased, treatment vs. control) to find rewired or lost communication
- Discovering pathway-level signaling programs (e.g., MHC-II, COLLAGEN, VEGF) enriched in a particular cell-cell interaction
- Prioritizing targets for perturbation experiments by ranking signaling pathways by their aggregate communication strength or network centrality
- Use liana (Python/R) instead when you want a pure-Python workflow or a consensus ranking across multiple ligand-receptor databases (CellChat, CellPhoneDB, Connectome, NicheNet)
- Use NicheNet (R) instead when you need ligand-to-target gene regulatory inference — predicting which ligands from sender cells regulate which target genes in receiver cells
Prerequisites
- R packages:
CellChat(>= 2.0),Seurat(>= 4.0, for Seurat-based input),NMF,ggplot2,ggalluvial,igraph,dplyr,patchwork,reticulate(optional) - Data requirements: Normalized scRNA-seq count matrix (genes × cells) and a cell group identity vector (cluster labels or cell types). Raw counts are acceptable if normalized inside CellChat.
- Species: CellChatDB available for human and mouse; other species require custom database construction
- Memory: 8 GB RAM minimum for datasets with 10,000–50,000 cells; 32 GB+ recommended for larger datasets
# Install CellChat from GitHub (CRAN version may lag)
if (!requireNamespace("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install(c("BiocNeighbors", "ComplexHeatmap"))
install.packages("devtools")
devtools::install_github("jinworks/CellChat")
# Core dependencies
install.packages(c("NMF", "ggplot2", "ggalluvial", "igraph",
"dplyr", "patchwork", "circlize", "RColorBrewer"))
Quick Start
library(CellChat)
library(Seurat)
# Assume `seurat_obj` is a processed Seurat object with cell type identities in Idents()
data.input <- GetAssayData(seurat_obj, assay = "RNA", slot = "data") # normalized counts
meta <- data.frame(labels = Idents(seurat_obj), row.names = names(Idents(seurat_obj)))
cellchat <- createCellChat(object = data.input, meta = meta, group.by = "labels")
cellchat@DB <- CellChatDB.human # or CellChatDB.mouse
cellchat <- subsetData(cellchat)
cellchat <- identifyOverExpressedGenes(cellchat)
cellchat <- identifyOverExpressedInteractions(cellchat)
cellchat <- computeCommunProb(cellchat, type = "triMean")
cellchat <- filterCommunication(cellchat, min.cells = 10)
cellchat <- computeCommunProbPathway(cellchat)
cellchat <- aggregateNet(cellchat)
# Quick summary
print(cellchat)
# e.g. "An object of class CellChat created from a single dataset
# with 8 cell groups and 312 inferred ligand-receptor pairs"
Workflow
Step 1: Create CellChat Object
Build a CellChat object from either a Seurat object or a raw count matrix with accompanying metadata.
library(CellChat)
library(Seurat)
# --- Option A: from a Seurat object ---
# seurat_obj must have cell type identities set with Idents() or in meta.data
data.input <- GetAssayData(seurat_obj, assay = "RNA", slot = "data") # log-normalized
meta <- data.frame(
labels = Idents(seurat_obj),
row.names = colnames(seurat_obj)
)
cellchat <- createCellChat(object = data.input, meta = meta, group.by = "labels")
# --- Option B: from a count matrix directly ---
# data.input: genes-by-cells normalized matrix (dgCMatrix or dense matrix)
# identity: named factor of cell group labels (length = ncol(data.input))
cellchat <- createCellChat(object = data.input, meta = data.frame(labels = identity),
group.by = "labels")
cat("Cell groups:", levels(cellchat@idents), "\n")
cat("Number of cells:", ncol(data.input), "\n")
# Cell groups: B_cell Endothelial Fibroblast Macrophage NK T_cell Tumor
# Number of cells: 12847
Step 2: Set CellChatDB and Subset Interactions
Load the species-appropriate ligand-receptor database and optionally subset to a signaling category of interest.
# Load database for the appropriate species
CellChatDB <- CellChatDB.human # use CellChatDB.mouse for mouse data
# Inspect available signaling categories
unique(CellChatDB$interaction$annotation)
# [1] "Secreted Signaling" "ECM-Receptor" "Cell-Cell Contact"
# Option 1: Use all interactions (recommended for discovery)
cellchat@DB <- CellChatDB
# Option 2: Subset to secreted ligand-receptor pairs only (reduces noise)
CellChatDB.use <- subsetDB(CellChatDB, search = "Secreted Signaling",
key = "annotation")
cellchat@DB <- CellChatDB.use
# Subset the CellChat data slots to only genes in the database
cellchat <- subsetData(cellchat)
cat("Genes retained after database subset:", nrow(cellchat@data.signaling), "\n")
# Genes retained after database subset: 1842
Step 3: Identify Over-Expressed Genes and Interactions
For each cell group, identify ligands and receptors that are significantly over-expressed compared to other groups.
# Identify over-expressed genes per cell group (uses Seurat-style wilcoxon test)
cellchat <- identifyOverExpressedGenes(cellchat)
# Map over-expressed genes to ligand-receptor pairs in CellChatDB
cellchat <- identifyOverExpressedInteractions(cellchat)
# Inspect how many interactions were identified per group pair
df.net <- subsetCommunication(cellchat)
cat("Total inferred interactions:", nrow(df.net), "\n")
head(df.net[, c("source", "target", "ligand", "receptor", "prob")], 5)
# source target ligand receptor prob
# 1 B_cell Macrophage CD22 PTPRC 0.0318
# 2 Fibroblast Tumor FN1 CD44 0.1072
# ...
Step 4: Infer Cell-Cell Communication Probabilities
Compute communication probability for each ligand-receptor pair between every ordered pair of cell groups using the law of mass action. CellChat accounts for multi-subunit complexes and co-stimulatory/co-inhibitory cofactors.
# Compute pairwise communication probability
# type = "triMean": uses 25th percentile × mean × 25th percentile for robustness
# type = "truncatedMean": uses trimmed mean with threshold parameter trim
cellchat <- computeCommunProb(
cellchat,
type = "triMean", # recommended default
trim = 0.1, # fraction to trim (only used if type="truncatedMean")
nboot = 100, # bootstrap iterations for p-value estimation
seed.use = 42,
population.size = TRUE # weight by population size (recommended)
)
# Filter out interactions with too few cells in sender or receiver groups
cellchat <- filterCommunication(cellchat, min.cells = 10)
# Summary of retained interactions
df.net <- subsetCommunication(cellchat)
cat("Interactions after filtering:", nrow(df.net), "\n")
cat("Significant interactions (p<0.05):", sum(df.net$pval < 0.05), "\n")
# Interactions after filtering: 247
# Significant interactions (p<0.05): 189
Step 5: Compute Pathway-Level Communication
Aggregate ligand-receptor pair probabilities into signaling pathway-level networks (e.g., COLLAGEN, MHC-II, VEGF).
# Aggregate to pathway level
cellchat <- computeCommunProbPathway(cellchat)
# Build aggregate interaction count and weight networks
cellchat <- aggregateNet(cellchat)
# View significant pathways
cat("Significant signaling pathways:\n")
print(cellchat@netP$pathways)
# [1] "MHC-II" "COLLAGEN" "FN1" "VEGF" "CXCL"
# [6] "CCL" "MIF" "APP" "GALECTIN" ...
# Extract pathway-level communication probabilities between groups
df.pathways <- subsetCommunication(cellchat, slot.name = "netP")
head(df.pathways[, c("source", "target", "pathway_name", "prob")], 5)
# source target pathway_name prob
# 1 Fibroblast Tumor COLLAGEN 0.2341
# 2 Macrophage Fibroblast MIF 0.1876
# ...
Step 6: Analyze Network Centrality — Senders, Receivers, Influencers
Identify each cell group's network role by computing information flow measures: out-strength (sender), in-strength (receiver), betweenness (mediator), and eigenvector centrality (influencer).
# Compute centrality measures for all pathways
cellchat <- netAnalysis_computeCentrality(cellchat, slot.name = "netP")
# Visualize centrality scores as a heatmap (rows=pathways, cols=cell groups)
# Each dot size: outgoing signal strength; color: incoming signal strength
netAnalysis_signalingRole_heatmap(
cellchat,
pattern = "all", # "outgoing", "incoming", or "all"
signaling = NULL, # NULL = all pathways; or specify e.g. c("COLLAGEN","VEGF")
height = 10,
color.heatmap = "OrRd"
)
# Identify dominant communication patterns using NMF
# outgoing patterns reveal which cell groups co-activate similar pathways
library(NMF)
selectK(cellchat, pattern = "outgoing") # elbow plot to choose K
cellchat <- identifyCommunicationPatterns(
cellchat,
pattern = "outgoing",
k = 3, # number of latent patterns; choose from selectK elbow
width = 8,
height = 6
)
Step 7: Visualize — Chord Diagrams, Heatmaps, Bubble Plots
CellChat provides several visualization functions for both aggregate and pathway-specific interactions.
library(ggplot2)
library(patchwork)
# --- 7a. Chord diagram: aggregate interaction count and weight ---
par(mfrow = c(1, 2))
netVisual_circle(
cellchat@net$count,
vertex.weight = as.numeric(table(cellchat@idents)),
weight.scale = TRUE,
label.edge = FALSE,
title.name = "Number of interactions"
)
netVisual_circle(
cellchat@net$weight,
vertex.weight = as.numeric(table(cellchat@idents)),
weight.scale = TRUE,
label.edge = FALSE,
title.name = "Interaction strength"
)
# --- 7b. Heatmap: cell-group × cell-group interaction matrix ---
p1 <- netVisual_heatmap(cellchat, measure = "count", color.heatmap = "Blues")
p2 <- netVisual_heatmap(cellchat, measure = "weight", color.heatmap = "Reds")
p1 + p2
# --- 7c. Chord diagram for a specific pathway ---
netVisual_aggregate(
cellchat,
signaling = "COLLAGEN",
layout = "chord",
vertex.receiver = NULL # NULL = show all groups as receivers
)
# --- 7d. Bubble plot: all significant interactions for chosen pathways ---
netVisual_bubble(
cellchat,
sources.use = NULL, # NULL = all senders
targets.use = NULL, # NULL = all receivers
signaling = c("COLLAGEN", "MIF", "VEGF"),
remove.isolate = FALSE
)
ggsave("bubble_plot_selected_pathways.pdf", width = 10, height = 8)
Step 8: Compare Two CellChat Objects Across Conditions
When you have two conditions (e.g., healthy and diseased), merge the CellChat objects and compare signaling networks.
# Assume cellchat_ctrl and cellchat_disease are pre-computed CellChat objects
object.list <- list(Control = cellchat_ctrl, Disease = cellchat_disease)
cellchat_merged <- mergeCellChat(object.list, add.names = names(object.list))
# --- Compare total interaction count and strength ---
compareInteractions(cellchat_merged, show.legend = FALSE,
group = c(1, 2), measure = "count")
compareInteractions(cellchat_merged, show.legend = FALSE,
group = c(1, 2), measure = "weight")
# --- Differential interaction chord diagram (gained/lost connections) ---
netVisual_diffInteraction(cellchat_merged, weight.scale = TRUE)
# --- Identify signaling pathways specific to each condition ---
rankNet(cellchat_merged, mode = "comparison", stacked = TRUE, do.stat = TRUE)
# --- Scatter plot: pathways shifted in information flow ---
rankNetPairwise(
cellchat_merged,
comparison = c(1, 2),
slot.name = "netP",
measure = "prob"
)
Key Parameters
| Parameter | Function | Default | Range / Options | Effect |
|---|---|---|---|---|
type |
computeCommunProb |
"triMean" |
"triMean", "truncatedMean", "thresholdedMean", "median" |
Aggregation method for group-level expression; triMean is most stringent |
trim |
computeCommunProb |
0.1 |
0–0.25 |
Fraction trimmed from each tail; only applies when type="truncatedMean" |
nboot |
computeCommunProb |
100 |
50–1000 |
Bootstrap iterations for p-value estimation; higher = slower but more accurate |
population.size |
computeCommunProb |
TRUE |
TRUE, FALSE |
Weight communication probability by cell group size; recommended for heterogeneous data |
min.cells |
filterCommunication |
10 |
5–50 |
Minimum number of cells required per sender or receiver group to retain an interaction |
k |
identifyCommunicationPatterns |
required | 2–6 (choose via selectK) |
Number of latent communication patterns; use selectK elbow to select |
thresh |
netAnalysis_computeCentrality |
0.05 |
0.01–0.1 |
P-value cutoff for retaining interactions in centrality analysis |
sources.use |
netVisual_bubble |
NULL |
cell group name(s) or index | Restrict sender cell groups in bubble plot; NULL = all |
targets.use |
netVisual_bubble |
NULL |
cell group name(s) or index | Restrict receiver cell groups in bubble plot; NULL = all |
Key Concepts
Communication Probability Model
CellChat quantifies communication probability using the law of mass action. For a ligand L expressed in cell group A and receptor R (potentially a multi-subunit complex) expressed in cell group B:
P(A → B | L-R) = hill(expr_L_A) × hill(expr_R1_B) × hill(expr_R2_B) × ...
where hill(x) = x^n / (K^n + x^n) (Hill function, n=1 by default), and expression values are group-aggregated using the chosen type argument. Multi-subunit receptor complexes require all subunits to be expressed; the probability is the product of Hill-transformed subunit expressions.
CellChatDB Ligand-Receptor Database
CellChatDB is a curated database of experimentally validated ligand-receptor interactions organized into three categories:
# Inspect the database structure
dim(CellChatDB.human$interaction) # [1] 2293 17
head(CellChatDB.human$interaction[, c("interaction_name", "pathway_name",
"ligand", "receptor", "annotation")], 4)
# interaction_name pathway_name ligand receptor annotation
# 1 TGFB1_TGFBR1_TGFBR2 TGFb TGFB1 TGFBR1_TGFBR2 Secreted Signaling
# 2 WNT5A_FZD1_LRP5 WNT WNT5A FZD1_LRP5 Secreted Signaling
# 3 FN1_CD44 FN1 FN1 CD44 ECM-Receptor
# 4 NOTCH1_DLL4 NOTCH NOTCH1 DLL4 Cell-Cell Contact
Three categories cover distinct biological mechanisms:
- Secreted Signaling: classical paracrine/autocrine ligands (cytokines, growth factors, morphogens)
- ECM-Receptor: extracellular matrix components binding membrane receptors
- Cell-Cell Contact: juxtacrine signals requiring direct cell contact (Notch, Ephrin, Semaphorin)
Network Centrality Roles
| Role | Centrality Measure | Interpretation |
|---|---|---|
| Sender | Out-degree / out-strength | Cell groups that broadcast signals to many targets |
| Receiver | In-degree / in-strength | Cell groups that receive signals from many sources |
| Mediator | Betweenness centrality | Cell groups that bridge communication between other groups |
| Influencer | Eigenvector centrality | Cell groups connected to other highly-connected groups |
Information Flow vs. Interaction Count
aggregateNet computes two complementary matrices:
cellchat@net$count: number of statistically significant ligand-receptor pairs per cell-group pair (raw interaction count)cellchat@net$weight: sum of communication probabilities across all pairs (interaction strength / information flow)
High count with low weight indicates many weak interactions; high weight with low count indicates a few dominant pathways.
Common Recipes
Recipe: Extract All Significant Interactions as a Data Frame
Use when you want to export results, apply custom filtering, or feed interactions into downstream pathway analysis.
# All ligand-receptor level interactions (p < 0.05)
df.lr <- subsetCommunication(cellchat, slot.name = "net")
df.lr_sig <- df.lr[df.lr$pval < 0.05, ]
cat("Significant LR interactions:", nrow(df.lr_sig), "\n")
# All pathway-level interactions
df.path <- subsetCommunication(cellchat, slot.name = "netP")
# Interactions involving specific cell groups
df.tumor_recv <- subsetCommunication(cellchat,
targets.use = "Tumor",
slot.name = "net")
cat("Interactions targeting Tumor cells:", nrow(df.tumor_recv), "\n")
# Save to CSV for downstream analysis
write.csv(df.lr_sig, "cellchat_lr_interactions.csv", row.names = FALSE)
write.csv(df.path, "cellchat_pathway_interactions.csv", row.names = FALSE)
write.csv(df.tumor_recv, "cellchat_tumor_receivers.csv", row.names = FALSE)
Recipe: Visualize Signaling Role of a Specific Pathway
Use when you want a detailed view of which cell types send and receive via one pathway.
# Show chord diagram + violin plots for a single pathway
pathway <- "COLLAGEN"
# Chord diagram
netVisual_aggregate(cellchat, signaling = pathway, layout = "chord")
title(main = paste0(pathway, " signaling network"))
# Contribution of each LR pair to the pathway
netAnalysis_contribution(cellchat, signaling = pathway)
# Gene expression of constituent ligands and receptors
plotGeneExpression(
cellchat,
signaling = pathway,
enriched.only = TRUE, # show only significantly enriched genes
type = "violin"
)
Recipe: Save and Reload a CellChat Object
Use when checkpointing a completed run before visualization or comparison steps.
# Save the completed CellChat object
saveRDS(cellchat, file = "cellchat_analysis.rds")
cat("Saved to cellchat_analysis.rds\n")
# Reload and resume analysis
cellchat_loaded <- readRDS("cellchat_analysis.rds")
cat("Cell groups:", levels(cellchat_loaded@idents), "\n")
cat("Pathways:", length(cellchat_loaded@netP$pathways), "\n")
# Verify the object is complete
slotNames(cellchat_loaded)
# [1] "data" "data.signaling" "images" "net"
# [5] "netP" "meta" "idents" "var.features"
# [9] "DB" "LR" "options"
Recipe: Python-Equivalent Workflow with liana
Use when your pipeline is Python-based or you want a consensus ranking across multiple LR databases.
# Install: pip install liana
import liana
import scanpy as sc
import pandas as pd
# Load preprocessed AnnData (cells x genes, log-normalized)
adata = sc.read_h5ad("my_scrna.h5ad")
# adata.obs["celltype"] must contain cluster/cell-type labels
# Run liana with CellChat resource (consensus across CellChatDB, CellPhoneDB, NATMI, etc.)
liana.mt.rank_aggregate(
adata,
groupby = "celltype",
resource_name = "consensus", # or "cellchat" for CellChat-only LR pairs
expr_prop = 0.1, # min fraction cells expressing ligand/receptor
verbose = True
)
# Results stored in adata.uns["liana_res"]
df = adata.uns["liana_res"]
df_sig = df[df["magnitude_rank"] < 0.05].sort_values("magnitude_rank")
print(df_sig[["source", "target", "ligand_complex", "receptor_complex",
"magnitude_rank"]].head(10))
df_sig.to_csv("liana_interactions.csv", index=False)
Expected Outputs
| Output | Type | Description |
|---|---|---|
cellchat@net$count |
R matrix (n_groups × n_groups) | Number of significant LR interactions between each cell-group pair |
cellchat@net$weight |
R matrix (n_groups × n_groups) | Aggregate communication probability (information flow) between cell-group pairs |
cellchat@netP$pathways |
Character vector | Names of all inferred signaling pathways |
subsetCommunication(cellchat) |
data.frame | Table of all LR-level interactions with source, target, ligand, receptor, probability, p-value |
subsetCommunication(cellchat, slot.name="netP") |
data.frame | Pathway-level interaction table |
| Chord diagram (PDF/PNG) | Figure | Circular diagram showing interaction strength between cell groups |
| Heatmap (PDF/PNG) | Figure | Cell-group × cell-group interaction count or weight heatmap |
| Bubble plot (PDF/PNG) | Figure | Dot plot showing interaction probabilities per LR pair per group pair |
| Signaling role heatmap (PDF/PNG) | Figure | Pathway × cell-group centrality scores (sender/receiver roles) |
Troubleshooting
| Problem | Likely Cause | Solution |
|---|---|---|
Error in computeCommunProb: all probabilities are zero |
Genes in CellChatDB not detected or filtered out | Confirm subsetData() retains genes: nrow(cellchat@data.signaling) > 0; check that expression matrix is log-normalized (not raw counts) and that gene names match CellChatDB (human: HGNC symbols; mouse: MGI symbols) |
Warning: groups with fewer than min.cells cells are removed |
Small clusters dropped at filtering step | Lower min.cells in filterCommunication() (e.g., min.cells = 5) or merge rare clusters before creating the CellChat object |
identifyCommunicationPatterns NMF error or no convergence |
Number of patterns k too high or data too sparse |
Use selectK() to choose k from the elbow in cophenetic/dispersion curves; try k=2 or k=3 first |
Memory error / session crash during computeCommunProb |
Dataset too large for available RAM | Subsample to ≤30,000 cells per condition; or run computeCommunProb with nboot = 50 to reduce bootstrap memory footprint |
| Chord diagram is unreadable (too many cell groups) | Many fine-grained clusters | Aggregate clusters into broader categories before creating CellChat object; or use netVisual_heatmap which scales better with many groups |
mergeCellChat error: cell group labels do not match |
Cell type names differ between objects | Harmonize levels(cellchat_ctrl@idents) and levels(cellchat_disease@idents) before merging; use setIdent() to rename groups |
| Gene symbols not recognized (all probabilities 0) | Mixed human/mouse gene naming convention | Confirm species: human genes are ALL CAPS (e.g., TGFB1); mouse genes are title case (e.g., Tgfb1). Set CellChatDB.mouse for mouse data |
References
- CellChat GitHub — jinworks/CellChat — source code, tutorials, and vignettes
- CellChat vignette (HTML) — official step-by-step tutorial
- Jin et al. (2021) Nature Communications — CellChat original paper — algorithmic description, benchmarking, and case studies
- CellChat comparison vignette — comparing CellChatDB with CellPhoneDB and other databases
- liana Python package documentation — pure-Python alternative with consensus LR ranking across CellChat, CellPhoneDB, NATMI, and Connectome
skills/systems-biology-multiomics/cobrapy-metabolic-modeling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill cobrapy-metabolic-modeling -g -y
SKILL.md
Frontmatter
{
"name": "cobrapy-metabolic-modeling",
"license": "GPL-2.0",
"description": "Constraint-based (COBRA) analysis of genome-scale metabolic models: FBA, FVA, knockouts, flux sampling, production envelopes, gapfilling, media optimization. Use for strain design, essential gene ID, flux analysis. For kinetic modeling use tellurium; for visualization use Escher."
}
COBRApy — Constraint-Based Metabolic Modeling
Overview
COBRApy is a Python package for constraint-based reconstruction and analysis (COBRA) of genome-scale metabolic models. It provides flux balance analysis (FBA), flux variability analysis (FVA), gene and reaction knockout screens, flux sampling, production envelopes, gapfilling, and media optimization on SBML-format metabolic networks.
When to Use
- Predicting microbial growth rates under different nutrient conditions (FBA)
- Identifying essential genes or reactions via single and double knockout screens
- Determining flux ranges and alternative optimal solutions (FVA)
- Sampling feasible flux distributions to characterize metabolic flexibility
- Designing minimal growth media or optimizing carbon sources
- Computing production envelopes for metabolic engineering targets
- Gapfilling incomplete draft models using a universal reaction database
- For kinetic modeling or dynamic ODE-based models, use Tellurium instead
- For pathway visualization on metabolic maps, use Escher instead
Prerequisites
- Python packages:
cobra(includes GLPK solver),numpy,pandas - Optional solvers: CPLEX or Gurobi (faster for large models, require license)
- Data: SBML (.xml), JSON, or YAML metabolic model files; available from BiGG Models, AGORA, or ModelSEED
pip install cobra
Quick Start
from cobra.io import load_model
model = load_model("textbook") # E. coli core model
print(f"Model: {model.id} — {len(model.reactions)} rxns, {len(model.metabolites)} mets, {len(model.genes)} genes")
solution = model.optimize()
print(f"Growth rate: {solution.objective_value:.4f} /h")
print(f"Status: {solution.status}")
# Model: e_coli_core — 95 rxns, 72 mets, 137 genes
# Growth rate: 0.8739 /h
# Status: optimal
Core API
1. Model I/O
Load bundled models and read/write standard formats.
from cobra.io import load_model, read_sbml_model, write_sbml_model, load_json_model, save_json_model
# Bundled: "textbook" (95 rxns), "ecoli" (2583 rxns), "salmonella"
model = load_model("textbook")
# model = read_sbml_model("my_model.xml") # from SBML file
# model = load_json_model("my_model.json") # from JSON file
write_sbml_model(model, "output_model.xml")
save_json_model(model, "output_model.json")
print(f"Saved model: {model.id}")
2. Model Structure and Components
Access reactions, metabolites, and genes via DictList containers.
from cobra.io import load_model
model = load_model("textbook")
# Inspect a reaction
rxn = model.reactions.get_by_id("PFK")
print(f"Reaction: {rxn.id} — {rxn.name}")
print(f"Equation: {rxn.reaction}")
print(f"Bounds: {rxn.bounds}, GPR: {rxn.gene_reaction_rule}")
# Inspect a metabolite
met = model.metabolites.get_by_id("atp_c")
print(f"Metabolite: {met.id}, Formula: {met.formula}, Compartment: {met.compartment}")
# Query and list exchange reactions
atp_rxns = model.reactions.query("atp", attribute="name")
print(f"ATP-related reactions: {len(atp_rxns)}, Exchange reactions: {len(model.exchanges)}")
3. Flux Balance Analysis (FBA)
Predict optimal flux distributions by maximizing an objective.
from cobra.io import load_model
from cobra.flux_analysis import pfba
model = load_model("textbook")
# Standard FBA
solution = model.optimize()
print(f"Growth: {solution.objective_value:.4f} /h, Active fluxes: {(solution.fluxes.abs() > 1e-6).sum()}")
# Parsimonious FBA — same growth, minimal total flux
pfba_sol = pfba(model)
print(f"pFBA total flux: {pfba_sol.fluxes.abs().sum():.1f} vs standard: {solution.fluxes.abs().sum():.1f}")
# Change objective; slim_optimize for speed
from cobra.io import load_model
model = load_model("textbook")
with model:
model.objective = "ATPM"
print(f"Max ATPM flux: {model.optimize().objective_value:.2f}")
print(f"Growth (slim): {model.slim_optimize():.4f}") # no flux vector, faster
4. Flux Variability Analysis (FVA)
Determine feasible flux ranges at or near optimality.
from cobra.io import load_model
from cobra.flux_analysis import flux_variability_analysis
model = load_model("textbook")
fva = flux_variability_analysis(model, fraction_of_optimum=1.0)
fva_90 = flux_variability_analysis(model, fraction_of_optimum=0.9)
fva["range"] = fva["maximum"] - fva["minimum"]
fva_90["range"] = fva_90["maximum"] - fva_90["minimum"]
print(f"Mean range at 100%: {fva['range'].mean():.2f}, at 90%: {fva_90['range'].mean():.2f}")
# Loopless FVA on specific reactions
from cobra.io import load_model
from cobra.flux_analysis import flux_variability_analysis
model = load_model("textbook")
fva_ll = flux_variability_analysis(
model, loopless=True, reaction_list=["PFK", "PGI", "FBA", "TPI", "GAPD"],
)
print(fva_ll)
5. Gene and Reaction Deletions
Screen for essential genes/reactions via knockout simulations.
from cobra.io import load_model
from cobra.flux_analysis import single_gene_deletion, double_gene_deletion
model = load_model("textbook")
wt_growth = model.slim_optimize()
# Single gene deletions
gene_results = single_gene_deletion(model)
gene_results["growth_fraction"] = gene_results["growth"] / wt_growth
essential = gene_results[gene_results["growth_fraction"] < 0.01]
print(f"Essential genes: {len(essential)} / {len(model.genes)}")
# Double deletions (synthetic lethality) — use multiprocessing
double_results = double_gene_deletion(model, processes=4)
print(f"Double deletion results: {double_results.shape}")
6. Growth Media and Minimal Media
Modify nutrient availability and compute minimal media.
from cobra.io import load_model
from cobra.medium import minimal_medium
model = load_model("textbook")
# View current medium
for rxn_id, flux in sorted(model.medium.items()):
print(f" {rxn_id}: {flux}")
# Anaerobic switch via context manager
with model:
medium = model.medium
medium["EX_o2_e"] = 0.0
model.medium = medium
print(f"Anaerobic growth: {model.slim_optimize():.4f} /h")
# Minimal medium
min_med = minimal_medium(model, minimize_components=True, open_exchanges=True)
print(f"Minimal medium: {len(min_med)} components")
7. Flux Sampling
Sample feasible flux distributions for variability analysis.
from cobra.io import load_model
from cobra.sampling import sample
model = load_model("textbook")
samples = sample(model, n=500, method="optgp")
print(f"Samples shape: {samples.shape}") # (500, n_reactions)
print(f"PFK flux: mean={samples['PFK'].mean():.2f}, std={samples['PFK'].std():.2f}")
8. Production Envelopes and Gapfilling
Compute phenotype phase planes and fill model gaps.
from cobra.io import load_model
from cobra.flux_analysis import production_envelope
model = load_model("textbook")
envelope = production_envelope(
model,
reactions=model.reactions.get_by_id("EX_ac_e"),
carbon_sources=model.reactions.get_by_id("EX_glc__D_e"),
)
print(f"Envelope: {len(envelope)} points")
print(envelope[["flux_minimum", "flux_maximum", "carbon_yield_minimum", "carbon_yield_maximum"]].head())
# Gapfilling: restore growth after reaction removal
from cobra.io import load_model
from cobra.flux_analysis.gapfilling import gapfill
model = load_model("textbook")
universal = load_model("textbook") # In practice, use a universal reaction DB
with model:
model.remove_reactions([model.reactions.get_by_id("PFK")])
print(f"Growth after removing PFK: {model.slim_optimize():.4f}")
for rxn in gapfill(model, universal)[0]:
print(f" Gapfill suggests: {rxn.id}")
Key Concepts
DictList Objects
Reactions, metabolites, and genes are stored in DictList — ordered, indexable, and accessible by ID.
rxn = model.reactions[0] # by index
rxn = model.reactions.get_by_id("PFK") # by ID
matches = model.reactions.query("phospho") # keyword search
Exchange Reactions
EX_ prefix reactions represent system boundary. Positive flux = secretion; negative = uptake. Managed via model.medium dict.
Gene-Reaction Rules (GPR)
Boolean expressions linking genes to reactions: (b0726 and b0727) or b1234. Gene knockout propagates through GPR logic.
Context Managers
with model: snapshots state and reverts all changes on exit (bounds, objective, medium, knockouts). Nesting supported.
Common Workflows
Workflow 1: Gene Knockout Screen
Goal: Identify essential, growth-reducing, and neutral genes.
from cobra.io import load_model
from cobra.flux_analysis import single_gene_deletion
model = load_model("textbook")
wt_growth = model.slim_optimize()
results = single_gene_deletion(model)
results["growth_fraction"] = results["growth"] / wt_growth
essential = results[results["growth_fraction"] < 0.01]
reduced = results[(results["growth_fraction"] >= 0.01) & (results["growth_fraction"] < 0.9)]
neutral = results[results["growth_fraction"] >= 0.9]
print(f"Essential: {len(essential)}, Reduced: {len(reduced)}, Neutral: {len(neutral)}")
for idx in essential.index:
print(f" Essential gene: {list(idx)[0]}")
Workflow 2: Media Optimization
Goal: Find minimal medium at different growth targets; compare aerobic vs anaerobic.
from cobra.io import load_model
from cobra.medium import minimal_medium
import pandas as pd
model = load_model("textbook")
results = []
for frac in [0.1, 0.5, 0.8, 1.0]:
with model:
target = model.slim_optimize() * frac
model.reactions.get_by_id("Biomass_Ecoli_core").lower_bound = target
try:
mm = minimal_medium(model, minimize_components=True, open_exchanges=True)
results.append({"growth_frac": frac, "n_components": len(mm)})
except Exception:
results.append({"growth_frac": frac, "n_components": None})
print(pd.DataFrame(results).to_string(index=False))
for label, o2 in [("Aerobic", 1000.0), ("Anaerobic", 0.0)]:
with model:
medium = model.medium
medium["EX_o2_e"] = o2
model.medium = medium
print(f"{label} growth: {model.slim_optimize():.4f} /h")
Workflow 3: Production Strain Design
Goal: Design a strain with maximized target metabolite production. Combines modules 3, 5, and 8.
- Load model and compute wild-type production envelope for target metabolite
- Screen single gene knockouts for overproducers (filter where target secretion increases)
- Combine top knockouts and validate with FBA under production conditions
- Gapfill if knockouts create infeasibilities
- Compute final production envelope and compare to wild-type
Key Parameters
| Parameter | Module/Function | Default | Range / Options | Effect |
|---|---|---|---|---|
fraction_of_optimum |
flux_variability_analysis |
1.0 |
0.0-1.0 |
Fraction of max objective to maintain; lower = wider flux ranges |
loopless |
flux_variability_analysis |
False |
True, False |
Eliminate thermodynamically infeasible loops; slower |
method |
sample |
"optgp" |
"optgp", "achr" |
Sampling algorithm; optgp supports parallelism |
n |
sample |
required | 100-10000 |
Number of flux samples to draw |
processes |
sample, double_gene_deletion |
1 |
1-N_cores |
Parallel worker processes |
minimize_components |
minimal_medium |
False |
True, False |
True = fewest nutrients (MILP); False = minimize total flux |
open_exchanges |
minimal_medium |
False |
True, False |
Allow all exchanges as nutrient candidates |
carbon_sources |
production_envelope |
None |
Reaction object | Compute carbon yield alongside flux envelope |
thinning |
sample |
100 |
1-1000 |
Steps between kept samples; higher = less correlated |
Best Practices
-
Use context managers for temporary changes:
with model:reverts all modifications on exit.with model: model.reactions.PFK.knock_out() print(model.slim_optimize()) # modified # model is restored here -
Validate with
slim_optimize()before analysis: Quick feasibility check before expensive operations (FVA, sampling). -
Check
solution.statusafter optimization: Always verify"optimal"before interpreting fluxes. -
Use loopless FVA when thermodynamic feasibility matters: Standard FVA can include infeasible internal cycles that inflate flux ranges.
-
Parallelize expensive operations: Sampling and double deletions support
processesparameter. -
Prefer SBML for model exchange: Community standard supported by all COBRA tools.
-
Use
slim_optimize()in loops: Skips full flux vector construction, significantly faster for screening. -
Validate flux samples: Use
sampler.validate(samples)to check stoichiometric and bound constraints.
Common Recipes
Recipe: Parameter Scan (Glucose Uptake Rate)
from cobra.io import load_model
model = load_model("textbook")
print("Glucose_uptake | Growth_rate")
for glc_uptake in [1, 2, 5, 10, 15, 20]:
with model:
model.reactions.get_by_id("EX_glc__D_e").lower_bound = -glc_uptake
growth = model.slim_optimize()
print(f" {glc_uptake:>13} | {growth:.4f}")
Recipe: Batch Condition Analysis
from cobra.io import load_model
import pandas as pd
model = load_model("textbook")
conditions = [
{"name": "Rich aerobic", "EX_o2_e": 1000, "EX_glc__D_e": 10},
{"name": "Anaerobic", "EX_o2_e": 0, "EX_glc__D_e": 10},
{"name": "Low glucose", "EX_o2_e": 1000, "EX_glc__D_e": 1},
]
results = []
for c in conditions:
with model:
medium = model.medium
medium["EX_o2_e"], medium["EX_glc__D_e"] = c["EX_o2_e"], c["EX_glc__D_e"]
model.medium = medium
results.append({"condition": c["name"], "growth": round(model.slim_optimize(), 4)})
print(pd.DataFrame(results).to_string(index=False))
Recipe: Model Validation Checklist
from cobra.io import load_model
from cobra.flux_analysis import find_blocked_reactions
model = load_model("textbook")
# Feasibility, mass balance, dead-ends, blocked reactions
print(f"Growth feasible: {model.slim_optimize() > 0}")
print(f"Missing formula: {sum(1 for m in model.metabolites if m.formula is None)}")
print(f"Dead-end metabolites: {sum(1 for m in model.metabolites if len(m.reactions) == 1)}")
print(f"Blocked reactions: {len(find_blocked_reactions(model))} / {len(model.reactions)}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
solution.status == "infeasible" |
Constraints cannot be simultaneously satisfied | Check medium has required nutrients; verify reaction bounds; use model.medium to restore defaults |
solution.status == "unbounded" |
No upper bound on fluxes | Set finite upper bounds on exchange reactions |
| Very slow optimization | Large model + default GLPK solver | Install CPLEX or Gurobi: model.solver = "cplex" |
ValueError setting bounds |
lower_bound > upper_bound temporarily |
Set as tuple: rxn.bounds = (new_lb, new_ub) |
| Gene deletion returns NaN | Knockout makes model infeasible | Expected for essential genes; classify as essential |
IOError reading SBML |
Invalid SBML or missing namespace | Validate at sbml.org; try cobra.io.sbml.validate_sbml_model(path) |
| Flux samples fail validation | Numerical solver tolerance | Increase thinning parameter; try method="achr" |
Bundled Resources
1 reference file:
references/api_workflows.md— Consolidates API quick reference and advanced workflows. Covers: detailed function signatures, solver configuration (GLPK/CPLEX/Gurobi), advanced analysis (find_blocked_reactions,find_essential_genes/find_essential_reactions), model manipulation (adding reactions/metabolites/genes), flux sample validation, and 5 workflow examples (knockout with visualization, media design, flux space exploration, production strain design, model validation). Relocated inline: basic FBA/FVA/deletion/sampling (Core API modules 3-7). Omitted: geometric FBA internals, MIP gap configuration — consult COBRApy docs.
Related Skills
- escher — interactive metabolic map visualization; use JSON models from COBRApy
- bioservices — retrieve models from BiGG, BioModels, or KEGG for import into COBRApy
- networkx — metabolic network topology analysis; export stoichiometry matrix as graph
References
- COBRApy documentation — official API reference and tutorials
- BiGG Models — curated genome-scale metabolic model repository
- COBRApy GitHub — source code and issue tracker
- Ebrahim et al. (2013) "COBRApy: COnstraints-Based Reconstruction and Analysis for Python" — BMC Systems Biology
skills/systems-biology-multiomics/kegg-pathway-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill kegg-pathway-analysis -g -y
SKILL.md
Frontmatter
{
"name": "kegg-pathway-analysis",
"license": "CC-BY-4.0",
"description": "Guide to KEGG pathway enrichment for DEG results. Covers ORA vs GSEA, mandatory directionality splitting, KEGG organism codes, API failure handling with offline fallbacks, cross-condition comparisons, and answer-first reporting. Consult when running enrichment with clusterProfiler or gseapy."
}
KEGG Pathway Enrichment Analysis Guide
Overview
KEGG (Kyoto Encyclopedia of Genes and Genomes) pathway enrichment analysis identifies biological pathways that are statistically over-represented among differentially expressed genes. This guide covers the two main enrichment approaches (ORA and GSEA), critical workflow decisions such as splitting genes by directionality, tool selection between R clusterProfiler and Python gseapy, and strategies for handling the notoriously unreliable KEGG REST API. It addresses recurring failure modes that produce incorrect pathway counts or stalled analyses.
The three most common errors in KEGG pathway analysis are: (1) combining up-regulated and down-regulated genes into a single enrichment run, which masks true pathway signals; (2) analysis failures caused by KEGG REST API timeouts with no fallback strategy; and (3) delaying result reporting while attempting cosmetic pathway name lookups that may never complete. This guide provides concrete solutions for each.
Key Concepts
ORA vs GSEA
Over-Representation Analysis (ORA) and Gene Set Enrichment Analysis (GSEA) are the two primary methods for pathway enrichment, and they differ in both input and statistical approach.
ORA takes a pre-filtered gene list (e.g., genes with padj < 0.05 and |log2FC| > 1.5) and tests whether KEGG pathway members are over-represented in that list relative to a background universe. ORA uses a hypergeometric test (Fisher's exact test). It is straightforward but discards magnitude information and depends heavily on the significance cutoff chosen.
GSEA takes a ranked list of all genes (typically ranked by log2 fold change or a signed significance statistic) without any cutoff. It computes a running enrichment score by walking down the ranked list and identifies pathways whose members cluster toward the top or bottom of the ranking. GSEA captures subtle coordinated changes that ORA may miss.
In practice, ORA via enrichKEGG() (clusterProfiler) or gp.enrichr() (gseapy) is the more common starting point. GSEA via gseKEGG() or gp.prerank() is preferred when you want to avoid arbitrary cutoffs or when effect sizes are small.
Directionality in Enrichment
When performing ORA, gene directionality -- whether a gene is up-regulated or down-regulated -- is critical. A single pathway can contain genes regulated in opposite directions. If up-regulated and down-regulated genes are combined into one list, their opposing signals cancel out, diluting the enrichment signal and masking genuinely enriched pathways. Running enrichment separately for up-regulated and down-regulated gene sets produces more accurate and interpretable results. This splitting is mandatory for ORA. GSEA inherently handles directionality through the signed ranking, though interpreting leading-edge genes by direction is still important.
KEGG Organism Codes
KEGG uses three-letter (or four-letter) organism codes to identify species-specific pathway databases. Using the wrong code silently returns empty results. Common codes:
| Organism | Code |
|---|---|
| Human | hsa |
| Mouse | mmu |
| Rat | rno |
| Zebrafish | dre |
| Drosophila | dme |
| C. elegans | cel |
| E. coli K-12 | eco |
| P. aeruginosa PA14 | pau |
| P. aeruginosa PAO1 | pae |
| S. cerevisiae | sce |
| A. thaliana | ath |
Gene ID format also varies by organism: eukaryotic species typically require Entrez gene IDs, while bacterial species use locus tags. Mismatched ID types are a silent failure mode.
KEGG API Reliability
The KEGG REST API (rest.kegg.jp) is rate-limited, frequently slow, and prone to timeouts. Both clusterProfiler::enrichKEGG() and direct HTTP requests to KEGG can fail unpredictably. Planning for API failures is not optional -- it is a necessary part of any KEGG-based workflow. Strategies include pre-fetching and caching pathway data, using offline gene set databases bundled with gseapy, and implementing retry logic with timeouts.
Decision Framework
Question: What enrichment analysis do you need?
|
+-- Have a pre-filtered DEG list (with cutoffs applied)?
| +-- Yes --> ORA
| | +-- Using R? --> clusterProfiler::enrichKEGG()
| | +-- Using Python? --> gseapy.enrichr()
| | +-- KEGG API failing? --> gseapy with offline gene sets
| +-- No, want cutoff-free analysis --> GSEA
| +-- Using R? --> clusterProfiler::gseKEGG()
| +-- Using Python? --> gseapy.prerank()
|
+-- Need to split by direction?
| +-- ORA --> YES, always split up/down (mandatory)
| +-- GSEA --> No split needed (direction encoded in ranking)
|
+-- KEGG API unreliable?
+-- Try cached/pre-fetched data first
+-- Fall back to gseapy offline databases
+-- Use retry logic with short timeouts
| Scenario | Recommended Approach | Rationale |
|---|---|---|
| Standard ORA with R | clusterProfiler::enrichKEGG(), split by direction |
Most widely used, integrates with Bioconductor ecosystem |
| Standard ORA with Python | gseapy.enrichr() with KEGG_2021_Human |
Offline gene sets avoid API dependency |
| Cutoff-free enrichment | GSEA via gseKEGG() or gp.prerank() |
Captures subtle coordinated changes, no arbitrary threshold |
| KEGG API is down | Switch to gseapy offline databases | gseapy bundles KEGG gene sets locally |
| Comparing conditions | Run separate up/down enrichment per condition | Enables direction-aware set operations across conditions |
| Non-model organism | Verify organism code, use KEGGREST to check availability | Wrong code silently returns empty results |
Best Practices
-
Always split ORA by gene direction. Run
enrichKEGG()orgp.enrichr()separately for up-regulated and down-regulated genes. Combining them inflates the gene list, dilutes enrichment signal, and produces incorrect pathway counts. Report the union of significant pathways from both directions. -
Specify the background universe explicitly. Set the universe to all tested genes (the full set from your differential expression analysis), not just the significant ones. Omitting the universe defaults to all genes in the KEGG database, which inflates significance for well-studied pathways.
-
Pre-fetch and cache KEGG data before running enrichment. Download pathway-gene mappings at the start of the analysis and save them locally. This avoids mid-analysis failures when the KEGG API becomes unresponsive and makes the analysis reproducible.
-
Report the numeric answer before resolving pathway names. Once you have computed the count or list of significant pathway IDs, emit that result immediately. Resolving IDs to human-readable names via additional KEGG API calls is cosmetic and can timeout, losing the primary result.
-
Apply multiple testing correction consistently. Use adjusted p-values (p.adjust < 0.05, typically BH method) rather than raw p-values. Both clusterProfiler and gseapy apply correction by default, but always verify the cutoff is on the adjusted value.
-
Verify gene ID format matches the organism. Eukaryotic KEGG pathways expect Entrez gene IDs; bacterial species expect locus tags. A mismatch silently returns zero enriched pathways. Use
bitr()in clusterProfiler or equivalent ID conversion if your input uses gene symbols. -
Use gseapy as a fallback when clusterProfiler fails. When
enrichKEGG()fails due to KEGG API issues, gseapy'senrichr()function with bundled offline gene sets (e.g.,KEGG_2021_Human) provides equivalent ORA results without any network dependency.
Common Pitfalls
-
Combining up-regulated and down-regulated genes into a single enrichment run. Pathways with genes regulated in opposite directions cancel out, producing fewer significant pathways than the true count. Results from combined lists are unreliable. Example of the anti-pattern:
# WRONG: combining up and down genes into one list all_sig_genes <- rownames(subset(res, padj < 0.05 & abs(log2FoldChange) > 1.5)) ekegg <- enrichKEGG(gene = all_sig_genes, ...) # Will miss pathways- How to avoid: Always split DEGs by direction before ORA. Run enrichment twice (once for up, once for down) and take the union of significant pathways.
-
Using the wrong KEGG organism code. KEGG silently returns empty results for invalid or mismatched organism codes. This is especially common for bacterial species with multiple strain-specific codes (e.g.,
paefor PAO1 vspaufor PA14).- How to avoid: Confirm the organism code from the KEGG organism list before running enrichment. For bacteria, verify the specific strain code.
-
Gene ID type mismatch. Providing gene symbols when KEGG expects Entrez IDs (or locus tags for bacteria) silently yields zero enriched pathways with no error message.
- How to avoid: Check the expected ID type for your organism. Use
clusterProfiler::bitr()or equivalent to convert gene symbols to Entrez IDs before enrichment.
- How to avoid: Check the expected ID type for your organism. Use
-
Not handling KEGG API timeouts. The KEGG REST API frequently times out, causing
enrichKEGG()to fail mid-analysis. Without error handling, the entire analysis is lost.- How to avoid: Wrap KEGG API calls in retry logic with short timeouts (30 seconds). Pre-fetch pathway data at the start. Have gseapy as a fallback.
-
Delaying the answer to resolve pathway names. Calling
keggGet()to convert pathway IDs to human-readable names after computing results can timeout, losing the numeric answer entirely. Example of the anti-pattern:# BAD: answer delayed by name lookup that may hang result_ids <- setdiff(pathways_condA, pathways_condB) names <- keggGet(result_ids) # Can timeout -- answer never emitted count <- length(result_ids)- How to avoid: Report pathway IDs and counts immediately. Resolve names only after the primary result is secured, inside a
tryCatch()or try/except block.
- How to avoid: Report pathway IDs and counts immediately. Resolve names only after the primary result is secured, inside a
-
Omitting the background universe. Not specifying the universe parameter defaults to the entire KEGG gene database for that organism, inflating statistical significance for pathways containing well-annotated housekeeping genes.
- How to avoid: Always pass
universe = rownames(res)(all tested genes from the DE analysis) toenrichKEGG().
- How to avoid: Always pass
-
Using raw p-values instead of adjusted p-values for filtering. Reporting pathways with p < 0.05 without multiple testing correction dramatically increases false positives.
- How to avoid: Always filter on
p.adjust < 0.05. Verify that the column you are filtering is the adjusted value, not the raw p-value.
- How to avoid: Always filter on
Workflow
-
Step 1: Prepare gene lists
- Filter significant DEGs from differential expression results (e.g., padj < 0.05, |log2FC| > 1.5)
- Split into up-regulated and down-regulated gene sets
- Convert gene IDs to the format expected by KEGG (Entrez IDs or locus tags)
library(clusterProfiler) # Filter significant DEGs sig_genes <- subset(res, padj < 0.05 & abs(log2FoldChange) > 1.5) # MANDATORY: Split by direction BEFORE running enrichment up_genes <- rownames(subset(sig_genes, log2FoldChange > 0)) dn_genes <- rownames(subset(sig_genes, log2FoldChange < 0)) cat("Up-regulated genes:", length(up_genes), "\n") cat("Down-regulated genes:", length(dn_genes), "\n") -
Step 2: Pre-fetch KEGG data (recommended)
- Cache pathway-gene mappings locally before running enrichment
- Decision point: If KEGG API is accessible, proceed with live queries. If not, switch to offline gene sets (Step 2b).
R (KEGGREST package):
library(KEGGREST) pathway_list <- tryCatch( keggList("pathway", organism_code), error = function(e) NULL )Python (requests with caching):
import requests import json import os cache_file = f"kegg_{organism_code}_pathways.json" if os.path.exists(cache_file): with open(cache_file) as f: pathway_map = json.load(f) else: resp = requests.get( f"https://rest.kegg.jp/list/pathway/{organism_code}", timeout=30 ) if resp.ok: pathway_map = dict( line.split("\t") for line in resp.text.strip().split("\n") ) with open(cache_file, 'w') as f: json.dump(pathway_map, f) -
Step 3: Run enrichment separately for each direction
- Run ORA twice: once for up-regulated genes, once for down-regulated genes
- Always specify the universe (all tested genes)
R (clusterProfiler):
ekegg_up <- enrichKEGG(gene = up_genes, organism = organism_code, universe = rownames(res), pvalueCutoff = 0.05) ekegg_dn <- enrichKEGG(gene = dn_genes, organism = organism_code, universe = rownames(res), pvalueCutoff = 0.05) up_pathways <- subset(as.data.frame(ekegg_up), p.adjust < 0.05)$ID dn_pathways <- subset(as.data.frame(ekegg_dn), p.adjust < 0.05)$ID all_sig_pathways <- union(up_pathways, dn_pathways) cat("Significant pathways (up):", length(up_pathways), "\n") cat("Significant pathways (down):", length(dn_pathways), "\n") cat("Total unique significant pathways:", length(all_sig_pathways), "\n")Python (gseapy):
import gseapy as gp up_genes = sig_genes[sig_genes['log2FoldChange'] > 0].index.tolist() dn_genes = sig_genes[sig_genes['log2FoldChange'] < 0].index.tolist() enr_up = gp.enrichr(gene_list=up_genes, gene_sets='KEGG_2021_Human', organism='human', outdir=None) enr_dn = gp.enrichr(gene_list=dn_genes, gene_sets='KEGG_2021_Human', organism='human', outdir=None) -
Step 4: Report results immediately
- Emit pathway IDs and counts before attempting name resolution
- Optionally resolve pathway names in a non-blocking manner
result_ids <- setdiff(pathways_condA, pathways_condB) count <- length(result_ids) cat("Answer:", count, "pathways unique to condition A\n") cat("Pathway IDs:", paste(result_ids, collapse = ", "), "\n") # Optional: resolve names (non-blocking) names <- tryCatch(keggGet(result_ids), error = function(e) NULL) -
Step 5: Cross-condition comparison (if applicable)
- Run separate up/down enrichment for each condition (4 enrichKEGG calls total)
- Compare pathway sets within the same direction
- Report the union of direction-specific unique pathways
# Condition A up_pathways_A <- subset(as.data.frame(ekegg_up_A), p.adjust < 0.05)$ID dn_pathways_A <- subset(as.data.frame(ekegg_dn_A), p.adjust < 0.05)$ID # Condition B up_pathways_B <- subset(as.data.frame(ekegg_up_B), p.adjust < 0.05)$ID dn_pathways_B <- subset(as.data.frame(ekegg_dn_B), p.adjust < 0.05)$ID # Find pathways unique to condition A in each direction unique_up <- setdiff(up_pathways_A, up_pathways_B) unique_dn <- setdiff(dn_pathways_A, dn_pathways_B) # Union unique_to_A <- union(unique_up, unique_dn) cat("Pathways in A but not B:", length(unique_to_A), "\n") cat(" From up-regulated:", length(unique_up), "-", paste(unique_up, collapse = ", "), "\n") cat(" From down-regulated:", length(unique_dn), "-", paste(unique_dn, collapse = ", "), "\n") -
Step 6: Handle API failures with retry logic
- If KEGG API calls fail, retry with timeout before falling back to offline data
fetch_kegg_with_retry <- function(organism_code, max_retries = 3, timeout_sec = 30) { for (i in seq_len(max_retries)) { result <- tryCatch({ R.utils::withTimeout( keggList("pathway", organism_code), timeout = timeout_sec ) }, error = function(e) NULL) if (!is.null(result)) return(result) Sys.sleep(2) } warning("KEGG API unreachable after retries. Proceeding without pathway names.") return(NULL) }
Further Reading
- KEGG: Kyoto Encyclopedia of Genes and Genomes -- Official KEGG database with pathway maps, organism lists, and REST API documentation
- clusterProfiler: an R package for comparing biological themes among gene clusters -- Original publication by Guangchuang Yu describing the enrichKEGG and gseKEGG functions
- gseapy documentation -- Python package for gene set enrichment analysis with offline KEGG gene set support
- KEGG REST API reference -- Technical documentation for programmatic access to KEGG pathway data
Related Skills
gseapy-gene-enrichment-- Python-based gene set enrichment analysis; use as a fallback when clusterProfiler KEGG API calls fail, or as the primary tool for Python-based workflowsdeseq2-differential-expression/pydeseq2-differential-expression-- Upstream differential expression analysis that produces the DEG lists used as input to KEGG pathway enrichment
skills/systems-biology-multiomics/lamindb-data-management/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill lamindb-data-management -g -y
SKILL.md
Frontmatter
{
"name": "lamindb-data-management",
"license": "Apache-2.0",
"description": "Open-source FAIR biology data framework. Version artifacts (AnnData, DataFrame, Zarr), track lineage, validate via ontologies (Bionty), query datasets. Integrates with Nextflow, Snakemake, W&B, scVI. For scRNA-seq use scanpy; for ontology lookups use bionty."
}
LaminDB — Biological Data Management
Overview
LaminDB is an open-source data framework for biology that makes data queryable, traceable, and FAIR (Findable, Accessible, Interoperable, Reusable). It combines data lakehouse architecture, lineage tracking, biological ontology validation, and a unified Python API for managing biological datasets from raw files to annotated, curated artifacts.
When to Use
- Managing and versioning biological datasets (scRNA-seq, spatial, flow cytometry, multi-modal)
- Tracking computational lineage (which code produced which data)
- Validating and curating data against biological ontologies (cell types, genes, tissues, diseases)
- Building queryable data lakehouses across multiple experiments
- Ensuring reproducibility with automatic environment and provenance capture
- Integrating with workflow managers (Nextflow, Snakemake) or MLOps (W&B, MLflow)
- Standardizing metadata with ontology-based annotation (Bionty)
- For single-cell analysis pipelines (clustering, DE), use scanpy instead
- For ontology lookups only without data management, use bionty directly
Prerequisites
pip install lamindb
# With extras for specific data types
pip install 'lamindb[bionty,zarr,fcs]'
Setup: Requires instance initialization before use:
lamin login
lamin init --storage ./my-data --name my-project
# Or with cloud storage:
# lamin init --storage s3://my-bucket --name my-project --db postgresql://...
Instance types: Local SQLite (development), Cloud + SQLite (small teams), Cloud + PostgreSQL (production).
Quick Start
import lamindb as ln
ln.track() # Start lineage tracking
# Save an artifact
import pandas as pd
df = pd.DataFrame({"gene": ["TP53", "BRCA1"], "score": [0.95, 0.87]})
artifact = ln.Artifact.from_df(df, key="results/gene_scores.parquet", description="Gene importance scores")
artifact.save()
print(f"Saved: {artifact.uid}, size: {artifact.size}")
# Query artifacts
results = ln.Artifact.filter(key__startswith="results/").df()
print(f"Found {len(results)} artifacts")
ln.finish()
Core API
1. Artifacts — Data Objects
Artifacts are versioned data objects (files, DataFrames, AnnData, arrays).
import lamindb as ln
import pandas as pd
import anndata as ad
ln.track()
# From DataFrame
df = pd.DataFrame({"sample": ["A", "B"], "value": [1.5, 2.3]})
artifact = ln.Artifact.from_df(df, key="experiments/batch1.parquet").save()
print(f"ID: {artifact.uid}, Version: {artifact.version}")
# From AnnData
adata = ad.read_h5ad("counts.h5ad")
artifact = ln.Artifact.from_anndata(adata, key="scrna/batch1.h5ad", description="scRNA-seq batch 1").save()
# From file path
artifact = ln.Artifact("results/figure.png", key="figures/fig1.png").save()
# Load back
df_loaded = artifact.load() # Returns DataFrame/AnnData/etc.
path = artifact.cache() # Returns local file path
# Versioning
artifact_v2 = ln.Artifact.from_df(df_updated, key="experiments/batch1.parquet", revises=artifact).save()
print(f"v1: {artifact.uid}, v2: {artifact_v2.uid}")
print(f"Latest version: {artifact_v2.is_latest}")
# Delete (archive first, then permanent)
artifact.delete(permanent=False) # Archive
# artifact.delete(permanent=True) # Permanent deletion
2. Lineage Tracking
Automatic provenance capture for reproducibility.
import lamindb as ln
# Start tracking — captures notebook/script, environment, user
ln.track(params={"method": "PCA", "n_components": 50})
# All artifacts created within this block are linked to this run
input_data = ln.Artifact.get(key="raw/counts.h5ad")
adata = input_data.load()
# ... analysis code ...
output = ln.Artifact.from_anndata(adata, key="processed/pca.h5ad").save()
# View lineage graph
output.view_lineage()
ln.finish() # Finalize tracking
3. Querying and Filtering
Search and filter artifacts by metadata, features, and annotations.
import lamindb as ln
# Basic filtering
artifacts = ln.Artifact.filter(key__startswith="scrna/").df()
print(f"Found {len(artifacts)} scRNA-seq artifacts")
# Filter by metadata
recent = ln.Artifact.filter(
created_at__gte="2026-01-01",
size__gt=1000000
).df()
# Filter by annotated features
immune = ln.Artifact.filter(
cell_types__name="T cell",
tissues__name="PBMC"
).df()
# Single record retrieval
artifact = ln.Artifact.get(key="results/final.parquet") # Exact match, raises if not found
artifact = ln.Artifact.filter(key="results/final.parquet").one_or_none() # Returns None if missing
# Full-text search
results = ln.Artifact.search("gene expression PBMC")
# Streaming large files (without full load into memory)
artifact = ln.Artifact.get(key="large_dataset.h5ad")
backed = artifact.open() # AnnData-backed mode
subset = backed[backed.obs["cell_type"] == "B cell"]
4. Annotation and Validation
Curate datasets against schemas and ontology terms.
import lamindb as ln
import bionty as bt
# Annotate artifacts with features
artifact = ln.Artifact.get(key="scrna/batch1.h5ad")
artifact.features.add_values({
"tissue": "PBMC",
"condition": "treated",
"organism": "human",
"batch": 1
})
# Validate with schema
curator = ln.curators.AnnDataCurator(adata, schema)
try:
curator.validate()
artifact = curator.save_artifact(key="validated/batch1.h5ad")
print("Validation passed")
except ln.errors.ValidationError as e:
print(f"Validation failed: {e}")
# Standardize cell type names using ontology
adata.obs["cell_type"] = bt.CellType.standardize(adata.obs["cell_type"])
5. Biological Ontologies (Bionty)
Access standardized biological vocabularies for annotation.
import bionty as bt
# Available ontologies
# bt.Gene (Ensembl), bt.Protein (UniProt), bt.CellType (CL),
# bt.Tissue (Uberon), bt.Disease (Mondo), bt.Pathway (GO),
# bt.CellLine (CLO), bt.Phenotype (HPO), bt.Organism (NCBItaxon)
# Import and search ontology
bt.CellType.import_source()
results = bt.CellType.search("T helper")
print(results.head())
# Get specific term
t_cell = bt.CellType.get(name="T cell")
print(f"Ontology ID: {t_cell.ontology_id}")
# Explore hierarchy
children = t_cell.children.all()
parents = t_cell.parents.all()
print(f"Children: {[c.name for c in children]}")
# Validate a list of terms
validated = bt.CellType.validate(["T cell", "B cell", "Unknown_type"])
# Returns boolean array: [True, True, False]
6. Collections and Organization
Group related artifacts for batch operations.
import lamindb as ln
# Create a collection
artifacts = ln.Artifact.filter(key__startswith="scrna/batch_").all()
collection = ln.Collection(artifacts, name="scRNA-seq batches Q1 2026").save()
print(f"Collection: {collection.name}, {collection.n_objects} artifacts")
# Query collection
for artifact in collection.artifacts.all():
print(f" {artifact.key}: {artifact.size} bytes")
# Organize with hierarchical keys
# Convention: project/experiment/datatype/file
# e.g., "immunology/exp42/scrna/counts.h5ad"
Key Concepts
Core Entity Model
| Entity | Purpose | Example |
|---|---|---|
| Artifact | Versioned data object | counts.h5ad, results.parquet |
| Run | Single code execution | Notebook run, script execution |
| Transform | Code definition (notebook, script, pipeline) | analysis.ipynb |
| Feature | Typed metadata field | tissue, condition, batch |
| Collection | Group of related artifacts | "Experiment batches" |
| ULabel | Universal label for custom categorization | "high_quality", "pilot" |
Data Types Supported
| Format | Method | Use Case |
|---|---|---|
| DataFrame | Artifact.from_df() |
Tabular data, metadata tables |
| AnnData | Artifact.from_anndata() |
Single-cell data |
| MuData | Artifact.from_mudata() |
Multi-modal data |
| Any file | Artifact("path") |
Images, FASTQ, custom formats |
| Zarr | Via zarr extra | Large array data |
| TileDB-SOMA | Via tiledbsoma extra | Scalable cell-level queries |
track() / finish() Pattern
Every analysis session should be wrapped:
ln.track(params={"key": "value"}) # Start: captures code, environment, user
# ... analysis ...
ln.finish() # End: finalizes lineage links
Common Workflows
Workflow: Multi-Experiment Data Lakehouse
import lamindb as ln
import anndata as ad
ln.track()
# Register multiple experiments
data_files = ["batch1.h5ad", "batch2.h5ad", "batch3.h5ad"]
tissues = ["PBMC", "bone_marrow", "PBMC"]
conditions = ["control", "treated", "treated"]
for i, (file, tissue, condition) in enumerate(zip(data_files, tissues, conditions)):
adata = ad.read_h5ad(file)
artifact = ln.Artifact.from_anndata(
adata, key=f"scrna/batch_{i}.h5ad", description=f"scRNA-seq batch {i}"
).save()
artifact.features.add_values({
"tissue": tissue, "condition": condition, "batch": i
})
print(f"Registered batch {i}: {artifact.uid}")
# Query across all experiments
treated_pbmc = ln.Artifact.filter(
key__startswith="scrna/",
features__tissue="PBMC",
features__condition="treated"
).all()
print(f"Found {len(treated_pbmc)} matching datasets")
# Load and concatenate
import anndata as ad
adatas = [a.load() for a in treated_pbmc]
combined = ad.concat(adatas)
print(f"Combined: {combined.shape}")
ln.finish()
Workflow: Validated Data Curation
import lamindb as ln
import bionty as bt
import anndata as ad
ln.track()
# 1. Import ontologies
bt.CellType.import_source()
bt.Gene.import_source(organism="human")
# 2. Load raw data
adata = ad.read_h5ad("raw_counts.h5ad")
print(f"Raw: {adata.shape}")
# 3. Validate and standardize cell types
validated = bt.CellType.validate(adata.obs["cell_type"].unique())
if not all(validated):
adata.obs["cell_type"] = bt.CellType.standardize(adata.obs["cell_type"])
# 4. Validate gene names
gene_validated = bt.Gene.validate(adata.var_names)
print(f"Valid genes: {sum(gene_validated)}/{len(gene_validated)}")
# 5. Curate and save
curator = ln.curators.AnnDataCurator(adata, schema)
curator.validate()
artifact = curator.save_artifact(key="curated/validated_counts.h5ad")
print(f"Saved curated artifact: {artifact.uid}")
ln.finish()
Workflow: Nextflow Pipeline Integration
- In each Nextflow process, import lamindb and call
ln.track() - Load input artifacts with
ln.Artifact.get(key=...); cache to local path - Run analysis; save output as new artifact with
ln.Artifact(...).save() - Call
ln.finish()— lineage automatically links inputs to outputs
Key Parameters
| Parameter | Function | Default | Options | Effect |
|---|---|---|---|---|
key |
Artifact() |
None | String path | Hierarchical storage key (e.g., "project/data.h5ad") |
description |
Artifact() |
None | String | Human-readable description |
revises |
Artifact() |
None | Artifact | Previous version to revise |
params |
ln.track() |
None | Dict | Parameters for the current run |
organism |
bt.Gene.import_source() |
None | "human", "mouse" | Organism for ontology |
permanent |
.delete() |
False | True/False | Permanent vs archive deletion |
__startswith |
.filter() |
— | String | Key prefix filter |
__gte, __lte |
.filter() |
— | Value | Greater/less than or equal |
__contains |
.filter() |
— | String | Substring match |
Best Practices
-
Always wrap analysis with
ln.track()/ln.finish(): This captures lineage automatically. Without it, artifacts have no provenance. -
Use hierarchical keys: Structure as
project/experiment/datatype/file.ext(e.g.,immunology/exp42/scrna/counts.h5ad). This enables prefix-based queries. -
Anti-pattern — duplicating data instead of versioning: Use the
revises=parameter to create new versions, not new keys for the same dataset. -
Validate early: Run schema validation before analysis. Catching bad metadata early saves debugging time downstream.
-
Use ontologies for standardization: Map free-text labels to ontology terms (e.g., "T helper cell" → CL:0000912). This enables cross-dataset queries.
-
Anti-pattern — loading large files without checking size: Use
.filter().df()to inspect metadata first, then.load()or.open()(backed mode) for large files. -
Query metadata first, load data second: Filter with
.filter()to find relevant artifacts, then load only what you need.
Common Recipes
Recipe: Bulk Dataset Registration
import lamindb as ln
from pathlib import Path
ln.track()
data_dir = Path("raw_data/")
for fcs_file in data_dir.glob("*.fcs"):
artifact = ln.Artifact(str(fcs_file), key=f"flow_cytometry/{fcs_file.name}").save()
artifact.features.add_values({"assay": "flow_cytometry", "source": "batch_import"})
print(f"Registered: {fcs_file.name} -> {artifact.uid}")
ln.finish()
Recipe: View and Export Lineage
import lamindb as ln
artifact = ln.Artifact.get(key="results/final_analysis.h5ad")
# View lineage graph (opens in browser or notebook)
artifact.view_lineage()
# Programmatic lineage access
run = artifact.run
print(f"Created by: {run.transform.name}")
print(f"User: {run.created_by.name}")
print(f"Date: {run.created_at}")
print(f"Input artifacts: {[a.key for a in run.input_artifacts.all()]}")
Recipe: Ontology Hierarchy Exploration
import bionty as bt
bt.CellType.import_source()
t_cell = bt.CellType.get(name="T cell")
# Explore hierarchy
print(f"Parents: {[p.name for p in t_cell.parents.all()]}")
print(f"Children: {[c.name for c in t_cell.children.all()]}")
# Find all descendants
descendants = t_cell.children.all()
for child in descendants:
grandchildren = child.children.all()
print(f" {child.name}: {[gc.name for gc in grandchildren]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
InstanceNotSetupError |
Instance not initialized | Run lamin init --storage ./data --name my-project |
ln.track() fails |
No transform context | Run inside a notebook/script, not REPL; or pass transform explicitly |
Artifact key conflict |
Key already exists (not a version) | Use revises= for versioning, or choose a different key |
ValidationError |
Data doesn't match schema | Run curator.validate() to see specific failures; standardize terms |
| Slow queries on large instances | No index on filtered field | Use .df() for overview first; add database indexes for frequently filtered fields |
| Ontology import fails | Network issue or wrong organism | Check internet connection; specify organism="human" explicitly |
FileNotFoundError on .cache() |
Cloud artifact not synced | Check storage connectivity; use artifact.load() instead for in-memory access |
Related Skills
- anndata-data-structure — AnnData format used as primary data container in LaminDB for single-cell data
- scanpy-scrna-seq — single-cell analysis pipeline; LaminDB manages data that scanpy analyzes
- scvi-tools-single-cell — deep learning models for single-cell; integrates with LaminDB for data/model tracking
References
- LaminDB documentation — official user guide and API reference
- LaminDB tutorial — step-by-step introduction
- Bionty documentation — biological ontology management
- LaminDB GitHub — source code and issues
skills/systems-biology-multiomics/libsbml-network-modeling/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill libsbml-network-modeling -g -y
SKILL.md
Frontmatter
{
"name": "libsbml-network-modeling",
"license": "LGPL-2.1",
"description": "Build, read, validate, modify SBML biological network models via the libSBML Python API. SBML Levels 1–3, reactions\/kinetic laws, species, rules, FBC extension for flux balance, conversion. Interoperates with COBRApy, Tellurium\/RoadRunner, COPASI. Use when programmatically constructing ODE or constraint-based metabolic\/signaling models in SBML."
}
libsbml-network-modeling
Overview
libSBML is the reference library for reading, writing, creating, and validating SBML (Systems Biology Markup Language) models. SBML is the community standard for encoding biochemical reaction networks — ODE models, signaling cascades, and genome-scale metabolic models all use it. The Python API (python-libsbml) exposes a full object model covering compartments, species, reactions, kinetic laws, rules, constraints, and every SBML extension. Models saved as SBML .xml files are interoperable with COPASI, Tellurium, RoadRunner, COBRApy, and BioModels Database.
When to Use
- Building a new ODE-based biochemical model (enzyme kinetics, signaling pathway) from scratch in SBML format for simulation in COPASI or Tellurium
- Reading and programmatically modifying an existing BioModels Database model — changing kinetic parameters, adding species, or patching reaction stoichiometry
- Validating an SBML file against the specification before submitting to BioModels or sharing with collaborators
- Converting SBML models between Level 1/2/3 for compatibility with older simulation tools
- Constructing genome-scale metabolic models with flux bounds and objective functions via the FBC (Flux Balance Constraints) extension for use with COBRApy
- Parsing an SBML model to extract the stoichiometry matrix, species list, or reaction network as NumPy/pandas data structures for custom analysis
- Use
cobrapy-metabolic-modelinginstead when you need to run FBA, FVA, or gene knockouts on an already-built metabolic model — libSBML is for constructing and editing the SBML file itself - Use
telluriumdirectly when you want an integrated Python environment for both SBML authoring (Antimony syntax) and ODE simulation without low-level XML manipulation
Prerequisites
- Python packages:
python-libsbml,numpy,pandas(optional, for matrix extraction) - Optional packages:
cobra(COBRApy, for FBA after SBML load),tellurium(for SBML↔Antimony conversion and simulation) - Data requirements: SBML files (
.xml), or built from scratch in Python; BioModels Database SBML files are freely available at https://www.ebi.ac.uk/biomodels/
pip install python-libsbml numpy pandas
# Optional simulation/FBA integrations:
pip install cobra tellurium
Quick Start
Load an SBML file, inspect its content, and modify a parameter value:
import libsbml
# Read an SBML model file
reader = libsbml.SBMLReader()
doc = reader.readSBMLFromFile("BIOMD0000000012.xml")
# Check for errors
if doc.getNumErrors() > 0:
doc.printErrors()
model = doc.getModel()
print(f"Model: {model.getId()}")
print(f" Compartments: {model.getNumCompartments()}")
print(f" Species: {model.getNumSpecies()}")
print(f" Reactions: {model.getNumReactions()}")
# Modify a global parameter
param = model.getParameter("Km")
if param:
old_val = param.getValue()
param.setValue(0.05)
print(f"Updated Km: {old_val} → {param.getValue()}")
# Write modified model back to file
writer = libsbml.SBMLWriter()
writer.writeSBMLToFile(doc, "BIOMD0000000012_modified.xml")
print("Saved modified model.")
Core API
Module 1: Reading and Validating SBML
Load SBML files from disk or strings, check parse errors, and run full SBML spec validation.
import libsbml
# Read from file
reader = libsbml.SBMLReader()
doc = reader.readSBMLFromFile("model.xml")
# Check for fatal parse errors
n_errors = doc.getNumErrors()
print(f"Parse errors: {n_errors}")
for i in range(n_errors):
err = doc.getError(i)
severity = err.getSeverityAsString()
print(f" [{severity}] line {err.getLine()}: {err.getMessage()}")
# Check the SBML Level and Version
print(f"SBML Level {doc.getLevel()} Version {doc.getVersion()}")
# Read from in-memory XML string
xml_string = open("model.xml").read()
doc2 = reader.readSBMLFromString(xml_string)
model = doc2.getModel()
print(f"Model id: {model.getId()}, name: {model.getName()}")
import libsbml
# Full consistency / validation check (more thorough than parse error check)
doc = libsbml.readSBMLFromFile("model.xml")
# Enable all consistency checks
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_GENERAL_CONSISTENCY, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_IDENTIFIER_CONSISTENCY, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_UNITS_CONSISTENCY, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_MATHML_CONSISTENCY, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_SBO_CONSISTENCY, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_OVERDETERMINED_MODEL, True)
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_MODELING_PRACTICE, True)
n_errors = doc.checkConsistency()
print(f"Consistency check: {n_errors} issue(s)")
for i in range(n_errors):
err = doc.getError(i)
print(f" [{err.getSeverityAsString()}] {err.getShortMessage()}: {err.getMessage()[:120]}")
Module 2: Creating Models from Scratch
Build a complete SBML document by adding compartments, species, and reactions programmatically.
import libsbml
# Create a new SBML Level 3 Version 2 document
doc = libsbml.SBMLDocument(3, 2)
model = doc.createModel()
model.setId("simple_enzymatic_model")
model.setName("Simple Enzymatic Reaction Model")
model.setTimeUnits("second")
model.setSubstanceUnits("mole")
model.setVolumeUnits("litre")
model.setExtentUnits("mole")
# Add a compartment (cytoplasm)
comp = model.createCompartment()
comp.setId("cytoplasm")
comp.setName("Cytoplasm")
comp.setConstant(True)
comp.setSize(1.0) # 1 litre
comp.setSpatialDimensions(3)
comp.setUnits("litre")
# Add species: substrate S, enzyme E, complex ES, product P
species_data = [
("S", "Substrate", 0.01, True), # (id, name, initialConc, boundaryCondition)
("E", "Enzyme", 0.001, False),
("ES", "Enzyme-Substrate", 0.0, False),
("P", "Product", 0.0, True),
]
for sp_id, sp_name, init_conc, boundary in species_data:
sp = model.createSpecies()
sp.setId(sp_id)
sp.setName(sp_name)
sp.setCompartment("cytoplasm")
sp.setInitialConcentration(init_conc)
sp.setBoundaryCondition(boundary)
sp.setHasOnlySubstanceUnits(False)
sp.setConstant(False)
print(f"Added species: {sp_id} (init={init_conc} M, boundary={boundary})")
print(f"Model has {model.getNumSpecies()} species and {model.getNumCompartments()} compartment(s)")
Module 3: Editing Reactions and Kinetic Laws
Add reactions with stoichiometry and MathML kinetic law formulas.
import libsbml
# Continuing from Module 2: add Michaelis-Menten kinetics reactions
# Forward: S + E -> ES (association)
# Reverse: ES -> S + E (dissociation)
# Catalytic: ES -> P + E (product release)
# First, add kinetic parameters as global parameters
params = [
("kf", 1e6, "litre per mole per second"), # forward rate constant
("kr", 1e-3, "per second"), # reverse rate constant
("kcat", 0.1, "per second"), # catalytic rate constant
]
for p_id, p_val, p_units in params:
param = model.createParameter()
param.setId(p_id)
param.setValue(p_val)
param.setConstant(True)
# Units are for documentation — libSBML stores them as unit definitions
print(f"Added parameter: {p_id} = {p_val}")
def add_reaction(model, rxn_id, rxn_name, reactants, products, formula):
"""Helper: create a reaction with MathML kinetic law."""
rxn = model.createReaction()
rxn.setId(rxn_id)
rxn.setName(rxn_name)
rxn.setReversible(False)
for sp_id, stoich in reactants:
sr = rxn.createReactant()
sr.setSpecies(sp_id)
sr.setStoichiometry(stoich)
sr.setConstant(True)
for sp_id, stoich in products:
sr = rxn.createProduct()
sr.setSpecies(sp_id)
sr.setStoichiometry(stoich)
sr.setConstant(True)
kl = rxn.createKineticLaw()
math_ast = libsbml.parseL3Formula(formula)
if math_ast is None:
raise ValueError(f"Could not parse formula: {formula}")
kl.setMath(math_ast)
return rxn
add_reaction(model, "v1", "Association", [("S",1),("E",1)], [("ES",1)], "kf * S * E * cytoplasm")
add_reaction(model, "v2", "Dissociation", [("ES",1)], [("S",1),("E",1)], "kr * ES * cytoplasm")
add_reaction(model, "v3", "Catalysis", [("ES",1)], [("P",1),("E",1)], "kcat * ES * cytoplasm")
print(f"Model has {model.getNumReactions()} reactions")
writer = libsbml.SBMLWriter()
writer.writeSBMLToFile(doc, "michaelis_menten.xml")
print("Saved michaelis_menten.xml")
Module 4: Species and Compartments
Inspect and modify species properties — initial amounts vs concentrations, boundary conditions, compartment volumes.
import libsbml
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
# Iterate species and print their properties
print(f"{'ID':<12} {'Compartment':<15} {'InitConc':>10} {'InitAmt':>10} {'Boundary':>10} {'Constant':>10}")
print("-" * 70)
for i in range(model.getNumSpecies()):
sp = model.getSpecies(i)
init_conc = sp.getInitialConcentration() if sp.isSetInitialConcentration() else "—"
init_amt = sp.getInitialAmount() if sp.isSetInitialAmount() else "—"
print(f"{sp.getId():<12} {sp.getCompartment():<15} {str(init_conc):>10} {str(init_amt):>10} "
f"{str(sp.getBoundaryCondition()):>10} {str(sp.getConstant()):>10}")
# Modify compartment volume (e.g. scale to a smaller cell)
comp = model.getCompartment("cytoplasm")
if comp:
old_size = comp.getSize()
comp.setSize(old_size * 0.1)
print(f"\nCytoplasm volume: {old_size} → {comp.getSize()} litre")
# Set a species initial concentration by ID
sp = model.getSpecies("S")
if sp:
sp.setInitialConcentration(0.005)
print(f"Updated [S] initial concentration to {sp.getInitialConcentration()} M")
Module 5: Rules and Constraints
Add assignment rules, rate rules, and algebraic rules to model derived quantities or conserved relationships.
import libsbml
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
# AssignmentRule: computes a variable algebraically at every time step
# Example: total enzyme E_total = E + ES (conservation relationship, monitoring only)
ar = model.createAssignmentRule()
ar.setVariable("E_total") # must be an existing parameter or species id
# First add the target as a parameter if needed
if model.getParameter("E_total") is None:
p = model.createParameter()
p.setId("E_total")
p.setConstant(False) # MUST be False for assignment rule targets
p.setValue(0.0)
math_ast = libsbml.parseL3Formula("E + ES")
ar.setMath(math_ast)
print(f"Added AssignmentRule: E_total = E + ES")
# RateRule: specifies dX/dt directly, bypassing reaction-based ODE generation
# Useful for custom non-mass-action dynamics
rr = model.createRateRule()
rr.setVariable("P") # species P already has boundary=True to allow rate rules
math_ast2 = libsbml.parseL3Formula("kcat * ES * cytoplasm")
rr.setMath(math_ast2)
print(f"Added RateRule: dP/dt = kcat * ES * cytoplasm")
# Constraint: model invariant that simulators should monitor (not enforced computationally)
constraint = model.createConstraint()
math_ast3 = libsbml.parseL3Formula("S >= 0")
constraint.setMath(math_ast3)
msg = libsbml.XMLNode.convertStringToXMLNode("<message><p>Substrate cannot be negative</p></message>")
constraint.setMessage(msg)
print(f"Added Constraint: S >= 0")
print(f"Model rules: {model.getNumRules()}, constraints: {model.getNumConstraints()}")
Module 6: FBC Extension (Flux Balance Constraints)
Use the SBML FBC package to encode genome-scale metabolic models with flux bounds and an objective function for use with COBRApy or other FBA solvers.
import libsbml
# Build a minimal FBC-enabled model (3-reaction toy network)
doc = libsbml.SBMLDocument(3, 2)
# Enable FBC package (required)
doc.enablePackage(libsbml.FbcExtension.getXmlnsL3V1V2(), "fbc", True)
doc.setPackageRequired("fbc", False)
model = doc.createModel()
model.setId("toy_fba_model")
fbc_plugin = model.getPlugin("fbc")
fbc_plugin.setStrict(True)
# Add a compartment and species
comp = model.createCompartment()
comp.setId("c")
comp.setConstant(True)
comp.setSize(1.0)
for sp_id in ["A", "B", "C"]:
sp = model.createSpecies()
sp.setId(sp_id)
sp.setCompartment("c")
sp.setInitialAmount(0.0)
sp.setBoundaryCondition(False)
sp.setConstant(False)
sp.setHasOnlySubstanceUnits(True)
sp_fbc = sp.getPlugin("fbc")
sp_fbc.setChemicalFormula("")
# Add flux bound parameters
bounds = {"lb_0": 0.0, "lb_neg1000": -1000.0, "ub_1000": 1000.0}
for b_id, b_val in bounds.items():
p = model.createParameter()
p.setId(b_id)
p.setValue(b_val)
p.setConstant(True)
def add_fbc_reaction(model, rxn_id, reactants, products, lb_id, ub_id):
rxn = model.createReaction()
rxn.setId(rxn_id)
rxn.setReversible(lb_id == "lb_neg1000")
rxn.setFast(False)
for sp_id, stoich in reactants:
sr = rxn.createReactant(); sr.setSpecies(sp_id); sr.setStoichiometry(stoich); sr.setConstant(True)
for sp_id, stoich in products:
sr = rxn.createProduct(); sr.setSpecies(sp_id); sr.setStoichiometry(stoich); sr.setConstant(True)
rxn_fbc = rxn.getPlugin("fbc")
rxn_fbc.setLowerFluxBound(lb_id)
rxn_fbc.setUpperFluxBound(ub_id)
return rxn
add_fbc_reaction(model, "r1", [("A", 1)], [("B", 1)], "lb_0", "ub_1000")
add_fbc_reaction(model, "r2", [("B", 1)], [("C", 1)], "lb_0", "ub_1000")
add_fbc_reaction(model, "r3", [("A", 1)], [], "lb_0", "ub_1000") # exchange
# Add objective function: maximize r2 flux
obj = fbc_plugin.createObjective()
obj.setId("maximize_r2")
obj.setType("maximize")
fbc_plugin.setActiveObjectiveId("maximize_r2")
flux_obj = obj.createFluxObjective()
flux_obj.setReaction("r2")
flux_obj.setCoefficient(1.0)
writer = libsbml.SBMLWriter()
writer.writeSBMLToFile(doc, "toy_fba.xml")
print(f"Saved toy_fba.xml (Level {doc.getLevel()} Version {doc.getVersion()}, FBC enabled)")
print(f"Reactions: {model.getNumReactions()}, Objective: maximize r2")
Module 7: Exporting and Level Conversion
Write models to file or string, convert between SBML levels, and export to Antimony notation via Tellurium.
import libsbml
doc = libsbml.readSBMLFromFile("michaelis_menten.xml")
model = doc.getModel()
print(f"Loaded: Level {doc.getLevel()}, Version {doc.getVersion()}")
# Write to XML string (useful for in-memory transmission)
writer = libsbml.SBMLWriter()
xml_string = writer.writeSBMLToString(doc)
print(f"XML string length: {len(xml_string)} characters")
# Convert Level 3 → Level 2 (for compatibility with older tools)
# SBMLDocument.setLevelAndVersion handles conversion automatically
props = libsbml.ConversionProperties()
props.addOption("setLevelAndVersion", True, "Convert level and version")
props.addOption("targetLevel", 2)
props.addOption("targetVersion", 4)
status = doc.convert(props)
if status == libsbml.LIBSBML_OPERATION_SUCCESS:
writer.writeSBMLToFile(doc, "michaelis_menten_L2V4.xml")
print(f"Converted to L2V4 → michaelis_menten_L2V4.xml")
else:
print(f"Conversion failed with code: {status}")
# Export SBML to Antimony (human-readable) via Tellurium (optional)
try:
import tellurium as te
antimony_str = te.sbmlToAntimony(open("michaelis_menten.xml").read())
print("Antimony notation:")
print(antimony_str[:600])
with open("michaelis_menten.ant", "w") as f:
f.write(antimony_str)
print("Saved michaelis_menten.ant")
except ImportError:
print("tellurium not installed — skipping Antimony export")
Key Concepts
SBMLDocument, Model, and the Plugin Architecture
Every libSBML session starts with an SBMLDocument that owns exactly one Model. Extension packages (FBC, qual, layout, groups, distrib) are accessed as plugins retrieved via object.getPlugin("fbc"). Plugins are only available after enabling the package on the document with doc.enablePackage(...). Calling getPlugin on a document that has not enabled the package returns None.
import libsbml
doc = libsbml.readSBMLFromFile("iJO1366.xml")
model = doc.getModel()
# Check which packages are active
for i in range(doc.getNumPlugins()):
pkg = doc.getPlugin(i)
print(f"Package: {pkg.getPackageName()} (level {pkg.getLevel()})")
# Access FBC plugin
fbc_plugin = model.getPlugin("fbc")
if fbc_plugin:
print(f"FBC strict mode: {fbc_plugin.getStrict()}")
print(f"Objectives: {fbc_plugin.getNumObjectives()}")
MathML Formulas and the AST
Kinetic laws in SBML are stored as MathML. libSBML parses formula strings to an Abstract Syntax Tree (AST) using libsbml.parseL3Formula(string) and converts AST back to a string with libsbml.formulaToL3String(ast). Always check that parseL3Formula returns non-None before assigning to a kinetic law — a None return means parsing failed silently.
import libsbml
# Parse and inspect a kinetic formula
formula = "Vmax * S / (Km + S) * cytoplasm"
ast = libsbml.parseL3Formula(formula)
if ast is None:
print("ERROR: formula could not be parsed")
else:
print(f"Parsed formula: {libsbml.formulaToL3String(ast)}")
print(f"AST root type: {ast.getType()}") # e.g., AST_TIMES
# Retrieve a kinetic law formula from an existing reaction
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
rxn = model.getReaction(0)
if rxn and rxn.isSetKineticLaw():
kl = rxn.getKineticLaw()
formula_str = libsbml.formulaToL3String(kl.getMath())
print(f"Reaction '{rxn.getId()}' kinetic law: {formula_str}")
Common Workflows
Workflow 1: Load BioModels Model, Modify Parameters, and Simulate
Goal: Download a BioModels model, adjust kinetic parameters, and run an ODE simulation with Tellurium/RoadRunner.
import libsbml
import urllib.request
# 1. Download SBML from BioModels Database (BIOMD0000000012 = Tyson 1991 cell cycle)
url = "https://www.ebi.ac.uk/biomodels/model/download/BIOMD0000000012?filename=BIOMD0000000012_url.xml"
urllib.request.urlretrieve(url, "BIOMD0000000012.xml")
print("Downloaded BIOMD0000000012.xml")
# 2. Load and inspect the model
doc = libsbml.readSBMLFromFile("BIOMD0000000012.xml")
model = doc.getModel()
print(f"Model: {model.getId()} | Level {doc.getLevel()} Version {doc.getVersion()}")
print(f"Species: {model.getNumSpecies()}, Reactions: {model.getNumReactions()}")
# 3. Print all global parameters and their values
print("\nGlobal parameters:")
for i in range(model.getNumParameters()):
p = model.getParameter(i)
print(f" {p.getId():<20} = {p.getValue()}")
# 4. Modify a parameter (example: increase a rate constant by 2x)
target_param = model.getParameter("k3") # parameter name varies by model
if target_param:
old_val = target_param.getValue()
target_param.setValue(old_val * 2.0)
print(f"\nModified k3: {old_val} → {target_param.getValue()}")
# 5. Save modified model
writer = libsbml.SBMLWriter()
writer.writeSBMLToFile(doc, "BIOMD0000000012_modified.xml")
print("Saved modified model.")
# 6. Simulate with Tellurium (optional)
try:
import tellurium as te
r = te.loadSBMLModel(open("BIOMD0000000012_modified.xml").read())
result = r.simulate(0, 100, 500)
print(f"Simulation complete: {result.shape[0]} time points, {result.shape[1]-1} species")
r.plot(result, title="BIOMD0000000012 modified simulation")
except ImportError:
print("tellurium not installed — simulation step skipped")
Workflow 2: Build a Michaelis-Menten ODE Model from Scratch
Goal: Construct a full Michaelis-Menten enzyme kinetics SBML model and verify it passes validation.
import libsbml
def build_mm_model() -> libsbml.SBMLDocument:
"""Create a Michaelis-Menten enzyme kinetics SBML L3V2 model."""
doc = libsbml.SBMLDocument(3, 2)
model = doc.createModel()
model.setId("michaelis_menten")
model.setName("Michaelis-Menten Enzyme Kinetics")
model.setTimeUnits("second")
model.setSubstanceUnits("mole")
model.setVolumeUnits("litre")
model.setExtentUnits("mole")
# Compartment
c = model.createCompartment()
c.setId("cell"); c.setConstant(True); c.setSize(1e-15); c.setSpatialDimensions(3)
# Species: S (substrate), E (enzyme), ES (complex), P (product)
for sp_id, init_conc, boundary in [
("S", 1e-3, False), ("E", 1e-6, False),
("ES", 0.0, False), ("P", 0.0, False)
]:
sp = model.createSpecies()
sp.setId(sp_id); sp.setCompartment("cell")
sp.setInitialConcentration(init_conc)
sp.setBoundaryCondition(boundary); sp.setConstant(False)
sp.setHasOnlySubstanceUnits(False)
# Parameters
for p_id, p_val in [("kf", 1e6), ("kr", 1e-3), ("kcat", 0.1)]:
p = model.createParameter()
p.setId(p_id); p.setValue(p_val); p.setConstant(True)
# Reactions
def make_rxn(m, rxn_id, reacts, prods, formula):
rxn = m.createReaction(); rxn.setId(rxn_id); rxn.setReversible(False)
for sp, s in reacts:
sr = rxn.createReactant(); sr.setSpecies(sp); sr.setStoichiometry(s); sr.setConstant(True)
for sp, s in prods:
sr = rxn.createProduct(); sr.setSpecies(sp); sr.setStoichiometry(s); sr.setConstant(True)
kl = rxn.createKineticLaw()
ast = libsbml.parseL3Formula(formula)
if ast is None: raise ValueError(f"Bad formula: {formula}")
kl.setMath(ast)
make_rxn(model, "v_forward", [("S",1),("E",1)], [("ES",1)], "kf * S * E * cell")
make_rxn(model, "v_reverse", [("ES",1)], [("S",1),("E",1)], "kr * ES * cell")
make_rxn(model, "v_catalysis",[("ES",1)], [("P",1),("E",1)], "kcat * ES * cell")
return doc
doc = build_mm_model()
# Validate
doc.setConsistencyChecks(libsbml.LIBSBML_CAT_UNITS_CONSISTENCY, False) # skip units for brevity
n_errors = doc.checkConsistency()
print(f"Validation: {n_errors} issue(s)")
for i in range(n_errors):
e = doc.getError(i)
print(f" [{e.getSeverityAsString()}] {e.getMessage()}")
writer = libsbml.SBMLWriter()
writer.writeSBMLToFile(doc, "michaelis_menten.xml")
print("Saved michaelis_menten.xml")
print(f"Reactions: {doc.getModel().getNumReactions()}, Species: {doc.getModel().getNumSpecies()}")
Workflow 3: Extract Stoichiometry Matrix as NumPy Array
Goal: Parse a loaded SBML model and extract the stoichiometry matrix and reaction/species lists for custom linear algebra or FBA analysis.
import libsbml
import numpy as np
import pandas as pd
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
n_species = model.getNumSpecies()
n_reactions = model.getNumReactions()
species_ids = [model.getSpecies(i).getId() for i in range(n_species)]
rxn_ids = [model.getReaction(i).getId() for i in range(n_reactions)]
# Build stoichiometry matrix S: rows=species, cols=reactions
S = np.zeros((n_species, n_reactions), dtype=float)
sp_index = {sp_id: idx for idx, sp_id in enumerate(species_ids)}
for j, rxn_id in enumerate(rxn_ids):
rxn = model.getReaction(rxn_id)
# Reactants: negative stoichiometry
for k in range(rxn.getNumReactants()):
sr = rxn.getReactant(k)
sp_id = sr.getSpecies()
if sp_id in sp_index:
S[sp_index[sp_id], j] -= sr.getStoichiometry()
# Products: positive stoichiometry
for k in range(rxn.getNumProducts()):
sr = rxn.getProduct(k)
sp_id = sr.getSpecies()
if sp_id in sp_index:
S[sp_index[sp_id], j] += sr.getStoichiometry()
# Create a labeled DataFrame for inspection
S_df = pd.DataFrame(S, index=species_ids, columns=rxn_ids)
print("Stoichiometry matrix (S):")
print(S_df.to_string())
print(f"\nMatrix shape: {S.shape} (species × reactions)")
# Null-space rank as a basic model check
rank = np.linalg.matrix_rank(S)
print(f"Rank of S: {rank}")
print(f"Degrees of freedom (flux modes): {n_reactions - rank}")
Workflow 4: Load SBML FBA Model and Hand Off to COBRApy
Goal: Read a genome-scale metabolic model in SBML FBC format and load it into COBRApy for FBA analysis.
import libsbml
import cobra
import cobra.io
# Method A: use COBRApy's built-in SBML reader (wraps libSBML)
model_cobra = cobra.io.read_sbml_model("iJO1366.xml")
print(f"COBRApy model: {model_cobra.id}")
print(f" Reactions: {len(model_cobra.reactions)}")
print(f" Metabolites: {len(model_cobra.metabolites)}")
print(f" Genes: {len(model_cobra.genes)}")
# Run FBA
solution = model_cobra.optimize()
print(f"\nFBA objective value: {solution.objective_value:.4f}")
print(f"Status: {solution.status}")
# Method B: use libSBML to inspect FBC metadata before loading into COBRApy
doc = libsbml.readSBMLFromFile("iJO1366.xml")
model = doc.getModel()
fbc = model.getPlugin("fbc")
if fbc:
n_obj = fbc.getNumObjectives()
active_obj_id = fbc.getActiveObjectiveId()
print(f"\nlibSBML FBC: {n_obj} objective(s), active='{active_obj_id}'")
obj = fbc.getObjective(active_obj_id)
if obj:
print(f"Objective type: {obj.getType()}")
for i in range(obj.getNumFluxObjectives()):
fo = obj.getFluxObjective(i)
print(f" {fo.getReaction()} (coeff={fo.getCoefficient()})")
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
level |
SBMLDocument | 3 |
1, 2, 3 |
SBML Level; use L3 for all new models; L2 for legacy tool compatibility |
version |
SBMLDocument | 2 |
1–2 (L3); 1–4 (L2) |
SBML Version within the Level; L3V2 is the current standard |
initialConcentration |
Species | 0.0 |
any float | Starting molar concentration; mutually exclusive with initialAmount |
hasOnlySubstanceUnits |
Species | False |
True, False |
If True, kinetic laws reference amount (mol); if False, they reference concentration (M) |
boundaryCondition |
Species | False |
True, False |
If True, ODE solver does not change this species — use for external inputs |
constant |
Species/Parameter | True (Param) |
True, False |
False required for assignment rule targets and mutable parameters |
strict |
FBC plugin | True |
True, False |
FBC strict mode enforces that all flux bounds are defined as parameters |
targetLevel / targetVersion |
ConversionProperties | — | L/V integers | Target for doc.convert() level/version conversion |
LIBSBML_CAT_UNITS_CONSISTENCY |
checkConsistency | True |
True, False |
Enable/disable unit dimension checking during validation |
Best Practices
-
Always check
parseL3Formulareturn value: the function returnsNoneon malformed input without raising an exception. AssigningNonetokl.setMath()creates a model with a missing kinetic law that passes parsing but fails validation.ast = libsbml.parseL3Formula("Vmax * S / (Km + S)") if ast is None: raise ValueError("Formula parse failed") kl.setMath(ast) -
Set
constant=Falseon assignment rule targets: any parameter or species that is the target of anAssignmentRuleorRateRulemust haveconstantset toFalse. ATruevalue creates a constraint violation that fails consistency checking. -
Multiply reaction rate by compartment volume in kinetic laws: SBML extent units are moles (or molecules), so rates must have units of extent/time. For species measured in concentration, multiply by compartment size:
kf * S * E * compartment_volume. Omitting this factor is the most common kinetic law unit error. -
Enable only the packages you use: calling
doc.enablePackage()for unnecessary extensions (layout, groups) adds namespace declarations that confuse some downstream tools. Enable FBC only for FBA/FVA models; leave it off for pure ODE models. -
Use
readSBMLFromFile(top-level function) for quick loading: the convenience functionlibsbml.readSBMLFromFile(path)is equivalent to creating aSBMLReaderinstance and callingreadSBMLFromFileon it. Both return anSBMLDocument; choose whichever is less verbose. -
Validate before saving and after converting: run
doc.checkConsistency()immediately before anywriteSBMLToFilecall and again after any level/version conversion. Conversion can introduce new warnings, especially for units.
Common Recipes
Recipe: List All Reactions with Their Kinetic Formulas
When to use: audit an SBML model to document every reaction and its rate law before modifying parameters.
import libsbml
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
print(f"{'Reaction':<20} {'Reversible':<12} {'Kinetic Law'}")
print("-" * 80)
for i in range(model.getNumReactions()):
rxn = model.getReaction(i)
if rxn.isSetKineticLaw():
formula = libsbml.formulaToL3String(rxn.getKineticLaw().getMath())
else:
formula = "(no kinetic law)"
rev = "reversible" if rxn.getReversible() else "irreversible"
print(f"{rxn.getId():<20} {rev:<12} {formula}")
Recipe: Batch-Update Multiple Parameters
When to use: sensitivity analysis — sweep a set of kinetic constants over a range of values and re-save an SBML model for each.
import libsbml
import copy
doc = libsbml.readSBMLFromFile("model.xml")
writer = libsbml.SBMLWriter()
# Parameter sweep: vary kcat and Km
sweep = [
{"kcat": 0.05, "Km": 0.01},
{"kcat": 0.10, "Km": 0.01},
{"kcat": 0.20, "Km": 0.01},
{"kcat": 0.10, "Km": 0.05},
]
for idx, params in enumerate(sweep):
# Re-read fresh copy each iteration to avoid cumulative edits
doc_i = libsbml.readSBMLFromFile("model.xml")
model_i = doc_i.getModel()
for p_id, p_val in params.items():
p = model_i.getParameter(p_id)
if p:
p.setValue(p_val)
fname = f"model_sweep_{idx:03d}.xml"
writer.writeSBMLToFile(doc_i, fname)
print(f"Saved {fname}: {params}")
Recipe: Extract All Species Initial Conditions as a Dict
When to use: initializing a custom ODE solver (e.g., scipy.integrate.solve_ivp) using SBML-defined initial conditions.
import libsbml
doc = libsbml.readSBMLFromFile("model.xml")
model = doc.getModel()
initial_conditions = {}
for i in range(model.getNumSpecies()):
sp = model.getSpecies(i)
if sp.isSetInitialConcentration():
initial_conditions[sp.getId()] = sp.getInitialConcentration()
elif sp.isSetInitialAmount():
# Convert amount to concentration using compartment volume
comp = model.getCompartment(sp.getCompartment())
vol = comp.getSize() if comp and comp.isSetSize() else 1.0
initial_conditions[sp.getId()] = sp.getInitialAmount() / vol
else:
initial_conditions[sp.getId()] = 0.0
print("Initial conditions (concentration in model units):")
for sp_id, val in initial_conditions.items():
print(f" {sp_id}: {val:.6g}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: No module named 'libsbml' |
Package not installed | pip install python-libsbml; note the import name is libsbml, not python_libsbml |
parseL3Formula returns None |
Malformed formula string (wrong operator, undefined function) | Check formula syntax; use libsbml.formulaToL3String on a known-good AST to see expected format; * is multiplication, ^ or pow() for exponentiation |
| Consistency check reports unit errors | Kinetic law missing compartment volume factor | Multiply rate formula by compartment volume: kf * S * E * V; set substance_units = "mole" and volume_units = "litre" on the model |
getPlugin("fbc") returns None |
FBC package not enabled on the document | Call doc.enablePackage(libsbml.FbcExtension.getXmlnsL3V1V2(), "fbc", True) before reading or building the model |
| Level/version conversion returns non-zero code | Source model has features unsupported in target level | Check doc.getNumErrors() after conversion; SBML L1 has severe limitations (no compartments, no units); prefer L2V4 as minimum target |
| Assignment rule target raises "model is overdetermined" | Species or parameter is constant=True but targeted by a rule |
Set constant=False on the rule target; constant=True means the value is fixed and cannot be overridden by rules |
COBRApy read_sbml_model fails on custom-built SBML |
FBC strict=True but flux bounds not defined as parameters |
Ensure every reaction's FBC plugin has setLowerFluxBound and setUpperFluxBound pointing to existing parameter IDs |
| Large model read is slow (>10 seconds) | Very large SBML file (genome-scale model, 10k+ reactions) | Normal — libSBML XML parsing is single-threaded; use readSBMLFromFile (not string-based) and avoid re-reading in loops |
Related Skills
- cobrapy-metabolic-modeling — FBA, FVA, gene knockouts, and flux sampling on SBML/JSON metabolic models; use libSBML to build or edit the model, COBRApy to analyze it
- string-database-ppi — protein-protein interaction networks; export PPI data to SBML qual extension for logical network models
- reactome-database — pathway data source; Reactome provides SBML exports of human pathways that can be loaded and analyzed with libSBML
- brenda-database — kinetic parameter source (Km, Vmax, kcat) for populating libSBML kinetic laws with experimentally measured values
- networkx-graph-analysis — use after extracting the reaction network from SBML to analyze graph topology (shortest paths, connectivity, centrality)
- sympy-symbolic-math — symbolic manipulation of kinetic law expressions parsed from SBML; combine with
formulaToL3Stringfor analytical steady-state derivation
References
- libSBML Python documentation — complete Python API reference
- SBML specification portal — official SBML Level 3 core and package specifications
- libSBML GitHub repository — source code, issue tracker, and release notes
- BioModels Database — curated SBML model repository (1,000+ manually curated models)
- Hucka et al. (2003) Bioinformatics 19(4):524–531 — original SBML specification paper
- Keating et al. (2020) Molecular Systems Biology 16(8):e9110 — SBML Level 3 and the current extension ecosystem overview
skills/systems-biology-multiomics/mofaplus-multi-omics/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill mofaplus-multi-omics -g -y
SKILL.md
Frontmatter
{
"name": "mofaplus-multi-omics",
"license": "LGPL-3.0",
"description": "Multi-Omics Factor Analysis v2 (MOFA+) with mofapy2. Jointly decompose omics layers (scRNA, ATAC, proteomics, methylation) into latent factors capturing major variation. Multi-group designs. AnnData views → MOFA object → train → variance explained → correlate factors with metadata → visualize\/cluster → enrich top loadings."
}
MOFA+ Multi-Omics Factor Analysis
Overview
MOFA+ (Multi-Omics Factor Analysis v2) is an unsupervised statistical framework that jointly decomposes multiple omics datasets into a small set of latent factors. Each factor captures an independent source of variation (e.g., cell cycle, a disease phenotype, a technical batch) and is associated with feature weights (loadings) that reveal which genes, peaks, or proteins drive it. The Python package mofapy2 produces an HDF5 model file compatible with downstream analysis in both Python and R. MOFA+ extends the original MOFA to support multi-group settings where samples belong to distinct cohorts or conditions.
When to Use
- Integrating two or more omics layers from the same set of cells or samples (e.g., scRNA-seq + scATAC-seq, RNA + proteomics, methylation + RNA)
- Identifying shared and view-specific sources of variation across omics modalities without supervised labels
- Comparing how latent factors differ between patient groups, treatment conditions, or time points in a multi-group analysis
- Reducing multi-omics dimensionality before clustering, trajectory inference, or survival modeling
- Discovering which genomic features (genes, peaks, proteins) drive each factor via sparse loadings
- Annotating latent factors by correlating factor scores with sample metadata (age, stage, treatment response)
- Use scVI / MultiVI (scverse) instead when you need deep generative batch correction across modalities with explicit latent space inference and VAE architecture
- Use LIGER instead when your primary goal is integrating datasets across technologies (e.g., snRNA-seq + snATAC-seq) with shared and dataset-specific factors via iNMF
Prerequisites
- Python packages:
mofapy2>=0.7,anndata>=0.10,numpy>=1.24,pandas>=2.0,scipy>=1.11,matplotlib>=3.7,seaborn>=0.13,muon(optional, for MuData integration) - Data requirements: One AnnData object per omics view (cells/samples x features). All views must share the same obs (cell/sample) index. Missing samples per group are supported.
- Environment: Python 3.9+; trained model is saved as HDF5 and can be analyzed in R via the
MOFA2Bioconductor package
pip install mofapy2 anndata muon matplotlib seaborn
Quick Start
import numpy as np
import pandas as pd
import anndata as ad
from mofapy2.run.entry_point import entry_point
# Simulate two omics views, 200 cells, 500 RNA genes, 300 ATAC peaks
np.random.seed(42)
n_cells, n_rna, n_atac = 200, 500, 300
adata_rna = ad.AnnData(np.abs(np.random.randn(n_cells, n_rna)),
obs=pd.DataFrame(index=[f"cell_{i}" for i in range(n_cells)]))
adata_atac = ad.AnnData(np.abs(np.random.randn(n_cells, n_atac)),
obs=adata_rna.obs.copy())
ent = entry_point()
ent.set_data_options(scale_groups=False, scale_views=True)
ent.set_data_matrix([[adata_rna.X, adata_atac.X]],
likelihoods=["gaussian", "gaussian"],
views_names=["RNA", "ATAC"],
groups_names=["all_cells"],
samples_names=[list(adata_rna.obs_names)])
ent.set_model_options(factors=10)
ent.set_train_options(iter=500, convergence_mode="fast", seed=42)
ent.build()
ent.run()
ent.save("mofa_model.hdf5")
print("Model saved to mofa_model.hdf5")
Workflow
Step 1: Load and Prepare Multi-Omics Data
Each omics layer is represented as an AnnData object. Align cell indices across modalities, log-normalize RNA counts, and binarize or normalize ATAC/methylation data as appropriate.
import numpy as np
import pandas as pd
import anndata as ad
import scipy.sparse as sp
# --- RNA-seq: 200 cells x 2000 highly variable genes ---
np.random.seed(42)
n_cells = 200
cell_ids = [f"cell_{i:03d}" for i in range(n_cells)]
# Simulate log-normalized counts (in practice: load from h5ad after Scanpy preprocessing)
rna_counts = np.abs(np.random.randn(n_cells, 2000) * 2)
adata_rna = ad.AnnData(
X=rna_counts,
obs=pd.DataFrame(
{"condition": ["A"] * 100 + ["B"] * 100,
"patient": [f"P{i % 10}" for i in range(n_cells)]},
index=cell_ids
),
var=pd.DataFrame(index=[f"Gene_{i}" for i in range(2000)])
)
# --- ATAC-seq: same cells x 1000 peaks ---
atac_matrix = (np.random.rand(n_cells, 1000) > 0.8).astype(float)
adata_atac = ad.AnnData(
X=atac_matrix,
obs=adata_rna.obs.copy(),
var=pd.DataFrame(index=[f"Peak_{i}" for i in range(1000)])
)
# Confirm alignment
assert list(adata_rna.obs_names) == list(adata_atac.obs_names), "Cell indices must match"
print(f"RNA: {adata_rna.shape}, ATAC: {adata_atac.shape}")
print(f"Conditions: {adata_rna.obs['condition'].value_counts().to_dict()}")
Step 2: Create the MOFA+ Model Object
Instantiate the entry_point and register all data views. Views are provided as a list-of-lists: data[groups][views]. Assign meaningful view and group names for interpretability.
from mofapy2.run.entry_point import entry_point
ent = entry_point()
# Configure data options before setting data
ent.set_data_options(
scale_groups=False, # Do not rescale variance between groups
scale_views=True, # Rescale each view to unit variance (recommended when views differ in scale)
)
# Provide data as list-of-lists: [groups][views]
# Single group → wrap each view in a list
ent.set_data_matrix(
data=[[adata_rna.X, adata_atac.X]], # outer list = groups, inner = views
likelihoods=["gaussian", "bernoulli"], # gaussian for continuous, bernoulli for binary ATAC
views_names=["RNA", "ATAC"],
groups_names=["all_cells"],
samples_names=[list(adata_rna.obs_names)] # one list per group
)
print("Data registered. Views: RNA, ATAC | Groups: all_cells")
Step 3: Set Model and Training Options
Configure the number of factors and training hyperparameters. More factors capture finer variation but increase computation and risk overfitting; convergence_mode="medium" balances speed and accuracy.
# Model options
ent.set_model_options(
factors=15, # Number of latent factors (start with 15; prune inactive ones automatically)
spikeslab_weights=True, # Sparse weight prior (ARD+spike-slab); recommended for feature selection
ard_factors=True, # Automatic relevance determination per factor per view
ard_weights=True, # ARD per feature weight; enables pruning of irrelevant features
)
# Training options
ent.set_train_options(
iter=1000, # Maximum EM iterations
convergence_mode="medium", # "fast" (<1000 iter), "medium" (default), "slow" (>5000 iter)
startELBO=1, # Start computing ELBO from iteration 1
freqELBO=5, # Compute ELBO every 5 iterations
dropR2=0.01, # Drop factors explaining < 1% variance (set to None to disable)
seed=42,
verbose=False
)
print("Model and training options set")
Step 4: Build and Train the Model
Build the internal data structures, then run variational inference. Training produces a fitted model where each factor's weights and scores are optimized to maximize the evidence lower bound (ELBO).
# Build internal model structure
ent.build()
# Run training (EM algorithm with variational Bayes updates)
ent.run()
# Save trained model to HDF5 — required for downstream analysis
output_path = "mofa_model.hdf5"
ent.save(output_path, overwrite=True)
print(f"Model trained and saved to {output_path}")
Step 5: Load Trained Model and Inspect Variance Explained
Load the HDF5 model and inspect how much variance each factor explains per view. Factors explaining less than ~1-2% total variance are typically noise.
import h5py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
def load_mofa_r2(model_path):
"""Extract variance explained (R2) per factor per view from MOFA+ HDF5."""
with h5py.File(model_path, "r") as f:
r2 = f["variance_explained"]["r2_per_factor"]
views = [v.decode() for v in f["views"]["views"][:]]
groups = [g.decode() for g in f["groups"]["groups"][:]]
# r2_per_factor: shape (n_groups, n_views, n_factors)
r2_array = np.stack([r2[g][:] for g in groups], axis=0) # (groups, views, factors)
# Average across groups; shape → (n_views, n_factors)
r2_mean = r2_array.mean(axis=0)
n_factors = r2_mean.shape[1]
df = pd.DataFrame(r2_mean * 100,
index=views,
columns=[f"Factor{i+1}" for i in range(n_factors)])
return df
r2_df = load_mofa_r2("mofa_model.hdf5")
print("Variance explained (%) per factor per view:")
print(r2_df.round(2))
# Heatmap of variance explained
fig, ax = plt.subplots(figsize=(max(8, r2_df.shape[1] * 0.6), 3))
sns.heatmap(r2_df, annot=True, fmt=".1f", cmap="YlOrRd",
linewidths=0.5, ax=ax, vmin=0)
ax.set_title("MOFA+ Variance Explained (%) per Factor per View")
ax.set_xlabel("Factor")
ax.set_ylabel("Omics View")
plt.tight_layout()
plt.savefig("mofa_variance_explained.png", dpi=200)
print("Saved mofa_variance_explained.png")
Step 6: Extract Factor Scores and Correlate with Metadata
Factor scores (Z matrix) are the per-sample coordinates in factor space. Correlate scores with continuous or categorical metadata to biologically annotate each factor.
import scipy.stats as stats
def load_mofa_factors(model_path):
"""Load factor scores (Z) from MOFA+ HDF5. Returns DataFrame (samples x factors)."""
with h5py.File(model_path, "r") as f:
groups = [g.decode() for g in f["groups"]["groups"][:]]
factors_list = []
for g in groups:
z = f["expectations"]["Z"][g][:] # shape: (n_factors, n_samples)
samples = [s.decode() for s in f["samples"][g][:]]
n_factors = z.shape[0]
df = pd.DataFrame(z.T, index=samples,
columns=[f"Factor{i+1}" for i in range(n_factors)])
factors_list.append(df)
return pd.concat(factors_list, axis=0)
factors_df = load_mofa_factors("mofa_model.hdf5")
print(f"Factor scores: {factors_df.shape} (cells x factors)")
# Merge with metadata
meta = adata_rna.obs[["condition", "patient"]].copy()
factors_meta = factors_df.join(meta)
# Point-biserial correlation: factor score vs binary metadata
factor_cols = [c for c in factors_meta.columns if c.startswith("Factor")]
print("\nFactor–Condition correlation (eta-squared approximation):")
for fc in factor_cols[:5]:
groups_vals = [factors_meta.loc[factors_meta["condition"] == g, fc].values
for g in factors_meta["condition"].unique()]
stat, pval = stats.f_oneway(*groups_vals)
print(f" {fc}: F={stat:.2f}, p={pval:.3f}")
Step 7: Visualize Factors — Scatter Plots and Feature Heatmaps
Plot factor score scatter plots colored by metadata, and heatmaps of the top-weighted features (loadings) per factor to understand what each factor captures.
def load_mofa_weights(model_path, view_name):
"""Load feature weights (W) for a specific view. Returns DataFrame (features x factors)."""
with h5py.File(model_path, "r") as f:
views = [v.decode() for v in f["views"]["views"][:]]
view_idx = views.index(view_name)
# Weights stored per view as (n_factors, n_features)
w = f["expectations"]["W"][view_name][:]
features = [ft.decode() for ft in f["features"][view_name][:]]
n_factors = w.shape[0]
df = pd.DataFrame(w.T, index=features,
columns=[f"Factor{i+1}" for i in range(n_factors)])
return df
# --- Factor scatter plot ---
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for ax, (fx, fy) in zip(axes, [("Factor1", "Factor2"), ("Factor1", "Factor3")]):
for cond, grp in factors_meta.groupby("condition"):
ax.scatter(grp[fx], grp[fy], label=cond, alpha=0.6, s=20)
ax.set_xlabel(fx)
ax.set_ylabel(fy)
ax.legend(title="Condition")
ax.set_title(f"{fx} vs {fy}")
plt.tight_layout()
plt.savefig("mofa_factor_scatter.png", dpi=200)
print("Saved mofa_factor_scatter.png")
# --- Top-loading heatmap for RNA view ---
weights_rna = load_mofa_weights("mofa_model.hdf5", "RNA")
top_n = 20
top_features = []
for fc in [f"Factor{i+1}" for i in range(min(5, weights_rna.shape[1]))]:
top_pos = weights_rna[fc].nlargest(top_n // 2).index.tolist()
top_neg = weights_rna[fc].nsmallest(top_n // 2).index.tolist()
top_features.extend(top_pos + top_neg)
top_features = list(dict.fromkeys(top_features)) # unique, preserve order
fig, ax = plt.subplots(figsize=(8, max(6, len(top_features) * 0.3)))
sns.heatmap(weights_rna.loc[top_features, [f"Factor{i+1}" for i in range(min(5, weights_rna.shape[1]))]],
cmap="RdBu_r", center=0, ax=ax, yticklabels=True)
ax.set_title("Top RNA Feature Weights per Factor")
plt.tight_layout()
plt.savefig("mofa_rna_weights_heatmap.png", dpi=200)
print("Saved mofa_rna_weights_heatmap.png")
Step 8: Downstream — Cluster Cells by Factor Scores and Enrichment
Use factor scores as a low-dimensional embedding for clustering, and extract top-weighted genes per factor for pathway enrichment.
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
import warnings
# --- Cluster cells using factor scores ---
factor_cols = [c for c in factors_df.columns if c.startswith("Factor")]
X_factors = StandardScaler().fit_transform(factors_df[factor_cols].values)
kmeans = KMeans(n_clusters=4, random_state=42, n_init=10)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
cluster_labels = kmeans.fit_predict(X_factors)
factors_df["mofa_cluster"] = cluster_labels.astype(str)
print(f"K-means clusters (k=4):\n{pd.Series(cluster_labels).value_counts().sort_index()}")
# --- Extract top-weighted genes per factor for enrichment input ---
weights_rna = load_mofa_weights("mofa_model.hdf5", "RNA")
enrichment_inputs = {}
for fc in [f"Factor{i+1}" for i in range(min(5, weights_rna.shape[1]))]:
# Positive weights: top activating genes; negative: top repressing genes
top_pos = weights_rna[fc].nlargest(100).index.tolist()
top_neg = weights_rna[fc].nsmallest(100).index.tolist()
enrichment_inputs[f"{fc}_positive"] = top_pos
enrichment_inputs[f"{fc}_negative"] = top_neg
# Save gene lists for external enrichment (e.g., gseapy Enrichr)
for name, genes in list(enrichment_inputs.items())[:2]:
print(f"\n{name} — top 10: {genes[:10]}")
# Example: run ORA with gseapy (install separately)
# import gseapy
# enr = gseapy.enrichr(gene_list=enrichment_inputs["Factor1_positive"],
# gene_sets="GO_Biological_Process_2023",
# organism="human", outdir=None)
# print(enr.results.head(5)[["Term", "Adjusted P-value", "Genes"]])
factors_df.to_csv("mofa_factor_scores.csv")
print("\nFactor scores with cluster labels saved to mofa_factor_scores.csv")
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
factors |
15 |
5–50 |
Number of latent factors to infer; inactive ones pruned by ARD |
likelihoods |
(required) | "gaussian", "bernoulli", "poisson" |
Per-view likelihood; gaussian for normalized continuous, bernoulli for binary ATAC, poisson for raw counts |
scale_views |
True |
True/False |
Rescale each view to unit variance; recommended when views differ in scale or unit |
scale_groups |
False |
True/False |
Rescale variance across groups; set True if groups have very different total variances |
spikeslab_weights |
True |
True/False |
Spike-and-slab sparsity prior on weights; enables feature selection via near-zero weights |
ard_factors |
True |
True/False |
Automatic relevance determination per factor per view; prunes factors not used in a view |
iter |
1000 |
200–5000 |
Maximum EM iterations; convergence usually reached in 200–800 |
convergence_mode |
"medium" |
"fast", "medium", "slow" |
ELBO convergence tolerance: fast = 1e-4, medium = 1e-6, slow = 1e-8 |
dropR2 |
0.01 |
None, 0.001–0.05 |
Drop factors explaining less than this fraction of variance; None keeps all |
startELBO |
1 |
1–100 |
Iteration to start ELBO monitoring; set higher to skip initial instability |
Key Concepts
Latent Factors and Loadings
Each latent factor Z_k (a vector of length n_samples) represents a source of variation. The corresponding weight matrix W_k (a vector of length n_features per view) contains the loading of each feature on that factor. Factors with spike-and-slab priors produce sparse loadings: most weights are shrunk to near zero, leaving a small set of features that meaningfully drive the factor. A positive weight means higher factor score correlates with higher feature expression.
Multi-Group vs Single-Group
A "group" in MOFA+ is a set of samples that share the same factor weight matrices W but have independent factor score distributions Z. Use multi-group analysis when:
- Samples come from distinct cohorts with batch-level differences (patients, datasets)
- You want to compare how much each factor explains within vs between groups
- You expect the same biological programs to operate differently across conditions
Single-group analysis (all samples in one group) is appropriate when samples are from a single experiment with no major batch structure.
Variance Explained Heatmap Interpretation
The R2 heatmap (views x factors) is the primary diagnostic output. Factors should show:
- View-specific R2: a factor driving only RNA captures transcriptional programs; one driving both RNA and ATAC captures chromatin-accessibility-coupled gene expression
- Declining R2: Factor 1 explains the most variance; factors should be inspected in order
- Factors with <1% R2 in all views can generally be ignored
Common Recipes
Recipe: Multi-Group Analysis Across Conditions
Use when comparing two or more patient groups or experimental conditions, where you want to identify condition-specific vs shared factors.
from mofapy2.run.entry_point import entry_point
import numpy as np
import pandas as pd
import anndata as ad
# Simulate two groups: condition A (100 cells) and condition B (100 cells)
np.random.seed(0)
n_per_group, n_genes, n_peaks = 100, 1000, 500
groups = {"condA": {}, "condB": {}}
for g in groups:
groups[g]["rna"] = np.abs(np.random.randn(n_per_group, n_genes))
groups[g]["atac"] = (np.random.rand(n_per_group, n_peaks) > 0.75).astype(float)
sample_ids_A = [f"A_cell_{i}" for i in range(n_per_group)]
sample_ids_B = [f"B_cell_{i}" for i in range(n_per_group)]
ent = entry_point()
ent.set_data_options(scale_groups=False, scale_views=True)
# Multi-group: data[groups][views] — two groups, each with RNA and ATAC
ent.set_data_matrix(
data=[[groups["condA"]["rna"], groups["condA"]["atac"]],
[groups["condB"]["rna"], groups["condB"]["atac"]]],
likelihoods=["gaussian", "bernoulli"],
views_names=["RNA", "ATAC"],
groups_names=["condA", "condB"],
samples_names=[sample_ids_A, sample_ids_B]
)
ent.set_model_options(factors=10, spikeslab_weights=True, ard_factors=True)
ent.set_train_options(iter=500, convergence_mode="fast", seed=0, verbose=False)
ent.build()
ent.run()
ent.save("mofa_multigroup.hdf5", overwrite=True)
# Compare factor scores between groups
factors_all = load_mofa_factors("mofa_multigroup.hdf5")
factors_all["group"] = ["condA"] * n_per_group + ["condB"] * n_per_group
print(f"Multi-group model trained. Factor scores: {factors_all.shape}")
print(factors_all.groupby("group")[["Factor1", "Factor2"]].mean().round(3))
Recipe: Identify and Annotate Factors by Top-Weighted Genes
Retrieve the top positive and negative loading genes per factor and print a summary table for biological annotation.
import pandas as pd
def annotate_factors(model_path, view_name="RNA", top_n=20, n_factors=5):
"""
Summarize top-loading features per factor to assist biological annotation.
Returns a DataFrame with factor names, top positive genes, top negative genes,
and the absolute weight range (an activity proxy).
"""
weights = load_mofa_weights(model_path, view_name)
factor_cols = [f"Factor{i+1}" for i in range(min(n_factors, weights.shape[1]))]
rows = []
for fc in factor_cols:
w = weights[fc]
top_pos = w.nlargest(top_n).index.tolist()
top_neg = w.nsmallest(top_n).index.tolist()
rows.append({
"Factor": fc,
"Max_weight": round(w.max(), 4),
"Min_weight": round(w.min(), 4),
"Top_positive": ", ".join(top_pos[:5]),
"Top_negative": ", ".join(top_neg[:5]),
})
summary = pd.DataFrame(rows)
return summary
summary_df = annotate_factors("mofa_model.hdf5", view_name="RNA", top_n=20, n_factors=5)
print("\nFactor annotation summary (RNA view):")
print(summary_df.to_string(index=False))
summary_df.to_csv("mofa_factor_annotation.csv", index=False)
print("\nSaved mofa_factor_annotation.csv")
Expected Outputs
| Output | Description |
|---|---|
mofa_model.hdf5 |
Trained MOFA+ model — factor scores, weights, ELBO trace, variance explained |
mofa_variance_explained.png |
Heatmap of R2 (%) per factor per view; primary diagnostic for factor selection |
mofa_factor_scatter.png |
Scatter plots of Factor1 vs Factor2/3 colored by metadata (condition, patient) |
mofa_rna_weights_heatmap.png |
Heatmap of top RNA feature weights across the first 5 factors |
mofa_factor_scores.csv |
Table of per-cell factor scores (cells x factors) with cluster labels |
mofa_factor_annotation.csv |
Factor annotation table: top positive/negative genes per factor |
| Per-factor gene lists | Input for gseapy Enrichr or GSEA to identify enriched pathways per factor |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
KeyError in set_data_matrix |
Mismatched number of groups, views, or sample list dimensions | Ensure data, likelihoods, views_names, groups_names, samples_names all have consistent lengths |
| All factors show near-zero variance explained | Data not preprocessed or scale mismatch across views | Normalize each view before input; set scale_views=True; verify non-zero variance in input matrices |
| Model trains but factor scores are NaN | Convergence failure due to extreme values or near-singular data | Check for Inf/NaN in input matrices; reduce iter; try convergence_mode="fast" first |
| Too many factors pruned (only 1-2 remain) | dropR2 threshold too aggressive or insufficient variation in data |
Set dropR2=0.001 or dropR2=None; increase data diversity or reduce noise |
HDF5 file cannot be read |
File truncated due to crash during training | Re-run training; check disk space; use overwrite=True in ent.save() |
| Factor scores identical across all samples | Single-sample group or zero-variance input view | Confirm at least 2 distinct samples per group; check input matrix is not all zeros |
| Very slow training (>1 hr) | Large feature space (>10k features per view) or many factors | Pre-filter to top HVGs (2000-5000) per view; reduce factors to 10-15; enable verbose=False |
| ELBO not converging (oscillates) | Learning rate instability or poorly scaled data | Increase startELBO; standardize each view independently; use convergence_mode="slow" |
| Weights all near zero for one view | Bernoulli likelihood on continuous data or vice versa | Verify likelihoods list matches view data types; use "gaussian" for normalized RNA |
ModuleNotFoundError: mofapy2 |
Package not installed | pip install mofapy2 |
References
- MOFA2 GitHub repository — Source code for mofapy2 (Python) and MOFA2 R package
- MOFA2 documentation and tutorials — Official documentation with vignettes for Python and R
- Argelaguet et al. (2020) — "MOFA+: a statistical framework for comprehensive integration of multi-modal single-cell data", Genome Biology
- Argelaguet et al. (2018) — Original MOFA paper, Molecular Systems Biology
- mofapy2 PyPI package — Installation and version history
skills/systems-biology-multiomics/muon-multiomics-singlecell/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill muon-multiomics-singlecell -g -y
SKILL.md
Frontmatter
{
"name": "muon-multiomics-singlecell",
"license": "BSD-3-Clause",
"description": "Multi-modal single-cell analysis with muon\/MuData. Joint RNA+ATAC (10x Multiome), CITE-seq (RNA+protein), other multi-omics. MuData holds per-modality AnnData with shared obs. WNN joint embedding, per-modality preprocessing, MOFA factor analysis. Use scanpy-scrna-seq for single-modality RNA; use muon when combining 2+ omics from the same cells."
}
muon — Multi-Modal Single-Cell Analysis
Overview
muon is a Python framework for multi-modal single-cell data analysis that extends the AnnData ecosystem. Its core data structure, MuData, holds multiple AnnData objects (one per modality: RNA, ATAC, protein, etc.) with shared observation and variable axes, enabling coordinated operations across all modalities. muon provides modality-specific preprocessing routines (TF-IDF and LSI for ATAC, CLR normalization for surface proteins), Weighted Nearest Neighbor (WNN) graph construction for joint dimensionality reduction, and cross-modal analysis tools. It integrates directly with scanpy, scvi-tools, and MOFA+ for a complete multi-omics single-cell workflow.
When to Use
- Analyzing 10x Genomics Multiome data (simultaneous RNA + ATAC from the same nuclei)
- Processing CITE-seq experiments (RNA + surface protein from the same cells)
- Building joint UMAP embeddings that integrate signals from two or more modalities via WNN
- Preprocessing ATAC-seq modalities (TF-IDF normalization, LSI dimensionality reduction)
- Normalizing surface protein data with centered log-ratio (CLR) normalization
- Performing cross-modal feature linkage (associating ATAC peaks with nearby gene expression)
- Applying MOFA+ factor analysis across multiple omics layers within a unified container
- Use scanpy-scrna-seq instead when analyzing a single RNA-seq modality without any co-measured omics
- Use scvi-tools (MultiVI / totalVI) when you need probabilistic deep generative batch correction across modalities
Prerequisites
- Python packages:
muon>=0.1.6,scanpy>=1.10,anndata>=0.10,numpy,scipy,pandas,matplotlib,leidenalg - Data requirements: 10x Multiome h5 or h5mu files, or per-modality AnnData objects (cells x features) sharing the same
obs_names - Environment: Python 3.9+; 16 GB+ RAM for datasets >50k cells; optional
mofapy2for MOFA factor analysis
pip install "muon[all]" "scanpy[leiden]" anndata
# Optional: for MOFA+ integration
pip install mofapy2
Quick Start
import muon as mu
import scanpy as sc
import numpy as np
import anndata as ad
import pandas as pd
# Simulate a small RNA + ATAC MuData object (200 cells)
np.random.seed(42)
n_cells = 200
rna = ad.AnnData(np.abs(np.random.negative_binomial(5, 0.3, (n_cells, 2000))).astype(float),
obs=pd.DataFrame(index=[f"cell_{i}" for i in range(n_cells)]),
var=pd.DataFrame(index=[f"gene_{j}" for j in range(2000)]))
atac = ad.AnnData(np.abs(np.random.negative_binomial(2, 0.5, (n_cells, 5000))).astype(float),
obs=rna.obs.copy(),
var=pd.DataFrame(index=[f"peak_{j}" for j in range(5000)]))
mdata = mu.MuData({"rna": rna, "atac": atac})
print(mdata)
# MuData object with n_obs × n_vars = 200 × 7000
# 2 modalities
# rna: 200 × 2000
# atac: 200 × 5000
# Minimal preprocessing
sc.pp.normalize_total(mdata["rna"], target_sum=1e4)
sc.pp.log1p(mdata["rna"])
sc.pp.highly_variable_genes(mdata["rna"], n_top_genes=500)
sc.pp.pca(mdata["rna"])
mu.atac.pp.tfidf(mdata["atac"])
mu.atac.tl.lsi(mdata["atac"])
# WNN joint embedding
mu.pp.neighbors(mdata, key_added="wnn",
use_rep={"rna": "X_pca", "atac": "X_lsi"})
sc.tl.umap(mdata, neighbors_key="wnn")
sc.tl.leiden(mdata, neighbors_key="wnn", key_added="leiden_wnn")
print(f"Clusters: {mdata.obs['leiden_wnn'].nunique()}")
Core API
Module 1: MuData Creation and I/O
MuData is the central container: a dictionary of AnnData modalities plus shared obs and var slots. The mdata.obs DataFrame merges per-modality observation metadata with a modality prefix for ambiguous columns.
import muon as mu
import anndata as ad
import numpy as np
import pandas as pd
# --- Build from per-modality AnnData objects ---
n_cells = 300
rna = ad.AnnData(np.abs(np.random.negative_binomial(5, 0.3, (n_cells, 3000))).astype(float),
obs=pd.DataFrame(index=[f"cell_{i}" for i in range(n_cells)]),
var=pd.DataFrame(index=[f"gene_{j}" for j in range(3000)]))
prot = ad.AnnData(np.abs(np.random.randn(n_cells, 30)) + 3,
obs=rna.obs.copy(),
var=pd.DataFrame(index=[f"protein_{j}" for j in range(30)]))
mdata = mu.MuData({"rna": rna, "protein": prot})
print(mdata)
# Access individual modalities
print(mdata.mod["rna"]) # AnnData: 300 × 3000
print(mdata["rna"]) # same shorthand
print(mdata.obs.head()) # shared observation metadata
print(mdata.obs_names[:5]) # cell barcodes
# Save and load
mdata.write("multiome_data.h5mu")
mdata2 = mu.read("multiome_data.h5mu")
print(f"Loaded: {mdata2.n_obs} cells, {mdata2.n_mod} modalities")
# --- Load from 10x Multiome h5 file ---
# mdata = mu.read_10x_h5("filtered_feature_bc_matrix.h5")
# Produces MuData with mdata["rna"] and mdata["atac"] modalities
# --- Subsetting by cells or features ---
# Select high-quality cells (e.g., after QC)
mask = np.ones(mdata.n_obs, dtype=bool) # replace with actual QC mask
mdata_filtered = mdata[mask].copy()
print(f"After filtering: {mdata_filtered.n_obs} cells")
# Propagate obs mask to each modality
mu.pp.intersect_obs(mdata) # ensures obs consistency across modalities
Module 2: RNA Modality Preprocessing
Standard scRNA-seq preprocessing applied to mdata["rna"] using scanpy functions. The RNA modality is preprocessed identically to a standalone scanpy workflow but operates on the slice of the MuData container.
import scanpy as sc
import numpy as np
# Assume mdata["rna"] has raw integer counts
rna = mdata["rna"]
# QC metrics
sc.pp.calculate_qc_metrics(rna, percent_top=None, log1p=False, inplace=True)
rna.obs["pct_counts_mt"] = rna[:, rna.var_names.str.startswith("MT-")].X.sum(axis=1).A1 / rna.obs["total_counts"] * 100
# Filter cells and genes
min_genes, max_genes, max_mt = 200, 5000, 20
sc.pp.filter_cells(rna, min_genes=min_genes)
sc.pp.filter_genes(rna, min_cells=5)
rna = rna[(rna.obs["n_genes_by_counts"] < max_genes) &
(rna.obs["pct_counts_mt"] < max_mt)].copy()
print(f"RNA after QC: {rna.n_obs} cells × {rna.n_vars} genes")
# Normalize and log-transform
sc.pp.normalize_total(rna, target_sum=1e4)
sc.pp.log1p(rna)
# Highly variable genes
sc.pp.highly_variable_genes(rna, n_top_genes=3000, flavor="seurat_v3",
batch_key=None)
print(f"HVGs: {rna.var['highly_variable'].sum()}")
# PCA on HVGs
sc.pp.pca(rna, n_comps=50, use_highly_variable=True)
print(f"PCA embedding shape: {rna.obsm['X_pca'].shape}")
# Update slice in MuData
mdata.mod["rna"] = rna
Module 3: ATAC Modality Preprocessing
ATAC-seq modalities require a different normalization strategy. TF-IDF (term frequency–inverse document frequency) normalizes peak accessibility across cells and peaks; LSI (latent semantic indexing, equivalent to truncated SVD after TF-IDF) produces a low-dimensional embedding. The first LSI component typically captures sequencing depth rather than biology and is excluded.
# Assume mdata["atac"] contains raw binary or integer peak accessibility counts
atac = mdata["atac"]
# Basic QC: filter low-coverage cells and low-frequency peaks
sc.pp.calculate_qc_metrics(atac, percent_top=None, log1p=False, inplace=True)
sc.pp.filter_cells(atac, min_genes=200) # min peaks detected
sc.pp.filter_genes(atac, min_cells=10) # min cells a peak appears in
print(f"ATAC after QC: {atac.n_obs} cells × {atac.n_vars} peaks")
# TF-IDF normalization (log(TF) * log(IDF) scaling)
mu.atac.pp.tfidf(atac, scale_factor=1e4)
print("TF-IDF normalization complete")
print(f"ATAC data range: [{atac.X.min():.2f}, {atac.X.max():.2f}]")
# LSI dimensionality reduction (truncated SVD on TF-IDF matrix)
mu.atac.tl.lsi(atac, n_comps=50, use_highly_variable=False)
# LSI component 1 correlates with sequencing depth — exclude it
# mu.atac.tl.lsi sets X_lsi starting from component 2 by default
print(f"LSI embedding shape: {atac.obsm['X_lsi'].shape}")
# Update modality in MuData
mdata.mod["atac"] = atac
Module 4: WNN Graph and Joint Embedding
Weighted Nearest Neighbor (WNN) integrates multiple modality embeddings by learning per-cell, per-modality weights. Cells with high-quality RNA signal receive higher RNA weight; cells with cleaner ATAC signal receive higher ATAC weight. The resulting WNN graph is used for UMAP layout and Leiden clustering.
# Compute per-modality neighbor graphs first (optional but enables modality-specific UMAPs)
mu.pp.neighbors(mdata["rna"], use_rep="X_pca", n_neighbors=30, key_added="neighbors")
mu.pp.neighbors(mdata["atac"], use_rep="X_lsi", n_neighbors=30, key_added="neighbors")
# WNN joint neighbor graph across modalities
mu.pp.neighbors(
mdata,
key_added="wnn",
use_rep={"rna": "X_pca", "atac": "X_lsi"},
n_neighbors=30,
random_state=42,
)
print("WNN graph built. Keys:", list(mdata.obsp.keys()))
# Expected: ['wnn_connectivities', 'wnn_distances']
# UMAP from WNN graph
sc.tl.umap(mdata, neighbors_key="wnn", random_state=42)
print(f"UMAP embedding shape: {mdata.obsm['X_umap'].shape}")
# Leiden clustering from WNN graph
sc.tl.leiden(mdata, neighbors_key="wnn", resolution=0.5, key_added="leiden_wnn")
n_clusters = mdata.obs["leiden_wnn"].nunique()
print(f"Leiden WNN clustering: {n_clusters} clusters at resolution 0.5")
Module 5: Visualization
muon extends scanpy's plotting interface with modality-aware functions. mu.pl.embedding() colors joint UMAP embeddings by features from any modality; sc.pl.umap() with color pointing to modality-prefixed feature names (e.g., "rna:CD3E") is also supported.
import matplotlib.pyplot as plt
# Joint UMAP colored by cluster assignment
sc.pl.umap(mdata, color="leiden_wnn", title="WNN Leiden clusters",
legend_loc="on data", show=False)
plt.savefig("wnn_umap_clusters.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved wnn_umap_clusters.png")
# Color by RNA gene expression on joint UMAP
sc.pl.umap(mdata, color=["rna:CD3E", "rna:CD19", "rna:CD14"],
use_raw=False, vmax="p99", show=False, ncols=3)
plt.savefig("wnn_umap_markers.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved wnn_umap_markers.png")
# Per-modality scatter / embedding plots
mu.pl.embedding(mdata, basis="X_umap", color="leiden_wnn",
show=False)
plt.savefig("embedding_clusters.png", dpi=150, bbox_inches="tight")
plt.close()
# Violin plot: RNA QC metrics per cluster
sc.pl.violin(mdata["rna"], keys=["n_genes_by_counts", "total_counts"],
groupby=mdata.obs.loc[mdata["rna"].obs_names, "leiden_wnn"],
rotation=90, show=False)
plt.savefig("rna_qc_by_cluster.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved rna_qc_by_cluster.png")
Module 6: Cross-Modal Analysis
Cross-modal analysis links features across modalities — for example, associating ATAC peak accessibility near a gene's promoter with its RNA expression, or applying MOFA+ to find shared latent factors.
# --- Peak-to-gene distance annotation ---
# Annotate ATAC peaks with nearest gene (requires genomic coordinates in var)
# mdata["atac"].var should contain columns: chrom, chromStart, chromEnd
# mu.atac.tl.rank_peaks_groups(mdata, groupby="leiden_wnn") # differential peaks
# --- Differentially accessible peaks per cluster ---
atac = mdata["atac"]
# Transfer cluster labels from MuData obs to ATAC obs
atac.obs["cluster"] = mdata.obs.loc[atac.obs_names, "leiden_wnn"].values
sc.tl.rank_genes_groups(atac, groupby="cluster", method="wilcoxon",
use_raw=False)
top_peaks = sc.get.rank_genes_groups_df(atac, group="0").head(5)
print("Top differential peaks in cluster 0:")
print(top_peaks[["names", "logfoldchanges", "pvals_adj"]])
# --- MOFA+ factor analysis on MuData ---
# Requires: pip install mofapy2
try:
mu.tl.mofa(mdata, n_factors=10, seed=42,
use_obs="all", outfile="mofa_model.hdf5")
# Factor scores stored in mdata.obsm["X_mofa"]
print(f"MOFA factors: {mdata.obsm['X_mofa'].shape}")
# Variance explained per factor per modality
# Inspect with mdata.uns["mofa"]
except ImportError:
print("Install mofapy2 to enable MOFA factor analysis")
Common Workflows
Workflow 1: Full 10x Multiome (RNA + ATAC) Joint WNN Clustering
Goal: Process paired RNA and ATAC data from 10x Genomics Multiome, build a WNN joint embedding, cluster cells, and identify RNA marker genes per cluster.
import muon as mu
import scanpy as sc
import numpy as np
import anndata as ad
import pandas as pd
import matplotlib.pyplot as plt
# --- 1. Simulate 10x Multiome-like data (replace with mu.read_10x_h5()) ---
np.random.seed(0)
n_cells = 400
cell_ids = [f"AAACGAATC{i:05d}-1" for i in range(n_cells)]
# Simulate two cell types with distinct expression/accessibility
labels = np.array(["typeA"] * 200 + ["typeB"] * 200)
rna_counts = np.abs(np.random.negative_binomial(6, 0.35, (n_cells, 2000))).astype(float)
rna_counts[:200, :100] += 10 # typeA marker genes
rna_counts[200:, 100:200] += 10 # typeB marker genes
atac_counts = np.abs(np.random.binomial(1, 0.05, (n_cells, 50000))).astype(float)
atac_counts[:200, :5000] += 1 # typeA accessible peaks
atac_counts[200:, 5000:10000] += 1 # typeB accessible peaks
rna = ad.AnnData(rna_counts,
obs=pd.DataFrame({"cell_type": labels}, index=cell_ids),
var=pd.DataFrame(index=[f"gene_{j}" for j in range(2000)]))
atac = ad.AnnData(atac_counts,
obs=pd.DataFrame({"cell_type": labels}, index=cell_ids),
var=pd.DataFrame(index=[f"peak_{j}" for j in range(50000)]))
mdata = mu.MuData({"rna": rna, "atac": atac})
print(f"Loaded: {mdata}")
# --- 2. RNA preprocessing ---
sc.pp.filter_cells(mdata["rna"], min_genes=50)
sc.pp.filter_genes(mdata["rna"], min_cells=5)
sc.pp.normalize_total(mdata["rna"], target_sum=1e4)
sc.pp.log1p(mdata["rna"])
sc.pp.highly_variable_genes(mdata["rna"], n_top_genes=500)
sc.pp.pca(mdata["rna"], n_comps=30, use_highly_variable=True)
print(f"RNA PCA: {mdata['rna'].obsm['X_pca'].shape}")
# --- 3. ATAC preprocessing: TF-IDF + LSI ---
sc.pp.filter_cells(mdata["atac"], min_genes=100)
sc.pp.filter_genes(mdata["atac"], min_cells=5)
mu.atac.pp.tfidf(mdata["atac"])
mu.atac.tl.lsi(mdata["atac"], n_comps=30)
print(f"ATAC LSI: {mdata['atac'].obsm['X_lsi'].shape}")
# --- 4. Per-modality neighbors (optional for modality-specific plots) ---
mu.pp.neighbors(mdata["rna"], use_rep="X_pca", n_neighbors=15, key_added="neighbors")
mu.pp.neighbors(mdata["atac"], use_rep="X_lsi", n_neighbors=15, key_added="neighbors")
# --- 5. WNN joint neighbor graph ---
mu.pp.intersect_obs(mdata) # align obs after per-modality filtering
mu.pp.neighbors(mdata, key_added="wnn",
use_rep={"rna": "X_pca", "atac": "X_lsi"},
n_neighbors=15, random_state=42)
# --- 6. UMAP + Leiden clustering ---
sc.tl.umap(mdata, neighbors_key="wnn", random_state=42)
sc.tl.leiden(mdata, neighbors_key="wnn", resolution=0.5, key_added="leiden_wnn")
print(f"Clusters: {mdata.obs['leiden_wnn'].value_counts().to_dict()}")
# --- 7. Marker genes per cluster ---
sc.tl.rank_genes_groups(mdata["rna"],
groupby=mdata.obs.loc[mdata["rna"].obs_names, "leiden_wnn"],
method="wilcoxon", use_raw=False)
top_markers = sc.get.rank_genes_groups_df(mdata["rna"], group="0").head(5)
print("Top RNA markers for cluster 0:")
print(top_markers[["names", "logfoldchanges", "pvals_adj"]])
# --- 8. Visualization ---
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sc.pl.umap(mdata, color="leiden_wnn", ax=axes[0], show=False, title="WNN clusters")
sc.pl.umap(mdata, color="rna:gene_0", ax=axes[1], show=False, title="gene_0 expression",
use_raw=False, vmax="p99")
plt.tight_layout()
plt.savefig("multiome_wnn_pipeline.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved multiome_wnn_pipeline.png")
Workflow 2: CITE-seq (RNA + Surface Protein) Analysis
Goal: Process CITE-seq data with paired RNA and antibody-derived tag (ADT/protein) counts. Normalize proteins with CLR, annotate cell types from protein markers, and build a joint UMAP.
import muon as mu
import scanpy as sc
import numpy as np
import anndata as ad
import pandas as pd
import matplotlib.pyplot as plt
from scipy.sparse import csr_matrix
np.random.seed(1)
n_cells = 350
cell_ids = [f"CITESEQ_{i:04d}" for i in range(n_cells)]
# Simulate RNA + protein modalities
# Three cell types: T-cells (CD3+CD4+), B-cells (CD19+CD20+), Monocytes (CD14+CD16+)
n_t, n_b, n_mono = 120, 130, 100
labels = ["Tcell"] * n_t + ["Bcell"] * n_b + ["Monocyte"] * n_mono
rna_mat = np.abs(np.random.negative_binomial(4, 0.4, (n_cells, 1500))).astype(float)
rna_mat[:n_t, :50] += 15 # T-cell RNA markers
rna_mat[n_t:n_t+n_b, 50:100] += 15 # B-cell RNA markers
rna_mat[n_t+n_b:, 100:150] += 15 # Monocyte RNA markers
# 20 surface proteins: first 5 T-cell, next 5 B-cell, next 5 Monocyte, rest baseline
prot_mat = np.abs(np.random.normal(2, 0.5, (n_cells, 20)))
prot_mat[:n_t, :5] += 8 # CD3, CD4, CD5, CD7, CD8 for T-cells
prot_mat[n_t:n_t+n_b, 5:10] += 8 # CD19, CD20, CD22, CD24, CD79 for B-cells
prot_mat[n_t+n_b:, 10:15] += 8 # CD14, CD16, CD64, CD11b, HLA-DR for Monocytes
protein_names = ["CD3", "CD4", "CD5", "CD7", "CD8",
"CD19", "CD20", "CD22", "CD24", "CD79a",
"CD14", "CD16", "CD64", "CD11b", "HLA-DR",
"CD25", "CD56", "CD45RA", "CD45RO", "IgG-ctrl"]
rna = ad.AnnData(csr_matrix(rna_mat),
obs=pd.DataFrame({"cell_type": labels}, index=cell_ids),
var=pd.DataFrame(index=[f"gene_{j}" for j in range(1500)]))
prot = ad.AnnData(prot_mat,
obs=pd.DataFrame({"cell_type": labels}, index=cell_ids),
var=pd.DataFrame(index=protein_names))
mdata = mu.MuData({"rna": rna, "protein": prot})
print(mdata)
# --- RNA preprocessing ---
sc.pp.normalize_total(mdata["rna"], target_sum=1e4)
sc.pp.log1p(mdata["rna"])
sc.pp.highly_variable_genes(mdata["rna"], n_top_genes=500)
sc.pp.pca(mdata["rna"], n_comps=30, use_highly_variable=True)
# --- Protein CLR normalization (Centered Log-Ratio) ---
# CLR normalizes each protein across cells: log(x / geometric_mean(x))
mu.prot.pp.clr(mdata["protein"])
print("Protein CLR normalization applied")
print(f"Protein data range: [{mdata['protein'].X.min():.2f}, {mdata['protein'].X.max():.2f}]")
# PCA on protein modality
sc.pp.pca(mdata["protein"], n_comps=min(15, prot.n_vars - 1))
# --- WNN joint embedding ---
mu.pp.neighbors(mdata, key_added="wnn",
use_rep={"rna": "X_pca", "protein": "X_pca"},
n_neighbors=20, random_state=0)
sc.tl.umap(mdata, neighbors_key="wnn", random_state=0)
sc.tl.leiden(mdata, neighbors_key="wnn", resolution=0.4, key_added="leiden_wnn")
# --- Protein-based cell type annotation ---
# Compute mean CLR protein per cluster
prot_df = pd.DataFrame(
mdata["protein"].X,
index=mdata["protein"].obs_names,
columns=protein_names
)
prot_df["cluster"] = mdata.obs.loc[mdata["protein"].obs_names, "leiden_wnn"].values
cluster_means = prot_df.groupby("cluster").mean()
print("\nMean CLR protein per cluster:")
print(cluster_means[["CD3", "CD4", "CD19", "CD14"]].round(2))
# --- Visualization ---
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
sc.pl.umap(mdata, color="leiden_wnn", ax=axes[0], show=False, title="WNN Leiden")
sc.pl.umap(mdata, color="protein:CD3", ax=axes[1], show=False, title="CD3 (CLR)",
use_raw=False, vmax="p99")
sc.pl.umap(mdata, color="protein:CD19", ax=axes[2], show=False, title="CD19 (CLR)",
use_raw=False, vmax="p99")
plt.tight_layout()
plt.savefig("citeseq_joint_umap.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved citeseq_joint_umap.png")
# --- Dot plot: protein markers per cluster ---
sc.pl.dotplot(mdata["protein"],
var_names=["CD3", "CD4", "CD19", "CD20", "CD14", "CD16"],
groupby=mdata.obs.loc[mdata["protein"].obs_names, "leiden_wnn"],
show=False)
plt.savefig("citeseq_protein_dotplot.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved citeseq_protein_dotplot.png")
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
n_neighbors |
mu.pp.neighbors() |
30 |
10–100 |
Neighborhood size for graph; lower = finer local structure |
use_rep |
mu.pp.neighbors() |
None (auto) |
dict of {modality: embedding_key} |
Which embedding per modality to use for WNN |
key_added |
mu.pp.neighbors() |
"neighbors" |
any string | Key under which graph is stored; use "wnn" for joint graphs |
n_comps |
sc.pp.pca(), mu.atac.tl.lsi() |
50 |
10–100 |
Number of reduced dimensions; 30–50 typical |
resolution |
sc.tl.leiden() |
1.0 |
0.1–2.0 |
Clustering granularity; lower = fewer, larger clusters |
scale_factor |
mu.atac.pp.tfidf() |
1e4 |
1e3–1e5 |
TF scaling constant before log transform |
n_factors |
mu.tl.mofa() |
10 |
5–50 |
Number of MOFA latent factors; use elbow on variance explained |
target_sum |
sc.pp.normalize_total() |
1e4 |
1e3–1e6 |
Library size normalization target per cell (RNA) |
n_top_genes |
sc.pp.highly_variable_genes() |
varies | 1000–5000 |
Number of highly variable genes to retain for PCA |
Best Practices
-
Align obs before WNN: After per-modality QC filtering, cell counts across modalities may differ. Always call
mu.pp.intersect_obs(mdata)before WNN to ensure every modality has the same cell set.mu.pp.intersect_obs(mdata) print(f"Shared cells after intersect: {mdata.n_obs}") -
Drop LSI component 1 from ATAC: The first LSI component captures sequencing depth (total counts per cell) rather than biological signal.
mu.atac.tl.lsi()stores all components inX_lsi, so verify that component 1 correlates withlog_total_countsbefore usingX_lsiin WNN; if so, restrict toX_lsi[:, 1:].import numpy as np, pandas as pd atac = mdata["atac"] corr = np.corrcoef(atac.obsm["X_lsi"][:, 0], np.log1p(atac.obs["total_counts"]))[0, 1] print(f"LSI1 vs log_counts correlation: {corr:.3f}") # If |corr| > 0.9, exclude component 1: # atac.obsm["X_lsi"] = atac.obsm["X_lsi"][:, 1:] -
Use
key_added="wnn"consistently: Always name the joint graph"wnn"and passneighbors_key="wnn"to all downstreamsc.tl.umap(),sc.tl.leiden(), andsc.tl.paga()calls. Mixingneighbors_keyvalues silently uses the wrong graph. -
CLR normalization for proteins, not log-normalization: Antibody-derived tag (ADT) counts from CITE-seq have a different noise model than RNA. Use
mu.prot.pp.clr()for per-protein CLR normalization. Do NOT applysc.pp.normalize_total()+sc.pp.log1p()to the protein modality. -
Store raw RNA counts before normalization: Before any normalization, store the raw RNA counts in
mdata["rna"].layers["counts"]and setmdata["rna"].raw = mdata["rna"]. This is required for downstream differential expression tests and scvi-tools integration.import scipy.sparse as sp mdata["rna"].layers["counts"] = mdata["rna"].X.copy() # Store pre-normalization snapshot mdata["rna"].raw = mdata["rna"] -
Match WNN embedding dimensions: The RNA PCA and ATAC LSI embeddings passed to WNN should have comparable dimensionality (e.g., both 30 or 50 components). Mismatched dimensions do not cause errors but can down-weight the smaller modality.
Common Recipes
Recipe: Compute Per-Cluster Modality Weights from WNN
When to use: Inspect which cells rely more on RNA vs ATAC signal in the WNN graph. High RNA weight in a cluster suggests cleaner RNA data; high ATAC weight suggests stronger chromatin accessibility signal.
# WNN stores per-cell modality weights in mdata.obsm after mu.pp.neighbors()
# Key name: "{key_added}_weights" — check available keys
print([k for k in mdata.obsm.keys() if "wnn" in k.lower()])
# If weights are stored (depends on muon version):
if "wnn_weights" in mdata.obsm:
weights_df = pd.DataFrame(
mdata.obsm["wnn_weights"],
index=mdata.obs_names,
columns=["rna_weight", "atac_weight"]
)
weights_df["cluster"] = mdata.obs["leiden_wnn"].values
print(weights_df.groupby("cluster").mean().round(3))
else:
# Proxy: compare RNA vs ATAC PCA explained variance per cell
rna_var = np.var(mdata["rna"].obsm["X_pca"], axis=1)
atac_var = np.var(mdata["atac"].obsm["X_lsi"], axis=1)
mdata.obs["rna_signal_proxy"] = rna_var / (rna_var + atac_var)
mdata.obs["atac_signal_proxy"] = atac_var / (rna_var + atac_var)
print(mdata.obs[["rna_signal_proxy", "atac_signal_proxy", "leiden_wnn"]].groupby("leiden_wnn").mean().round(3))
Recipe: Export Cluster Labels Back to Per-Modality AnnData
When to use: After joint WNN clustering, copy shared cluster labels back into each modality's .obs for modality-specific downstream analyses (e.g., differential peaks within clusters).
# Copy joint cluster labels into each modality's obs
for mod_name in mdata.mod:
mod_adata = mdata[mod_name]
shared_cells = mod_adata.obs_names
mod_adata.obs["leiden_wnn"] = mdata.obs.loc[shared_cells, "leiden_wnn"].values
print(f"{mod_name}: added leiden_wnn, {mod_adata.obs['leiden_wnn'].nunique()} clusters")
# Now run modality-specific differential analysis per cluster
sc.tl.rank_genes_groups(mdata["rna"], groupby="leiden_wnn",
method="wilcoxon", use_raw=False)
sc.tl.rank_genes_groups(mdata["atac"], groupby="leiden_wnn",
method="wilcoxon", use_raw=False)
print("Differential features computed for both modalities")
Recipe: Convert MuData to Concatenated AnnData for scvi-tools
When to use: MultiVI and totalVI in scvi-tools require a concatenated AnnData with modality identity tracked in var. This recipe prepares the input for these models.
# Concatenate RNA and ATAC into a single AnnData with modality column in var
rna_adata = mdata["rna"].copy()
atac_adata = mdata["atac"].copy()
rna_adata.var["modality"] = "Gene Expression"
atac_adata.var["modality"] = "Peaks"
# Restore raw counts for scvi-tools (requires integer counts)
# Ensure mdata["rna"].layers["counts"] and mdata["atac"].X are integer
import anndata as ad
combined = ad.concat([rna_adata, atac_adata], axis=1, merge="unique")
combined.obs = mdata.obs.loc[combined.obs_names].copy()
print(f"Combined AnnData: {combined.n_obs} cells × {combined.n_vars} features")
print(combined.var["modality"].value_counts())
# Use combined with scvi.model.MULTIVI.setup_anndata(combined, ...)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
KeyError: 'rna' when building MuData |
Modality key not set or loaded from file without expected names | Check mdata.mod.keys(). When loading 10x h5 files, use mu.read_10x_h5() which auto-names modalities "rna" and "atac" |
ValueError: obs_names mismatch during WNN |
Cells filtered differently per modality leaving mismatched obs | Call mu.pp.intersect_obs(mdata) after per-modality QC to harmonize cell sets |
| UMAP/Leiden uses wrong graph | Multiple neighbor graphs exist; function uses default key "neighbors" |
Always pass neighbors_key="wnn" explicitly to sc.tl.umap() and sc.tl.leiden() |
| LSI component 1 dominates ATAC embedding | First component captures sequencing depth, not biology | Compute correlation of X_lsi[:, 0] with log_total_counts; if |
mu.atac.pp.tfidf raises sparse matrix error |
Input matrix is dense or wrong dtype | Convert first: mdata["atac"].X = scipy.sparse.csr_matrix(mdata["atac"].X.astype(float)) |
| CLR normalization produces NaN for proteins | Zero counts in a cell for all proteins | Filter cells with sc.pp.filter_cells(mdata["protein"], min_genes=1) before CLR |
mu.tl.mofa() fails with missing mofapy2 |
mofapy2 not installed | pip install mofapy2; ensure muon version ≥ 0.1.5 for MOFA integration |
| Memory error for large ATAC peak matrices | ATAC matrices (50k+ peaks × 10k+ cells) exceed RAM | Use sc.pp.highly_variable_genes() to select top 50k peaks first, or load in chunks |
Related Skills
- scanpy-scrna-seq — single-modality RNA analysis; use as the foundation for the RNA preprocessing steps within muon
- scvi-tools-single-cell — deep generative multi-modal integration (MultiVI for RNA+ATAC, totalVI for CITE-seq); use when probabilistic batch correction across samples is needed
- mofaplus-multi-omics — multi-omics factor analysis;
mu.tl.mofa()calls mofapy2 internally and stores factors in MuData - anndata-data-structure — AnnData fundamentals; each MuData modality is a standard AnnData object
- deeptools-ngs-analysis — upstream ATAC-seq BAM → bigWig normalization before peak calling
- macs3-peak-calling — produces the peak BED files used to generate the ATAC AnnData count matrix
References
- muon documentation — official docs, tutorials, API reference
- muon GitHub (scverse) — source code, issues, examples
- Bredikhin et al. Genome Biology 2022 — muon/MuData publication
- 10x Genomics Multiome analysis guide — step-by-step Multiome tutorial in muon
- WNN method (Hao et al. Cell 2021) — original Weighted Nearest Neighbor paper from Seurat v4
skills/systems-biology-multiomics/omics-analysis-guide/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill omics-analysis-guide -g -y
SKILL.md
Frontmatter
{
"name": "omics-analysis-guide",
"license": "open",
"description": "Three-tiered approach to omics data analysis (transcriptomics, proteomics) covering validated pipelines, standard workflows, and custom methods"
}
Omics Data Analysis Guide: Three-Tiered Approach
Metadata
Short Description: Comprehensive guide for analyzing omics data (transcriptomics, proteomics) using validated pipelines, standard workflows, or custom analysis methods.
Authors: HITS
Version: 1.0
Last Updated: December 2025
License: CC BY 4.0
Commercial Use: Allowed
Citations and Acknowledgments
If you use validated pipelines or tools (Option 1):
- Citation: Always cite the original publication associated with each tool or pipeline
- Acknowledgment: Cite the specific tools and methods used in your analysis
If you use standard workflows (Option 2):
- Acknowledgment Statement: "Analysis performed using standard omics data analysis workflows and best practices"
- Citation for RNA-seq analysis: Dobin A, et al. STAR: ultrafast universal RNA-seq aligner. Bioinformatics. 2013;29(1):15-21. PMID: 23104886
- Citation for proteomics: Cox J, Mann M. MaxQuant enables high peptide identification rates, individualized p.p.b.-range mass accuracies and proteome-wide protein quantification. Nat Biotechnol. 2008;26(12):1367-72. PMID: 19029910
Overview
This guide provides a three-tiered approach to omics data analysis, prioritizing validated pipelines and standard workflows before moving to custom analysis. Always start with Option 1 and proceed to subsequent options only if needed.
The guide covers:
- Transcriptomics: Bulk RNA-seq
- Proteomics: Pre-quantified protein abundance data (similar to bulk RNA-seq analysis)
Note: This guide focuses on analysis of already-quantified data. For raw data processing (alignment, quantification), refer to specialized tools and pipelines.
Key Concepts
Validated Pipeline vs. Standard Workflow vs. Custom Analysis
A validated pipeline is a specific tool with peer-reviewed benchmarking data demonstrating performance on data like yours (e.g., DESeq2 for RNA-seq counts, MaxQuant for label-free proteomics). A standard workflow is the canonical sequence of QC → normalization → statistical test → multiple-testing correction assembled from accepted community practice but tuned to your specific dataset. Custom analysis is bespoke statistical or computational modeling required when neither prior tier covers the data type or research question. The progression Option 1 → Option 2 → Option 3 trades reproducibility for flexibility — always exhaust earlier tiers first.
Missing Value Mechanisms (MCAR / MAR / MNAR)
Missing data in omics arises from three distinct mechanisms with different correct treatments. MCAR (Missing Completely At Random) means missingness is independent of any value — safe to impute with mean, median, or KNN. MAR (Missing At Random) means missingness depends on observed variables but not the unobserved value — KNN or model-based imputation is appropriate. MNAR (Missing Not At Random) means missingness depends on the missing value itself, typical in proteomics where low-abundance proteins drop below detection — requires left-censored imputation (minprob/QRILC) below the detection limit. Choosing the wrong mechanism systematically biases downstream statistics.
Test Assumptions and Test Selection
Parametric tests (Student's t-test, Welch's t-test) assume approximate normality and (for Student's) equal variances; they have higher power than non-parametric tests when assumptions hold. Non-parametric tests (Mann-Whitney U, permutation) make weaker assumptions and are correct under skewed distributions or small n, at the cost of statistical power. The choice depends on sample size (n < 10 favors non-parametric), normality (Shapiro-Wilk / Anderson-Darling at the feature level), variance homogeneity (Levene's test), and outlier prevalence.
Multiple Testing Correction
Omics analyses test thousands of features simultaneously. Without correction, expected false positives at α=0.05 across 20,000 genes is 1,000. Family-wise error rate (FWER) corrections like Bonferroni control the probability of any false positive but are conservative. False discovery rate (FDR) corrections like Benjamini-Hochberg control the expected proportion of false positives among reported significant features and are the standard for omics. Always report adjusted p-values, never raw p-values, when calling significance.
Decision Framework
Use this tree to choose the right analysis tier for your data:
Have you searched for a validated
pipeline matching your data type?
│
┌─────────────┴─────────────┐
│ │
NO YES
│ │
▼ ▼
Run Method 1 Did you find a validated
(literature) AND pipeline with benchmarks
Method 2 (consortia matching your data type
workflows) FIRST and biological question?
│
┌───────┴───────┐
│ │
YES NO
│ │
▼ ▼
OPTION 1: Is your data a
Use validated common type
pipeline (RNA-seq counts,
(e.g., DESeq2, pre-quantified
edgeR, MaxQuant) proteomics)?
│
┌───────┴───────┐
│ │
YES NO
│ │
▼ ▼
OPTION 2: OPTION 3:
Standard Custom analysis
workflow (consult
(QC → norm → statistician;
test → FDR) document
thoroughly)
Decision Table
| Data type | Sample size | Has validated pipeline? | Recommended tier | Specific tool / approach |
|---|---|---|---|---|
| Bulk RNA-seq counts | n ≥ 3/group | Yes (DESeq2, edgeR) | Option 1 | DESeq2 (negative binomial, default FDR < 0.05) |
| Pre-quantified proteomics, normal-distributed | n ≥ 5/group | Sometimes | Option 1 if pipeline matches; else Option 2 | limma or t-test + BH-FDR |
| Pre-quantified proteomics, MNAR-heavy | n ≥ 5/group | No (mechanism-specific) | Option 2 | minprob imputation → t-test or Mann-Whitney → BH-FDR |
| Small-cohort omics (n < 5) | n < 5 | Rarely | Option 2 with caution | Permutation test, report effect sizes; flag results as preliminary |
| Multi-omics integration | Variable | Limited | Option 3 | MOFA, DIABLO, or custom Bayesian model |
| Novel data type (e.g., spatial multi-omics) | Variable | No | Option 3 | Build from first principles; cross-validate |
| Time-series omics | n per timepoint | Sometimes (maSigPro, ImpulseDE2) | Option 1 if available; else Option 3 | maSigPro for transcriptomics; custom for proteomics |
Option 1: Search for Validated Analysis Methods (Recommended First)
1.1 Search for Validated Analysis Pipelines
IMPORTANT: You MUST complete BOTH Method 1 AND Method 2 before proceeding to Option 2. Do not skip Method 2 even if Method 1 finds no results.
Method 1: Literature Search for Best Practices
Search for validated analysis methods using web search tools or literature databases (PubMed, Google Scholar).
Search queries to try (use multiple):
"[DATA_TYPE]" "[ANALYSIS_TYPE]" validated pipeline best practices
"[DATA_TYPE]" analysis workflow "[ORGANISM]" published
"[DATA_TYPE]" "[TOOL_NAME]" validation benchmark comparison
Example for bulk RNA-seq:
"RNA-seq" "differential expression" validated pipeline human
"DESeq2" "edgeR" comparison validation RNA-seq
Example for proteomics:
"proteomics" "differential abundance" analysis validated methods
"proteomics" normalization imputation best practices
What to search for in results:
- Published papers with validated analysis pipelines
- Benchmark studies comparing different tools
- Best practices guides from major consortia (e.g., ENCODE, TCGA)
- Tool documentation with validation data
IMPORTANT: Spend adequate time searching literature. Look through at least the first 10-15 search results and check supplementary materials of relevant papers.
Method 2: Review Standard Analysis Workflows
Review established workflows from major consortia and publications:
- ENCODE RNA-seq analysis pipeline
- TCGA analysis protocols
- Published benchmark studies
What to Do with Results:
If you find validated pipelines or methods:
- Record the pipeline/method name and version
- Note the reference: Record the publication DOI/PubMed ID
- Record validation details: Benchmark results, recommended parameters, any limitations
- Document the workflow: Step-by-step analysis procedure
Example result format:
Data Type: Bulk RNA-seq
Analysis Goal: Differential expression
Pipeline: DESeq2 (v1.40.0)
Reference: Love MI, et al. Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biol. 2014;15(12):550. PMID: 25516281
Validation: Validated in multiple benchmark studies, recommended for count data
Parameters: Default parameters, FDR < 0.05, log2FC > 1
If no validated pipelines found in BOTH Method 1 AND Method 2: Only then proceed to Option 2: Use Standard Workflows
Option 2: Use Standard Analysis Workflows
When to Use This Option?
- No validated pipelines found for your specific data type
- Need to build a custom workflow from standard components
- Working with common data types (RNA-seq, proteomics)
- Want to follow community best practices
2.1 Overview of Standard Workflows
RNA-seq (Bulk):
- Quality control
- Normalization and filtering (if count data, use DESeq2/edgeR normalization)
- Statistical analysis (differential expression)
- Multiple testing correction
- Functional enrichment (optional)
Proteomics (Pre-quantified):
- Quality control
- Missing value assessment and imputation
- Normalization
- Batch correction (if needed)
- Statistical analysis (differential abundance)
- Multiple testing correction
2.2 Essential Quality Control Steps
CRITICAL: Quality control must be performed before any statistical analysis. Poor data quality will lead to unreliable results regardless of statistical methods used.
Sample-Level Quality Control
Check for outlier samples:
- Use PCA + Isolation Forest to detect outlier samples
- Standardize data, perform PCA, then apply Isolation Forest
- Remove or investigate samples identified as outliers
Check sample correlation:
- Calculate correlation matrix between samples
- Low correlations (< 0.5) may indicate poor quality samples
- Remove samples with consistently low correlation
Check for batch effects:
- Use PCA + silhouette score to assess batch separation
- If silhouette score > 0.3, strong batch effect detected
- Apply batch correction (ComBat or similar) if needed
Feature-Level Quality Control
Assess missing value patterns:
- Calculate missing percentage per feature and per sample
- Test correlation between mean intensity and missingness
- Determine mechanism: MCAR (Missing Completely At Random), MAR (Missing At Random), or MNAR (Missing Not At Random)
- MNAR: Low intensity -> more missing (common in proteomics)
- MCAR: No relationship between intensity and missingness
Check feature detection consistency:
- Count how many features are detected in minimum number of samples
- Filter features detected in < 50% of samples (adjustable threshold)
2.3 Preprocessing Steps
Missing Value Imputation
CRITICAL: Choose imputation method based on missing value mechanism:
-
MNAR (Missing Not At Random): Use minimum probability imputation (minprob)
- Impute values below detection limit using normal distribution
- Parameters: downshift=1.8, width=0.3
-
MCAR/MAR (Missing Completely/At Random): Use KNN imputation
- Use k-nearest neighbors (default: k=5) to impute missing values
- More robust than mean/median imputation
-
Simple methods (if few missing values):
- Mean imputation: Replace with feature mean
- Median imputation: Replace with feature median
Normalization
For RNA-seq count data: Normalization is typically handled by DESeq2/edgeR (size factors).
For proteomics/continuous data:
- Median normalization: Scale each sample to global median
- Quantile normalization: Make distributions identical across samples
- Z-score normalization: Standardize to mean=0, std=1
- Total intensity normalization: Scale to total intensity
2.4 Statistical Analysis: Choosing the Right Test
CRITICAL: Always check statistical test assumptions before performing analysis. Using the wrong test can lead to incorrect conclusions.
Step 1: Check Test Assumptions
Key checks to perform:
-
Normality test:
- Use Shapiro-Wilk test for n < 50, Anderson-Darling for n >= 50
- Sample subset of features (100 features) for speed
- If >=70% of features are normal, data is considered normal
-
Variance homogeneity test:
- Use Levene's test to check equal variances
- If >=70% of features have equal variances, assume equal variance
-
Sample size check:
- n < 5: Very small, results unreliable
- n < 10: Small, prefer non-parametric tests
- n >= 10: Can use parametric tests if assumptions met
-
Outlier check:
- Calculate z-scores, flag values with |z| > 3
- If >5% outliers, prefer non-parametric tests
Test selection logic:
- n < 5: Permutation test or Mann-Whitney U test
- n < 10: Mann-Whitney U test (non-parametric)
- Normal + Equal variance: Student's t-test
- Normal + Unequal variance: Welch's t-test
- Non-normal: Mann-Whitney U test
Step 2: Perform Statistical Test
Implementation steps:
-
For each feature:
- Extract values for group1 and group2
- Remove NaN values
- Calculate means and log2 fold change
- Perform selected test (t-test, Welch's t-test, or Mann-Whitney U)
- Record statistic and p-value
-
Apply FDR correction:
- Use Benjamini-Hochberg procedure (FDR_BH)
- Adjust p-values for multiple testing
- Mark features with p_adj < 0.05 as significant
Key libraries:
scipy.stats: Statistical tests (ttest_ind, mannwhitneyu, shapiro, levene)statsmodels.stats.multitest: FDR correction (multipletests with method='fdr_bh')
2.5 Visualization
Volcano Plot:
- X-axis: Log2 fold change
- Y-axis: -Log10 adjusted p-value
- Color by significance: Upregulated (red), Downregulated (blue), Not significant (gray)
- Add threshold lines for fold change and p-value
PCA Plot (for quality control):
- Standardize data, perform PCA
- Plot PC1 vs PC2
- Label samples, check for outliers and batch effects
2.6 What to Do with Results
Once you have completed the standard workflow:
- Document all steps and parameters used
- Save intermediate results for reproducibility
- Validate results using independent methods when possible
- Report key findings with appropriate statistics
If standard workflows don't meet your needs: Proceed to Option 3: Custom Analysis
Option 3: Custom Analysis Methods (Last Resort)
When to Use This Option?
- Novel data type not covered by standard workflows
- Specialized research questions requiring custom approaches
- Integration of multiple omics data types
- Advanced statistical modeling requirements
3.1 General Principles
Essential Requirements:
-
Data Quality: Ensure high-quality data before custom analysis
- Perform all QC steps from Option 2
- Remove outliers and batch effects
- Validate technical replicates
-
Statistical Rigor:
- Always check test assumptions before analysis
- Use appropriate statistical tests for your data distribution
- Apply multiple testing correction (FDR)
- Validate assumptions
-
Reproducibility:
- Document all steps and parameters
- Use version control for code
- Save intermediate results
- Provide seed values for random processes
-
Validation:
- Cross-validation when applicable
- Independent validation set if available
- Compare with known results when possible
Best Practices:
- Start simple: Begin with basic analyses before complex methods
- Validate assumptions: Test normality, independence, etc.
- Use appropriate transformations: Log transform if needed
- Consider biological context: Interpret results in light of known biology
- Consult literature: Review similar studies for guidance
Quick Start Examples
Example 1: Bulk RNA-seq Differential Expression Analysis
Step 1: Quality Control
- Check for outlier samples using PCA + Isolation Forest
- Check sample correlation matrix
- Remove low-quality samples
Step 2: For RNA-seq count data, use DESeq2 (typically in R)
library(DESeq2)
dds <- DESeqDataSetFromMatrix(countData = count_matrix, colData = sample_metadata, design = ~ condition)
dds <- DESeq(dds)
res <- results(dds, contrast=c("condition", "treatment", "control"))
Step 3: Functional Enrichment (optional)
- Use GSEA or GO enrichment tools (gseapy, etc.)
- Prepare ranked gene list from log2FC
- Run enrichment analysis
Example 2: Proteomics Differential Abundance Analysis
Step 1: Quality Control
- Check for outlier samples
- Assess missing values (determine mechanism: MCAR, MAR, or MNAR)
Step 2: Impute Missing Values
- If MNAR: Use minprob imputation
- If MCAR/MAR: Use KNN imputation
Step 3: Normalization
- Apply median or quantile normalization
Step 4: Check for Batch Effects
- Assess using PCA + silhouette score
- Apply batch correction if needed (ComBat or similar)
Step 5: Differential Abundance Analysis
- Check test assumptions (normality, variance, sample size)
- Select appropriate test (auto-select based on assumptions)
- Perform test, apply FDR correction
- Filter significant results (p_adj < 0.05)
Step 6: Visualization
- Create volcano plot
- Create PCA plot for QC
Data Type-Specific Considerations
RNA-seq (Bulk)
- Count data: Use DESeq2 or edgeR (negative binomial models)
- Normalization: Built into DESeq2/edgeR (size factors)
- Filtering: Remove low-count genes before analysis
- Multiple testing: Always apply FDR correction
- Statistical test: DESeq2/edgeR handle count data appropriately
Proteomics (Pre-quantified)
- Continuous data: Similar to normalized RNA-seq data
- Missing values: Common, especially for low-abundance proteins
- Assess missing mechanism (MCAR, MAR, MNAR)
- Use appropriate imputation method
- Normalization: Median, quantile, or total intensity normalization
- Statistical tests:
- Check normality and variance assumptions
- Use t-test if normal, Mann-Whitney if non-normal
- Always apply FDR correction
- Batch effects: Common in proteomics, check and correct if needed
Best Practices
-
Exhaust validated pipelines before building anything custom. Run both literature search and consortium-workflow review before falling back to bespoke analysis. Rationale: validated pipelines have peer-reviewed benchmarking; novel methods require their own validation effort and reduce reproducibility.
-
Perform sample-level QC before any statistical analysis. Use PCA + Isolation Forest for outlier detection, sample correlation matrices, and PCA + silhouette score for batch effects. Rationale: a single outlier sample or unrecognized batch effect can dominate test statistics and produce uninterpretable results regardless of the test chosen.
-
Diagnose the missing-value mechanism (MCAR / MAR / MNAR) before imputing. Check the correlation between mean intensity and missingness rate per feature. Rationale: imputing MNAR data with KNN biases low-abundance features upward; imputing MCAR data with minprob biases everything downward. Mechanism-aware imputation prevents systematic distortion.
-
Always check test assumptions, then choose the test — never the reverse. Run Shapiro-Wilk / Anderson-Darling for normality and Levene's for variance homogeneity on a representative feature subset. Rationale: applying a t-test to non-normal small-n data inflates type I error; defaulting to Mann-Whitney on well-behaved data wastes power.
-
Always apply FDR correction (Benjamini-Hochberg) for genome-wide tests. Report
p_adj(orq-value), not rawp. Rationale: with 20,000 genes tested at α=0.05, ~1,000 false positives are expected without correction — the result set is meaningless. -
Document every parameter and version, save intermediate outputs, and pin random seeds. Record tool version, parameter values, normalization method, imputation method, test choice, FDR threshold, and the seed for any stochastic step. Rationale: omics pipelines have many tunable knobs; without exact provenance the analysis cannot be reproduced or audited.
-
Validate findings on an independent dataset or with an orthogonal method whenever possible. Examples: confirm DE genes via qPCR, replicate in a public dataset (GEO, ArrayExpress), or compare across batches. Rationale: even FDR-controlled hits can be false positives driven by batch artifacts, contamination, or normalization choices.
Common Pitfalls
-
Skipping QC and going directly to statistics. Problem: Outlier samples and batch effects produce false signals that pass statistical tests, polluting the result list with artifacts. How to avoid: Always run sample-level PCA, correlation matrices, and outlier detection before any differential test. Treat QC as mandatory, not optional.
-
Imputing missing values with a one-size-fits-all method. Problem: Using mean imputation on MNAR proteomics data biases low-abundance proteins; using minprob on MCAR data biases everything below the detection limit downward. How to avoid: Diagnose the mechanism (correlation between intensity and missingness), then pick an appropriate imputer: minprob for MNAR, KNN for MCAR/MAR.
-
Using t-tests on non-normal or small-n data. Problem: Student's t-test assumes normality and (with pooled variance) equal variances; with n < 10 and skewed data, type I error inflates well above the nominal α. How to avoid: Run normality and variance tests first; use Welch's t-test for unequal variance, Mann-Whitney for non-normal, and permutation tests for n < 5.
-
Reporting raw p-values without multiple testing correction. Problem: Across thousands of features, raw p-values produce massive false discovery rates; the resulting "significant" gene lists are dominated by noise. How to avoid: Always apply Benjamini-Hochberg FDR (or BY for dependent tests) and report adjusted p-values. Set
p_adj < 0.05(orq < 0.05) as the significance threshold. -
Confusing fold change with statistical significance. Problem: A high log2 fold change at high p_adj is unreliable noise; a low log2 fold change at very low p_adj may be real but biologically negligible. How to avoid: Filter on both — typical thresholds are
|log2FC| > 1ANDp_adj < 0.05. Report effect sizes alongside p-values. -
Failing to correct for batch effects when present. Problem: Batch effects masquerade as biological signal, especially in proteomics and multi-cohort studies; PC1 ends up reflecting batch rather than condition. How to avoid: Check batch separation with PCA + silhouette score; if silhouette > ~0.3, apply ComBat, limma's
removeBatchEffect, or include batch as a covariate in the model. -
Treating Option 3 (custom analysis) as a shortcut. Problem: Jumping straight to custom methods without first running standard workflows skips peer-reviewed validation and makes results harder to publish and reproduce. How to avoid: Document a clear justification for why Options 1 and 2 are inadequate before moving to Option 3, and validate any custom method on simulated or held-out data.
References
Pipelines and Tools
- DESeq2: https://bioconductor.org/packages/release/bioc/html/DESeq2.html — Love MI, Huber W, Anders S. Genome Biology 2014; 15:550. PMID: 25516281
- edgeR: https://bioconductor.org/packages/release/bioc/html/edgeR.html — Robinson MD, McCarthy DJ, Smyth GK. Bioinformatics 2010; 26(1):139-40.
- STAR aligner: https://github.com/alexdobin/STAR — Dobin A, et al. Bioinformatics 2013; 29(1):15-21. PMID: 23104886
- MaxQuant: https://www.maxquant.org/ — Cox J, Mann M. Nat Biotechnol 2008; 26(12):1367-72. PMID: 19029910
- limma: https://bioconductor.org/packages/release/bioc/html/limma.html — Ritchie ME, et al. Nucleic Acids Res 2015; 43(7):e47.
- ComBat (sva package): https://bioconductor.org/packages/release/bioc/html/sva.html — Johnson WE, Li C, Rabinovic A. Biostatistics 2007; 8(1):118-27.
Consortium Best Practices
- ENCODE RNA-seq pipeline: https://www.encodeproject.org/data-standards/rna-seq/
- GTEx Analysis Protocol: https://gtexportal.org/home/methods
- TCGA Analysis Protocols: https://docs.gdc.cancer.gov/Data/Bioinformatics_Pipelines/
Statistical Methods
- Benjamini-Hochberg FDR: Benjamini Y, Hochberg Y. J. R. Stat. Soc. B 1995; 57(1):289-300.
- Multiple imputation in proteomics review: Lazar C, et al. J Proteome Res 2016; 15(4):1116-25.
- scipy.stats: https://docs.scipy.org/doc/scipy/reference/stats.html
- statsmodels multiple testing: https://www.statsmodels.org/stable/stats.html
Data Repositories for Validation
- GEO: https://www.ncbi.nlm.nih.gov/geo/
- ArrayExpress: https://www.ebi.ac.uk/biostudies/arrayexpress
- PRIDE (proteomics): https://www.ebi.ac.uk/pride/
Remember: Always start with validated pipelines (Option 1), then move to standard workflows (Option 2), and only use custom analysis (Option 3) when necessary. Document all steps and parameters for reproducibility. Quality control is essential at every stage of analysis. Always check statistical test assumptions before performing analysis.
skills/systems-biology-multiomics/reactome-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill reactome-database -g -y
SKILL.md
Frontmatter
{
"name": "reactome-database",
"license": "CC-BY-4.0",
"description": "Query Reactome pathways via REST: pathway queries, entity lookup, keyword search, gene list enrichment, hierarchy, cross-refs. Content + Analysis services. Python wrapper: reactome2py. For KEGG use kegg-database; for PPIs use string-database-ppi."
}
Reactome Database — Biological Pathway Queries & Enrichment Analysis
Overview
Reactome is an open-source, curated database of biological pathways and reactions for 16+ species. It provides two REST APIs: the Content Service for querying pathway data, entities, and hierarchy, and the Analysis Service for gene/protein list enrichment and expression data overlay. All endpoints return JSON (default) or other formats and require no authentication.
When to Use
- Querying pathway details by stable ID (e.g., R-HSA-69620 for Cell Cycle)
- Searching for pathways, reactions, or entities by keyword
- Running gene list enrichment analysis (over-representation) against Reactome pathways
- Retrieving pathway hierarchy and containment relationships
- Mapping identifiers across databases (UniProt, Ensembl, NCBI, ChEBI)
- Getting species-specific pathway data (human, mouse, rat, and 13+ other organisms)
- Retrieving analysis results by token for sharing or re-filtering
- Building pathway context for multi-omics integration workflows
- For KEGG metabolic pathways and cross-database ID conversion, use
kegg-databaseinstead - For protein-protein interaction networks, use
string-database-ppiinstead - For a Python wrapper with caching, consider
reactome2py(pip install reactome2py)
Prerequisites
pip install requests
API constraints:
- No authentication required — all endpoints are public
- No documented hard rate limit — add
time.sleep(0.5)between batch requests to be respectful - Content Service base URL:
https://reactome.org/ContentService - Analysis Service base URL:
https://reactome.org/AnalysisService - Identifier input: gene/protein lists accept UniProt IDs, Ensembl gene IDs, NCBI Gene IDs, HGNC symbols, ChEBI IDs, miRBase IDs, KEGG IDs, and more
Quick Start
import requests
import time
CONTENT = "https://reactome.org/ContentService"
ANALYSIS = "https://reactome.org/AnalysisService"
def reactome_get(base, path, params=None):
"""Generic Reactome REST API caller. Returns JSON or raises."""
resp = requests.get(f"{base}{path}", params=params)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
return resp.text
# Check database version
version = reactome_get(CONTENT, "/data/database/version")
print(f"Reactome version: {version}")
# Query a pathway
pathway = reactome_get(CONTENT, "/data/query/R-HSA-69620")
print(f"Pathway: {pathway['displayName']}")
print(f"Species: {pathway['speciesName']}")
time.sleep(0.5)
# Search for pathways
results = reactome_get(CONTENT, "/search/query", params={"query": "apoptosis", "types": "Pathway"})
print(f"Found {results['found']} results for 'apoptosis'")
Core API
1. Pathway & Entity Queries
Retrieve detailed information about pathways, reactions, and biological entities by stable ID. Uses reactome_get helper from Quick Start.
# Query pathway by stable ID
pathway = reactome_get(CONTENT, "/data/query/R-HSA-69620")
print(f"Name: {pathway['displayName']}")
print(f"Stable ID: {pathway['stId']}, Species: {pathway['speciesName']}")
print(f"Schema class: {pathway['schemaClass']}") # Pathway, TopLevelPathway, etc.
time.sleep(0.5)
# Get participating physical entities in a pathway
entities = reactome_get(CONTENT, f"/data/participants/{pathway['stId']}")
print(f"\nParticipating entities: {len(entities)}")
for e in entities[:3]:
print(f" {e['displayName']} ({e['schemaClass']})")
time.sleep(0.5)
# Get participating molecules with reference entities (UniProt, ChEBI, etc.)
refs = reactome_get(CONTENT, f"/data/participants/{pathway['stId']}/referenceEntities")
print(f"\nReference entities: {len(refs)}")
for r in refs[:3]:
print(f" {r['displayName']} — {r.get('databaseName', 'N/A')}:{r.get('identifier', 'N/A')}")
2. Search & Discovery
Search across Reactome by keyword with faceted filtering.
# Keyword search filtered to Pathways
results = reactome_get(CONTENT, "/search/query", params={
"query": "cell cycle",
"types": "Pathway",
"species": "Homo sapiens",
"cluster": "true"
})
print(f"Total found: {results['found']}")
for entry in results.get("results", [])[:1]:
for e in entry.get("entries", [])[:5]:
print(f" {e['stId']}: {e['name']}")
time.sleep(0.5)
# Search for proteins/complexes
proteins = reactome_get(CONTENT, "/search/query", params={
"query": "TP53", "types": "Protein", "species": "Homo sapiens"
})
print(f"\nTP53 protein entries: {proteins['found']}")
time.sleep(0.5)
# Suggest (autocomplete)
suggestions = reactome_get(CONTENT, "/search/suggest", params={"query": "apopt"})
print(f"Suggestions: {suggestions}")
Searchable types: Pathway, Reaction, Protein, Complex, SmallMolecule, Gene, DNA, RNA, Drug, ReferenceEntity
3. Enrichment Analysis
Submit a gene/protein list for over-representation analysis against Reactome pathways.
import requests
import time
ANALYSIS = "https://reactome.org/AnalysisService"
# Gene list (newline-separated identifiers — UniProt, HGNC symbols, Ensembl, etc.)
gene_list = "TP53\nBRCA1\nBRCA2\nATM\nCHEK2\nCDK2\nRB1\nMDM2\nCDKN1A\nBAX"
# Submit for enrichment (POST with text body)
resp = requests.post(
f"{ANALYSIS}/identifiers/",
headers={"Content-Type": "text/plain"},
data=gene_list,
params={"pageSize": 10, "page": 1, "sortBy": "ENTITIES_FDR", "order": "ASC"}
)
resp.raise_for_status()
result = resp.json()
print(f"Analysis token: {result['summary']['token']}")
print(f"Pathways found: {result['pathwaysFound']}")
print(f"Identifiers found: {result['identifiersNotFound']}")
print(f"\nTop enriched pathways:")
for p in result["pathways"][:5]:
print(f" {p['stId']}: {p['name']}")
print(f" FDR: {p['entities']['fdr']:.2e}, "
f"Found: {p['entities']['found']}/{p['entities']['total']}")
time.sleep(0.5)
Analysis accepts: newline-separated identifiers, or tab-separated with expression values (for expression overlay). Supported IDs include UniProt, HGNC symbols, Ensembl, NCBI Gene, ChEBI, miRBase, KEGG, and more.
4. Analysis Results & Filtering
Retrieve previously computed analysis results by token and apply filters.
import requests
import time
ANALYSIS = "https://reactome.org/AnalysisService"
# Re-fetch results by token (from a previous analysis)
token = "MjAyNTA2MTcxMDA3MzRfMQ%3D%3D" # example — use token from Module 3
# Get results with filtering
results = requests.get(f"{ANALYSIS}/token/{token}", params={
"pageSize": 20,
"page": 1,
"sortBy": "ENTITIES_FDR",
"species": "Homo sapiens",
"resource": "TOTAL" # TOTAL, UNIPROT, ENSEMBL, CHEBI, etc.
})
results.raise_for_status()
data = results.json()
print(f"Token: {data['summary']['token']}")
print(f"Pathways: {data['pathwaysFound']}")
time.sleep(0.5)
# Get identifiers found in a specific pathway
pathway_detail = requests.get(
f"{ANALYSIS}/token/{token}/found/all/{data['pathways'][0]['stId']}"
)
pathway_detail.raise_for_status()
found = pathway_detail.json()
print(f"\nIdentifiers found in {data['pathways'][0]['name']}:")
for entity in found.get("entities", [])[:5]:
mapsTo = [m["identifier"] for m in entity.get("mapsTo", [])]
print(f" {entity['id']} -> {mapsTo}")
Token persistence: analysis tokens are valid for several hours. Share tokens to let collaborators view the same results without re-running. Filter by resource (TOTAL, UNIPROT, ENSEMBL, CHEBI, etc.) and species.
5. Pathway Hierarchy & Events
Navigate the Reactome pathway hierarchy from top-level pathways down to reactions.
# Top-level pathways for human (9606 = NCBI taxonomy ID)
top = reactome_get(CONTENT, "/data/pathways/top/9606")
print(f"Top-level human pathways: {len(top)}")
for p in top[:5]:
print(f" {p['stId']}: {p['displayName']}")
time.sleep(0.5)
# Get contained events (sub-pathways and reactions)
events = reactome_get(CONTENT, "/data/pathway/R-HSA-69620/containedEvents")
print(f"\nContained events in Cell Cycle: {len(events)}")
for e in events[:5]:
print(f" {e['stId']}: {e['displayName']} ({e['schemaClass']})")
time.sleep(0.5)
# Get the full ancestor chain for a pathway
ancestors = reactome_get(CONTENT, "/data/event/R-HSA-69620/ancestors")
print(f"\nAncestors of Cell Cycle:")
for chain in ancestors:
names = [a["displayName"] for a in chain]
print(f" {' > '.join(names)}")
Species identifiers: use NCBI taxonomy IDs (9606=human, 10090=mouse, 10116=rat) or species names.
6. Cross-References & Species
Map identifiers across databases and query species-specific data.
# List all species in Reactome
species = reactome_get(CONTENT, "/data/species/all")
print(f"Species in Reactome: {len(species)}")
for s in species[:5]:
print(f" {s['displayName']} (taxId: {s['taxId']})")
time.sleep(0.5)
# Map a Reactome entity to external references
xrefs = reactome_get(CONTENT, "/data/query/R-HSA-69620/xrefs")
if isinstance(xrefs, list):
print(f"\nCross-references for R-HSA-69620: {len(xrefs)}")
for x in xrefs[:5]:
print(f" {x}")
time.sleep(0.5)
# Get orthologous pathway in another species (human → mouse)
mouse_ortho = reactome_get(CONTENT, "/data/orthology/R-HSA-69620/species/10090")
if mouse_ortho:
for o in mouse_ortho[:3]:
print(f"Mouse ortholog: {o['stId']}: {o['displayName']}")
Key Concepts
Pathway Hierarchy
Reactome organizes knowledge in a hierarchical structure:
| Level | Schema Class | Example |
|---|---|---|
| Top-Level Pathway | TopLevelPathway |
Cell Cycle, Immune System, Metabolism |
| Pathway | Pathway |
Cell Cycle Checkpoints, Mitotic G1-G1/S phases |
| Reaction | Reaction |
TP53 binds RB1 |
| Physical Entity | EntityWithAccessionedSequence |
TP53 [cytosol] |
Pathways contain sub-pathways and reactions. Reactions connect input/output physical entities. Each entity maps to reference databases (UniProt, ChEBI, Ensembl).
Supported Identifiers
The Analysis Service accepts a wide range of identifiers:
| Database | Example ID | Type |
|---|---|---|
| UniProt | P04637 | Protein |
| HGNC Symbol | TP53 | Gene symbol |
| Ensembl Gene | ENSG00000141510 | Gene |
| NCBI Gene | 7157 | Gene |
| ChEBI | CHEBI:15377 | Small molecule |
| miRBase | hsa-miR-21-5p | microRNA |
| KEGG Gene | hsa:7157 | Gene (KEGG format) |
| Ensembl Protein | ENSP00000269305 | Protein |
Analysis Token System
When you submit an analysis, Reactome returns a token — a URL-safe string that identifies your result set. Tokens enable:
- Re-fetching results without re-running analysis (
GET /token/{token}) - Filtering results by species or resource after initial analysis
- Sharing results with collaborators via URL:
https://reactome.org/PathwayBrowser/#/DTAB=AN&ANALYSIS={token} - Tokens expire after several hours; re-submit the gene list if needed
Common Workflows
Workflow 1: Gene List Enrichment Pipeline
Goal: Submit a gene list, get enriched pathways, and explore top hits.
import requests
import time
CONTENT = "https://reactome.org/ContentService"
ANALYSIS = "https://reactome.org/AnalysisService"
# Step 1: Submit gene list
genes = "TP53\nBRCA1\nBRCA2\nATM\nCHEK2\nCDK2\nRB1\nMDM2\nCDKN1A\nBAX"
resp = requests.post(
f"{ANALYSIS}/identifiers/",
headers={"Content-Type": "text/plain"},
data=genes,
params={"pageSize": 5, "sortBy": "ENTITIES_FDR", "order": "ASC"}
)
resp.raise_for_status()
result = resp.json()
token = result["summary"]["token"]
print(f"Token: {token} | Pathways found: {result['pathwaysFound']}")
# Step 2: Show top pathways with FDR
for p in result["pathways"][:5]:
fdr = p["entities"]["fdr"]
ratio = f"{p['entities']['found']}/{p['entities']['total']}"
print(f" {p['stId']}: {p['name']} (FDR={fdr:.2e}, {ratio})")
time.sleep(0.5)
# Step 3: Get details on top pathway
top_id = result["pathways"][0]["stId"]
detail = requests.get(f"{CONTENT}/data/query/{top_id}").json()
print(f"\nTop pathway: {detail['displayName']}")
print(f"Compartments: {[c['displayName'] for c in detail.get('compartment', [])]}")
Workflow 2: Pathway Exploration
Goal: Navigate from a top-level pathway down to specific reactions and entities.
# Uses reactome_get helper and CONTENT base URL from Quick Start
# Step 1: Find pathway by search
results = reactome_get(CONTENT, "/search/query",
params={"query": "DNA repair", "types": "Pathway", "species": "Homo sapiens"})
top_hit = results["results"][0]["entries"][0]
pid = top_hit["stId"]
print(f"Found: {pid} — {top_hit['name']}")
time.sleep(0.5)
# Step 2: Get sub-events
events = reactome_get(CONTENT, f"/data/pathway/{pid}/containedEvents")
reactions = [e for e in events if e["schemaClass"] == "Reaction"]
subpaths = [e for e in events if "Pathway" in e["schemaClass"]]
print(f"Sub-pathways: {len(subpaths)}, Reactions: {len(reactions)}")
time.sleep(0.5)
# Step 3: Get participating molecules for a reaction
if reactions:
rxn = reactions[0]
refs = reactome_get(CONTENT, f"/data/participants/{rxn['stId']}/referenceEntities")
print(f"\n{rxn['displayName']} participants:")
for r in refs[:5]:
print(f" {r.get('databaseName', '?')}:{r.get('identifier', '?')} — {r['displayName']}")
Workflow 3: Expression Data Analysis
Goal: Submit expression values alongside identifiers for pathway-level expression overlay.
import requests
ANALYSIS = "https://reactome.org/AnalysisService"
# Tab-separated: identifier \t expression_value1 \t expression_value2 ...
# First line can be a header (auto-detected)
expression_data = """#id\tcontrol\ttreated
TP53\t1.2\t3.5
BRCA1\t2.1\t1.8
CDK2\t0.9\t4.2
RB1\t1.5\t0.6
MDM2\t1.0\t2.8
CDKN1A\t0.8\t5.1
BAX\t1.1\t3.9"""
resp = requests.post(
f"{ANALYSIS}/identifiers/",
headers={"Content-Type": "text/plain"},
data=expression_data,
params={"pageSize": 10, "sortBy": "ENTITIES_FDR"}
)
resp.raise_for_status()
result = resp.json()
print(f"Expression columns: {result['summary'].get('sampleName', 'N/A')}")
print(f"Token: {result['summary']['token']}")
for p in result["pathways"][:3]:
exp = p["entities"].get("exp", [])
print(f" {p['name']}: FDR={p['entities']['fdr']:.2e}, expr={exp}")
Key Parameters
| Parameter | Function/Endpoint | Default | Options | Effect |
|---|---|---|---|---|
query |
/search/query |
— | Any string | Keyword search term |
types |
/search/query |
All | Pathway, Reaction, Protein, etc. |
Filter search by schema class |
species |
/search/query, analysis |
All | Species name or taxon ID | Restrict to organism |
pageSize |
Analysis, search | 20 | 1-250 | Results per page |
sortBy |
Analysis | ENTITIES_PVALUE |
ENTITIES_FDR, ENTITIES_PVALUE, ENTITIES_FOUND, NAME |
Sort enrichment results |
resource |
Analysis filtering | TOTAL |
TOTAL, UNIPROT, ENSEMBL, CHEBI, etc. |
Filter by identifier source |
cluster |
/search/query |
true |
true, false |
Group search results by type |
Best Practices
-
Use
time.sleep(0.5)between sequential requests: Reactome has no documented hard rate limit, but rapid-fire requests may be throttled. Be courteous to the shared resource. -
Save and reuse analysis tokens: Tokens remain valid for hours. Store the token to re-filter results by species or resource without re-submitting.
-
Prefer stable IDs over database IDs: Reactome stable IDs (R-HSA-69620) are permanent. Internal database IDs can change between releases.
-
Use
sortBy=ENTITIES_FDRfor enrichment results: FDR-corrected p-values are more reliable than raw p-values for pathway-level significance. -
Check
identifiersNotFoundin analysis results: a high unmapped count may indicate wrong identifier type or outdated IDs.
Common Recipes
Recipe: Get All Genes in a Pathway
import requests
CONTENT = "https://reactome.org/ContentService"
pathway_id = "R-HSA-69620" # Cell Cycle
refs = requests.get(f"{CONTENT}/data/participants/{pathway_id}/referenceEntities").json()
genes = set()
for r in refs:
if r.get("databaseName") == "UniProt":
genes.add(r.get("displayName", r.get("identifier")))
print(f"UniProt proteins in {pathway_id}: {len(genes)}")
for g in sorted(genes)[:10]:
print(f" {g}")
Recipe: Pathway Diagram URL
# Generate a direct link to the Reactome pathway diagram
pathway_id = "R-HSA-69620"
diagram_url = f"https://reactome.org/PathwayBrowser/#/{pathway_id}"
print(f"View diagram: {diagram_url}")
# With analysis overlay
token = "YOUR_TOKEN"
overlay_url = f"https://reactome.org/PathwayBrowser/#/{pathway_id}&DTAB=AN&ANALYSIS={token}"
print(f"View with analysis: {overlay_url}")
Recipe: Batch Pathway Query
import requests
import time
CONTENT = "https://reactome.org/ContentService"
pathway_ids = ["R-HSA-69620", "R-HSA-109581", "R-HSA-1640170"]
summaries = []
for pid in pathway_ids:
resp = requests.get(f"{CONTENT}/data/query/{pid}")
resp.raise_for_status()
data = resp.json()
summaries.append({
"stId": data["stId"],
"name": data["displayName"],
"species": data["speciesName"],
"hasDiagram": data.get("hasDiagram", False)
})
time.sleep(0.5)
for s in summaries:
print(f"{s['stId']}: {s['name']} (diagram: {s['hasDiagram']})")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
404 Not Found |
Invalid stable ID or wrong species prefix | Verify ID format: R-HSA-{number} for human; use /search/query to find valid IDs |
400 Bad Request |
Malformed POST body or wrong Content-Type | Use Content-Type: text/plain for analysis; newline-separated identifiers |
| Empty analysis results | Identifiers not recognized | Check identifiersNotFound; try different ID types (UniProt vs HGNC symbol) |
500 Internal Server Error |
Server-side issue or very large input | Retry after delay; split large gene lists (>2000 IDs) into batches |
| Token expired | Analysis results no longer available | Re-submit the gene list; tokens last several hours |
| Wrong species results | No species filter applied | Add species=Homo sapiens parameter to search/analysis |
| Slow response | Large pathway with many entities | Use pageSize to paginate; cache results locally |
| Cross-reference returns empty | Entity has no external DB mapping | Not all Reactome entities have UniProt/Ensembl mappings; check entity schema class |
Bundled Resources
This skill consolidates content from:
- API reference (465 lines): Content Service endpoints (data/query, search, participants, pathway hierarchy, species, xrefs) and Analysis Service endpoints (identifiers, token retrieval, filtering) are covered across Core API modules 1-6. Supported identifier types are in Key Concepts. Response format details and error handling are in Troubleshooting.
- Query script (286 lines): ReactomeClient class methods (query_pathway, get_pathway_entities, search_pathways, analyze_genes, get_analysis_by_token) are absorbed into Core API code blocks and Common Workflows.
Related Skills
- kegg-database — KEGG pathway queries and metabolic network data; use for metabolic pathway focus and cross-database ID conversion
- string-database-ppi — protein-protein interaction networks from STRING; complements Reactome pathway data with interaction evidence
- bioservices-multi-database — unified Python interface to 40+ databases including Reactome via
bioservices.Reactome - cobrapy-metabolic-modeling — constraint-based metabolic modeling; use Reactome pathway data as input for FBA analysis
References
- Reactome Content Service API — interactive API documentation (Swagger)
- Reactome Analysis Service API — enrichment analysis API documentation
- Reactome website — pathway browser, diagram viewer, species comparison
- reactome2py — official Python wrapper for Reactome APIs
- Gillespie, M. et al. (2022) "The reactome pathway knowledgebase 2022" Nucleic Acids Research 50:D364-D370
skills/systems-biology-multiomics/string-database-ppi/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill string-database-ppi -g -y
SKILL.md
Frontmatter
{
"name": "string-database-ppi",
"license": "CC-BY-4.0",
"description": "Query STRING REST API for PPIs (59M proteins, 20B interactions, 5000+ species). Retrieve networks, run GO\/KEGG enrichment, find partners, test PPI significance, visualize networks, analyze homology. For chemical interactions use chembl-database-bioactivity; pathways use kegg-database."
}
STRING Database — Protein-Protein Interactions
Overview
Query the STRING protein-protein interaction database (59M proteins, 20B+ interactions, 5000+ species) via REST API. Covers network retrieval, functional enrichment (GO, KEGG, Pfam), interaction partner discovery, PPI enrichment testing, network visualization, and homology analysis.
When to Use
- Retrieving protein-protein interaction networks for one or multiple proteins
- Performing functional enrichment analysis (GO, KEGG, Pfam, InterPro) on protein lists
- Discovering interaction partners and expanding protein networks from seed proteins
- Testing whether a set of proteins forms a significantly enriched functional module
- Generating network visualizations with evidence-based coloring
- Analyzing homology and protein family relationships across species
- Identifying hub proteins and network connectivity patterns
- For chemical compound interactions use chembl-database-bioactivity instead; for pathway-centric queries use kegg-database
Prerequisites
uv pip install requests pandas
Rate limiting: No strict rate limit, but wait ~1 second between API calls. For proteome-scale analyses, use bulk downloads from https://string-db.org/cgi/download instead of the API.
Quick Start
import requests
import time
STRING_API = "https://string-db.org/api"
def string_query(endpoint, params, fmt="tsv"):
"""Reusable helper for all STRING API calls."""
url = f"{STRING_API}/{fmt}/{endpoint}"
params.setdefault("caller_identity", "python_script")
response = requests.get(url, params=params)
response.raise_for_status()
return response.text
# Map gene names to STRING IDs (always do this first)
result = string_query("get_string_ids", {
"identifiers": "TP53\nBRCA1\nEGFR",
"species": 9606
})
print(result)
# Get interaction network
time.sleep(1)
network = string_query("network", {
"identifiers": "TP53%0dBRCA1%0dMDM2",
"species": 9606,
"required_score": 400
})
print(network[:500])
Key Concepts
Common Species NCBI Taxon IDs
| Organism | Common Name | Taxon ID |
|---|---|---|
| Homo sapiens | Human | 9606 |
| Mus musculus | Mouse | 10090 |
| Rattus norvegicus | Rat | 10116 |
| Drosophila melanogaster | Fruit fly | 7227 |
| Caenorhabditis elegans | C. elegans | 6239 |
| Saccharomyces cerevisiae | Yeast | 4932 |
| Arabidopsis thaliana | Thale cress | 3702 |
| Escherichia coli K-12 | E. coli | 511145 |
| Danio rerio | Zebrafish | 7955 |
| Gallus gallus | Chicken | 9031 |
Full species list: https://string-db.org/cgi/input?input_page_active_form=organisms
STRING Identifier Format
STRING uses Ensembl protein IDs with taxon prefix: {taxonId}.{ensemblProteinId} (e.g., 9606.ENSP00000269305 for human TP53). Always map gene names to STRING IDs first via get_string_ids for faster subsequent queries.
Interaction Confidence Scores
Combined scores (0-1000) integrating 7 evidence channels:
| Channel | Code | Source |
|---|---|---|
| Neighborhood | nscore |
Conserved genomic neighborhood |
| Fusion | fscore |
Gene fusion events |
| Phylogenetic profile | pscore |
Co-occurrence across species |
| Coexpression | ascore |
Correlated RNA expression |
| Experimental | escore |
Biochemical/genetic experiments |
| Database | dscore |
Curated pathway/complex databases |
| Text-mining | tscore |
Literature co-occurrence and NLP |
Recommended thresholds:
- 150: Low confidence (exploratory, hypothesis generation)
- 400: Medium confidence (standard analysis, default)
- 700: High confidence (conservative, fewer false positives)
- 900: Highest confidence (very stringent, experimental evidence preferred)
Network Types
- Functional (default): All evidence types — proteins functionally associated even without direct binding. Use for pathway analysis, enrichment, systems biology
- Physical: Direct binding evidence only — experimental data and curated physical interactions. Use for structural studies, complex analysis
Output Formats
Replace /tsv/ in the URL with the desired format:
- TSV: Tab-separated (default, best for data processing)
- JSON: Structured data (
/json/) - PNG/SVG: Network images (
/image/) - PSI-MI/PSI-MITAB: Proteomics standard formats
Core API
1. Identifier Mapping
# Map gene names to STRING IDs
result = string_query("get_string_ids", {
"identifiers": "TP53\nBRCA1\nEGFR",
"species": 9606,
"limit": 1, # matches per identifier
"echo_query": 1 # include query term in output
})
# Parse the mapping
import pandas as pd
import io
df = pd.read_csv(io.StringIO(result), sep='\t')
id_map = dict(zip(df['queryItem'], df['stringId']))
print(id_map)
# {'TP53': '9606.ENSP00000269305', 'BRCA1': '9606.ENSP00000...', ...}
2. Network Retrieval
# Get PPI network with confidence scores
network = string_query("network", {
"identifiers": "TP53%0dBRCA1%0dMDM2%0dATM%0dCHEK2",
"species": 9606,
"required_score": 400,
"network_type": "functional" # or "physical"
})
# Parse network edges
time.sleep(1)
df = pd.read_csv(io.StringIO(network), sep='\t')
print(f"Found {len(df)} interactions")
print(df[['preferredName_A', 'preferredName_B', 'score']].head())
# Expand network with additional interactors
expanded = string_query("network", {
"identifiers": "TP53",
"species": 9606,
"add_nodes": 10, # add 10 most connected proteins
"required_score": 700
})
3. Network Visualization
# Get PNG network image
url = f"{STRING_API}/image/network"
params = {
"identifiers": "TP53%0dMDM2%0dATM%0dCHEK2%0dBRCA1",
"species": 9606,
"required_score": 700,
"network_flavor": "evidence", # "evidence", "confidence", or "actions"
"caller_identity": "python_script"
}
response = requests.get(url, params=params)
with open("network.png", "wb") as f:
f.write(response.content)
4. Interaction Partners
# Discover top interaction partners
partners = string_query("interaction_partners", {
"identifiers": "TP53",
"species": 9606,
"limit": 20,
"required_score": 700
})
df = pd.read_csv(io.StringIO(partners), sep='\t')
print(f"Top 20 TP53 interactors:")
print(df[['preferredName_B', 'score']].head(10))
5. Functional Enrichment
# GO, KEGG, Pfam, InterPro, SMART, UniProt Keywords enrichment
# Statistical method: Fisher's exact test with Benjamini-Hochberg FDR correction
enrichment = string_query("enrichment", {
"identifiers": "TP53%0dMDM2%0dATM%0dCHEK2%0dBRCA1%0dATR%0dTP73",
"species": 9606
})
df = pd.read_csv(io.StringIO(enrichment), sep='\t')
significant = df[df['fdr'] < 0.05]
print(f"Significant terms: {len(significant)}")
# Group by annotation category
for cat, group in significant.groupby('category'):
print(f"\n{cat}: {len(group)} terms")
for _, row in group.head(3).iterrows():
print(f" {row['description']} (FDR={row['fdr']:.2e})")
6. PPI Enrichment Testing
import json
# Test if proteins form a significant functional module
result = string_query("ppi_enrichment", {
"identifiers": "TP53%0dMDM2%0dATM%0dCHEK2%0dBRCA1",
"species": 9606,
"required_score": 400
}, fmt="json")
data = json.loads(result)
print(f"Observed edges: {data['number_of_edges']}")
print(f"Expected edges: {data['expected_number_of_edges']}")
print(f"P-value: {data['p_value']}")
# p < 0.05 → proteins form a significantly enriched network
7. Homology Scores
# Get homology/similarity between proteins
homology = string_query("homology", {
"identifiers": "TP53%0dTP63%0dTP73",
"species": 9606
})
print(homology)
Common Workflows
Workflow 1: Protein List Analysis (Standard)
import requests, pandas as pd, io, json, time
STRING_API = "https://string-db.org/api"
def string_query(endpoint, params, fmt="tsv"):
url = f"{STRING_API}/{fmt}/{endpoint}"
params.setdefault("caller_identity", "python_script")
response = requests.get(url, params=params)
response.raise_for_status()
time.sleep(1)
return response.text
genes = "TP53%0dBRCA1%0dATM%0dCHEK2%0dMDM2%0dATR%0dBRCA2"
# Step 1: Map identifiers
mapping = string_query("get_string_ids", {"identifiers": genes.replace("%0d", "\n"), "species": 9606})
# Step 2: Get interaction network
network = string_query("network", {"identifiers": genes, "species": 9606, "required_score": 400})
net_df = pd.read_csv(io.StringIO(network), sep='\t')
print(f"Network: {len(net_df)} interactions")
# Step 3: Test PPI enrichment
ppi = json.loads(string_query("ppi_enrichment", {"identifiers": genes, "species": 9606}, fmt="json"))
print(f"PPI enrichment p-value: {ppi['p_value']}")
# Step 4: Functional enrichment
enrich = string_query("enrichment", {"identifiers": genes, "species": 9606})
enrich_df = pd.read_csv(io.StringIO(enrich), sep='\t')
sig = enrich_df[enrich_df['fdr'] < 0.05]
print(f"Significant GO/KEGG terms: {len(sig)}")
# Step 5: Save network image
img_resp = requests.get(f"{STRING_API}/image/network", params={
"identifiers": genes, "species": 9606, "required_score": 400,
"network_flavor": "evidence", "caller_identity": "python_script"
})
with open("protein_network.png", "wb") as f:
f.write(img_resp.content)
Workflow 2: Network Expansion from Seed Proteins
# Start with seed proteins, discover connected functional modules
seed = "TP53"
# Step 1: Get high-confidence interaction partners
partners = string_query("interaction_partners", {
"identifiers": seed, "species": 9606, "limit": 30, "required_score": 700
})
df = pd.read_csv(io.StringIO(partners), sep='\t')
all_proteins = list(set(df['preferredName_A'].tolist() + df['preferredName_B'].tolist()))
print(f"Expanded network: {len(all_proteins)} proteins")
# Step 2: Enrichment on expanded set
expanded_ids = "%0d".join(all_proteins[:50])
enrichment = string_query("enrichment", {"identifiers": expanded_ids, "species": 9606})
enrich_df = pd.read_csv(io.StringIO(enrichment), sep='\t')
modules = enrich_df[enrich_df['fdr'] < 0.001]
print(f"Highly significant terms: {len(modules)}")
Workflow 3: Cross-Species Comparison
# Compare protein interactions across species
for species, name, gene in [(9606, "Human", "TP53"), (10090, "Mouse", "Trp53")]:
network = string_query("network", {
"identifiers": gene, "species": species,
"required_score": 700, "add_nodes": 5
})
df = pd.read_csv(io.StringIO(network), sep='\t')
print(f"{name} ({gene}): {len(df)} interactions at score >= 700")
Common Recipes
Recipe: Parse Enrichment Results to DataFrame
import pandas as pd, io
enrichment_tsv = string_query("enrichment", {
"identifiers": "TP53%0dBRCA1%0dATM", "species": 9606
})
df = pd.read_csv(io.StringIO(enrichment_tsv), sep='\t')
# Columns: category, term, description, number_of_genes, p_value, fdr
kegg = df[df['category'] == 'KEGG'].sort_values('fdr')
print(kegg[['description', 'fdr']].head(5))
Recipe: Batch Protein Queries with Rate Limiting
import time
protein_lists = [["TP53", "MDM2"], ["EGFR", "ERBB2"], ["BRCA1", "BRCA2"]]
results = []
for proteins in protein_lists:
ids = "%0d".join(proteins)
network = string_query("network", {"identifiers": ids, "species": 9606})
results.append(network)
time.sleep(1) # respect rate limits
Recipe: Version Check for Reproducibility
version = string_query("version", {})
print(f"STRING version: {version.strip()}")
# Include in methods section: "STRING v{version}, accessed {date}"
Key Parameters
| Parameter | Endpoint | Default | Description |
|---|---|---|---|
identifiers |
All | — | Protein IDs, %0d-separated for URL or \n-separated for POST |
species |
All | — | NCBI taxon ID (9606=human, 10090=mouse) |
required_score |
network, partners, ppi_enrichment | 400 | Confidence threshold 0-1000 |
network_type |
network | functional |
functional (all evidence) or physical (direct binding) |
add_nodes |
network, image | 0 | Additional connected proteins to include (0-10) |
limit |
get_string_ids, partners | 1/10 | Max results per query |
network_flavor |
image | evidence |
evidence, confidence, or actions |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| No proteins found | Wrong species or identifier typo | Verify species taxon ID; use get_string_ids to check identifier mapping |
| Empty network | Too strict confidence threshold | Lower required_score; verify proteins actually interact in STRING |
| Timeout on large queries | Too many proteins in single request | Split into batches of 50-100; use bulk downloads for proteome-scale |
| "Species required" error | Missing species for >10 protein networks | Always include species parameter |
| Unexpected results | Wrong network type or STRING version | Check network_type (functional vs physical); verify version with /version |
| 400 Bad Request | Malformed identifiers | Use %0d separator in URL or \n in POST body; URL-encode special characters |
| Enrichment returns no terms | Too few input proteins | Enrichment needs 5+ proteins for meaningful results |
Best Practices
- Always map identifiers first — use
get_string_ids()before other operations; STRING IDs (e.g.,9606.ENSP00000269305) are faster than gene names - Rate-limit all requests — add
time.sleep(1)between API calls - Choose appropriate thresholds — 400 for exploratory analysis, 700 for publications, 900 for high-confidence only
- Specify species explicitly — required for networks >10 proteins, recommended always
- Use functional networks for pathway analysis and enrichment; physical networks for structural biology and direct binding
- Include version in methods — check
string_version()for reproducibility
Related Skills
networkx-graph-analysis— Graph analysis and visualization of STRING interaction networkskegg-database— Pathway-centric queries complementary to STRING enrichmentbioservices-multi-database— Alternative access to STRING via the PSICQUIC interface
References
- STRING website: https://string-db.org
- API documentation: https://string-db.org/help/api/
- Download page: https://string-db.org/cgi/download
- Publications: https://string-db.org/cgi/about
Bundled Resources
Main SKILL.md + 1 reference file. Original total: 990 lines (SKILL.md 534 + string_reference.md 456). Scripts: 370 lines (string_api.py).
references/api_advanced.md: Advanced API features (values/ranks enrichment, bulk upload, R/Cytoscape integration), output format details, HTTP error codes, data license — content from original string_reference.md that exceeds Core API scope.
Original file disposition:
SKILL.md(534 lines) → Core API modules 1-7, Workflows 1-3, Quick Start helper function, Key Concepts (species table, score thresholds, network types). "Common Use Cases" per-operation subsections consolidated into Core API module descriptions (rule 7b): each operation's "When to use" and "Use cases" → Core API intro text. "Detailed Reference" stub section → removed, content consolidated inlinereferences/string_reference.md(456 lines) → Partially consolidated inline: API endpoints → Core API modules with code blocks; species table → Key Concepts; confidence scores → Key Concepts; identifier format → Key Concepts. Advanced features (values/ranks enrichment, bulk upload), integration examples (R STRINGdb, Cytoscape), output format details, HTTP error codes, data license → migrated toreferences/api_advanced.mdscripts/string_api.py(370 lines) → Helper function pattern absorbed into Quick Start (string_queryreusable function). Per-function disposition:string_map_ids→ Core API Module 1;string_network→ Module 2;string_network_image→ Module 3;string_interaction_partners→ Module 4;string_enrichment→ Module 5;string_ppi_enrichment→ Module 6;string_homology→ Module 7;string_version→ Recipe. All were thin wrappers around urllib; replaced with requests-basedstring_queryhelper
Retention: ~460 lines (SKILL.md) + ~180 lines (reference) = ~640 / 990 original = ~65%.
.claude/skills/sciagent-skill-creator/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill sciagent-skill-creator -g -y
SKILL.md
Frontmatter
{
"name": "sciagent-skill-creator",
"description": "Scaffold a new SciAgent-Skills entry. Picks pipeline\/toolkit\/database\/guide template,\ncreates skills\/{category}\/{name}\/SKILL.md with valid frontmatter, appends the\nregistry.yaml entry, runs validation. Enforces name uniqueness, kebab-case,\ndescription keyword rules, schema rules from CLAUDE.md.\n\nTRIGGER when user says (any language): \"add a SciAgent skill\", \"add a skill for <X>\",\n\"create new skill\", \"create a SKILL.md for <X>\", \"scaffold a skill\", \"new skill entry\",\n\"register a skill\", \"신규 skill 추가\", \"스킬 만들어줘\", \"스킬 생성\", \"skill 만들어\",\nor any request to add a new SKILL.md to this repo. ALWAYS invoke this skill BEFORE\nwriting to skills\/ or registry.yaml.\n\nDO NOT TRIGGER when: editing existing entry's content (just edit the file directly);\nmigrating an existing entry (read CLAUDE.md \"Migrating from Existing Entries\" first);\nonly updating registry.yaml without creating a new SKILL.md."
}
SciAgent Skill Creator
Repo-local scaffolder for skills/ entries. Mechanizes the boilerplate from CLAUDE.md Steps 1, 2, 4, 5, 6 so authoring effort stays on content (When to Use, Workflow, Recipes, References) and not on field plumbing.
When to invoke this skill
- User asks for a new SciAgent skill entry on a specific tool, library, database, or guide topic
- User invokes
/sciagent-skill-creatordirectly - The agent is about to hand-edit
registry.yamland create askills/<cat>/<name>/SKILL.mdfrom scratch — use this instead
Do not invoke for:
- Editing an existing entry's content (just edit the file)
- Migrating an existing entry (read
CLAUDE.md"Migrating from Existing Entries" first — the scaffolder generates a skeleton, but migration requires content judgment) - Updating
registry.yamlonly (use a normal edit)
What you need to collect from the user
Before calling the scaffold script, gather these — in conversation, not via flags hidden from the user:
- Topic — concrete tool/library/concept name. Reject vague topics ("ML stuff") with a clarifying question.
- Sub-type —
pipeline|toolkit|database|guide. Use the decision rule from CLAUDE.md Step 1b. If unsure, ask the user. - Category — primary category directory. List the table from
CLAUDE.mdStep 2 if the user is unsure. - Entry name — kebab-case slug. Convention:
{tool-name}-{purpose}(e.g.,pydeseq2-differential-expression). Confirm with the user. - License — underlying tool's license. Default to
CC-BY-4.0for original prose-only content. - Description — 1-2 sentences, max 1024 chars. Lead with tool/domain keyword in the first 120 chars. Anti-patterns are in CLAUDE.md Step 5 "Description writing rules".
- Tags (optional) — only if the entry meaningfully spans multiple categories (e.g., literature DB stored under
scientific-writing, tag with["databases", "literature"]).
Duplicate check before scaffolding
Before calling the scaffold script, search the registry and legacy/ for similar names:
grep -i "<topic-keyword>" registry.yaml
ls legacy/ | grep -i "<topic-keyword>"
If a near-duplicate exists, surface it to the user before continuing. Authoring a parallel entry usually means the existing one needs updating, not duplication.
How to run the scaffolder
Call scripts/scaffold.py with explicit arguments. The script is non-interactive — the agent provides all values:
python .claude/skills/sciagent-skill-creator/scripts/scaffold.py \
--sub-type pipeline \
--category genomics-bioinformatics \
--name my-tool-purpose \
--description "MyTool short-form description starting with the tool name. Brief on inputs, outputs, when to pick this over alternatives." \
--license MIT \
--tags databases,literature # optional, comma-separated
Behavior:
- Validates name (kebab-case, not already in
registry.yaml, not inlegacy/) - Validates category exists as a directory under
skills/ - Validates description with
validate_description.py(length + first-120-char keyword lead) - Validates tags (kebab-case if provided)
- Creates
skills/{category}/{name}/SKILL.mdfrom the matching template, substituting frontmatter fields - Appends a new entry to
registry.yamlwithdate_added= today (UTC) - Runs
pixi run validateto confirm the registry is still well-formed - Prints next steps (fill in Overview, Workflow, Recipes, References)
On any validation failure, the script aborts without writing anything. Fix the offending value and re-run.
After scaffolding
The generated SKILL.md is a skeleton with placeholders. The agent's remaining job:
- Fill
Overview,When to Use,Prerequisites,Workflow/Core API/Key Concepts,Common Recipes,Troubleshooting,References - Match the section structure required by the sub-type (see CLAUDE.md Step 4 format rules)
- Run
pixi run test— full suite, not justvalidate— to catch sub-type-specific structural failures (code block counts, table row counts, section presence)
The scaffold script does not pretend to write content. Content stays with the agent and the source material.
Content authoring rules (what NOT to bake into a SKILL.md)
Skills document a tool's analysis surface, not the consumer's house style. A SKILL.md is read by many agents for many downstream tasks — visual choices that fit one analysis brief leak into every future invocation. Strip the following before committing:
- Color palettes, cmaps, themes — no hex codes (
#08306b), noLinearSegmentedColormap.from_list(...), noListedColormap([...]), no prescribedcmap=arguments unless the cmap is the tool's API (e.g., a tool that ships its own palette). Let matplotlib pick defaults; the consumer overrides downstream. - Per-replicate / per-condition color dicts — e.g.,
colors = {"rep1": "#1f77b4", ...}. Matplotlib auto-cycles colors. - Font choices, dpi presets, figure sizes tuned for one report —
figsize=(8, 4)for a routine line plot is fine;figsize=(12, 4)chosen to fit a slide deck is not. - One-shot user-brief specifics — if the user asked for "blue for low, red for high" in their analysis, that belongs in their code, not the skill. The skill teaches how to compute phi/psi density; how to color it is consumer choice.
- Hardcoded paths beyond the tool's defaults —
"figures/","results/",f"{pdb_id}_protein.pdb"are fine as illustrative outputs;"/Users/me/proj42/output"is not.
What to keep: the analysis logic, the data shape, the units, the parameter semantics, the expected output structure (columns, axes, units), and any visual choice the tool itself enforces.
Rule of thumb: if a downstream consumer would override the choice, don't ship the choice in the skill.
Writing style: be succinct
A SKILL.md is reference material for agents, not a tutorial. Token cost matters — every line is paid for on every retrieval. Write like documentation, not like a walkthrough:
- One sentence per section intro, not a paragraph. Drop hedging ("typically", "in general", "you may want to"), filler ("note that", "it's worth mentioning"), and softeners. State the rule.
- Comments inside code blocks earn their place. Only annotate non-obvious lines — units, gotchas, why this parameter. Don't restate what the code already says (
# load the trajectoryabovetraj = md.load(...)is noise). - Code over prose when possible. A four-line code block beats a paragraph describing the same call. The agent runs the code; the prose is for what the code can't show.
- No preamble before code. "The following snippet demonstrates how one might..." → delete. The header and code speak for themselves.
- No closing recap. Each section ends when the information ends. No "In summary..." or "As shown above...".
- Cut redundancy across sections. If Workflow Step 2 already shows the parameter, the Key Parameters table row doesn't need to re-explain it — just list it.
- Tables for enumerations, not bullets. Parameters, troubleshooting, codes → table. Prose lists waste vertical space.
Target density: a reader scanning the file should reach the next code block within ~5 lines of prose. If a section's prose is longer than its code, tighten the prose.
Files in this skill
SKILL.md— this file (when/how/what)scripts/scaffold.py— non-interactive scaffolder (create files, append registry, run validate)scripts/validate_description.py— description linter (length + first-120-char keyword rule). Reused byscaffold.pyand standalone.
Failure modes to surface to the user
- Name already exists → suggest a different suffix
- Category not in list → present the category table and ask
- Description starts with stop-verb (
Use,A,An,The,Query) → rewrite leading with the tool name - Description too long → trim disambiguation tail; keep keyword carrier
pixi run validatefails after scaffold → the registry is in an inconsistent state, abort and revert the partial write (the script does this automatically; report the validator output verbatim)
skills/genomics-bioinformatics/databases/clinpgx-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill clinpgx-database -g -y
SKILL.md
Frontmatter
{
"name": "clinpgx-database",
"license": "CC-BY-SA-4.0",
"description": "Query the ClinPGx (formerly PharmGKB) REST API plus the CPIC PostgREST companion API for pharmacogenomic clinical annotations, CPIC\/DPWG dosing guidelines, gene-drug pairs, variant-drug associations, FDA\/EMA drug labels, and PGx pathways. Two-host architecture: api.clinpgx.org for annotation records, api.cpicpgx.org for genotype→recommendation lookups. No auth. For germline pathogenicity use clinvar-database; for somatic cancer PGx use cosmic-database or opentargets-database; for drug bioactivity use chembl-database-bioactivity."
}
ClinPGx (PharmGKB) Pharmacogenomics Database
Overview
PharmGKB rebranded as ClinPGx in 2024 and the API moved from api.pharmgkb.org to api.clinpgx.org. The old host now returns 404/405; every example here uses the new endpoints. Two complementary APIs are used together:
- ClinPGx Data API (
api.clinpgx.org/v1) — record-style access to genes, drugs, variants, clinical annotations, guideline annotations, drug labels, and pathways. Responses wrap data as{"data": [...], "status": "success"}. Filters use dotted property paths (e.g.relatedChemicals.name=clopidogrel,levelOfEvidence.term=1A). - CPIC PostgREST API (
api.cpicpgx.org/v1) — relational lookup of genotype → drug recommendation rows. PostgREST filter syntax (column=eq.value, JSONcs.{...}for jsonb containment). Returns flat JSON arrays.
Use ClinPGx for what is known about a gene/drug/variant; use CPIC for how to prescribe given a phenotype. The pattern is ClinPGx for annotations, CPIC for recommendations.
When to Use
- Retrieving CPIC genotype-specific dosing recommendations for a gene-drug pair (e.g., CYP2C19 + clopidogrel) — use CPIC
- Looking up all pharmacogenomic clinical annotations for a drug or evidence level — use ClinPGx
data/clinicalAnnotation - Finding all CPIC/DPWG guideline annotations for a pharmacogene — use ClinPGx
data/guidelineAnnotation - Resolving a gene symbol, drug name, or rsID to ClinPGx PA identifiers — use
data/{gene,drug,variant} - Free-text search across all ClinPGx record types (genes, drugs, variants, annotations) — use
POST /site/search - Retrieving FDA/EMA pharmacogenomic drug label annotations — use ClinPGx
data/label - Building precision-medicine prescribing workflows that combine annotation evidence with phenotype-specific recommendations
- For germline disease pathogenicity (not PGx) use
clinvar-database - For somatic cancer pharmacogenomics use
cosmic-databaseoropentargets-database
Prerequisites
- Python packages:
requests,pandas— both already in standard environments - Data requirements: HGNC gene symbols, drug names (lowercase generic), dbSNP rsIDs, or PA identifiers
- Environment: internet connection; no authentication required for either host
- Rate limits: the ClinPGx host occasionally returns HTTP 429; insert
time.sleep(0.3–0.5)between sequential calls. CPIC is more permissive.
If you are inside a pixi/conda environment that already provides requests and pandas, skip the install — invoke scripts with pixi run python ....
pip install requests pandas
Quick Start
import requests
CLINPGX = "https://api.clinpgx.org/v1"
CPIC = "https://api.cpicpgx.org/v1"
# CPIC genotype → recommendation: clopidogrel + CYP2C19 Poor Metabolizer
drug = requests.get(f"{CPIC}/drug", params={"name": "eq.clopidogrel"}).json()[0]
recs = requests.get(f"{CPIC}/recommendation",
params={"drugid": f"eq.{drug['drugid']}",
"phenotypes": 'cs.{"CYP2C19":"Poor Metabolizer"}'}).json()
print(f"clopidogrel CYP2C19=PM: {len(recs)} recommendation(s)")
for rec in recs[:2]:
print(f" [{rec['classification']}] {rec['drugrecommendation'][:80]}…")
# ClinPGx side: how many CPIC guideline annotations cover CYP2C19?
glines = requests.get(f"{CLINPGX}/data/guidelineAnnotation",
params={"relatedGenes.symbol": "CYP2C19",
"source": "CPIC", "view": "base"}).json()["data"]
print(f"CYP2C19 CPIC guidelines: {len(glines)}")
Core API
Module 1: Free-text site search
POST /site/search with a JSON body {"query": "<term>"} is the canonical entry point when you don't know the PA ID. It searches across drugs, genes, variants, clinical annotations, guideline annotations, and labels in one shot.
import requests
CLINPGX = "https://api.clinpgx.org/v1"
r = requests.post(f"{CLINPGX}/site/search",
json={"query": "rs4149056"}, timeout=15)
r.raise_for_status()
hits = r.json()["data"]["hits"]
print(f"Total hits: {r.json()['data']['total']}")
for h in hits[:5]:
print(f" id={h.get('id')} name={h.get('name')[:80]}")
# Broader concept search
r = requests.post(f"{CLINPGX}/site/search",
json={"query": "TPMT azathioprine"}, timeout=15)
hits = r.json()["data"]["hits"]
print(f"TPMT+azathioprine hits: {len(hits)}")
for h in hits[:5]:
print(f" {h.get('id'):>15} {h.get('name','')[:80]}")
Module 2: Gene, drug, and variant record lookup
The /data/{type} endpoints accept simple property filters. All return {"data": [...], "status": "success"} — use view=base for summary, view=max for full nested objects.
import requests
CLINPGX = "https://api.clinpgx.org/v1"
# Gene by HGNC symbol
gene = requests.get(f"{CLINPGX}/data/gene",
params={"symbol": "CYP2D6", "view": "base"}).json()["data"][0]
print(f"{gene['symbol']} id={gene['id']} {gene['name']}")
# Drug by name (lowercase generic preferred)
drug = requests.get(f"{CLINPGX}/data/drug",
params={"name": "warfarin", "view": "base"}).json()["data"][0]
print(f"{drug['name']} id={drug['id']}")
# Variant by rsID
var = requests.get(f"{CLINPGX}/data/variant",
params={"name": "rs4149056", "view": "base"}).json()["data"][0]
print(f"{var['name']} id={var['id']} significance={var.get('clinicalSignificance')}")
# Direct record fetch when you already have a PA ID
r = requests.get(f"{CLINPGX}/data/drug/PA449088", params={"view": "max"}).json()
d = r["data"]
print(f"PA449088 → {d['name']} (objCls={d['objCls']})")
Module 3: Clinical annotations
data/clinicalAnnotation records associate a variant (location) with one or more drugs (relatedChemicals) and an evidence level (levelOfEvidence.term). The two supported filters are relatedChemicals.name= and levelOfEvidence.term=. There is no working gene= filter on this endpoint — see Module 4 for gene-driven access.
import requests, pandas as pd
CLINPGX = "https://api.clinpgx.org/v1"
# All clinical annotations for clopidogrel
data = requests.get(f"{CLINPGX}/data/clinicalAnnotation",
params={"relatedChemicals.name": "clopidogrel",
"view": "base"}).json()["data"]
print(f"clopidogrel annotations: {len(data)}")
rows = []
for ann in data[:10]:
loc = ann.get("location") or {}
drugs = ", ".join(c.get("name", "") for c in ann.get("relatedChemicals", []))
rows.append({
"id": ann["id"],
"variant": loc.get("displayName"),
"gene": (loc.get("genes") or [{}])[0].get("symbol"),
"drug": drugs,
"level": (ann.get("levelOfEvidence") or {}).get("term"),
"score": ann.get("score"),
})
print(pd.DataFrame(rows).to_string(index=False))
# All Level 1A clinical annotations (highest evidence)
data = requests.get(f"{CLINPGX}/data/clinicalAnnotation",
params={"levelOfEvidence.term": "1A",
"view": "base"}).json()["data"]
print(f"Level 1A annotations: {len(data)}")
drug_to_count = {}
for ann in data:
for c in ann.get("relatedChemicals") or []:
drug_to_count[c["name"]] = drug_to_count.get(c["name"], 0) + 1
top = sorted(drug_to_count.items(), key=lambda x: -x[1])[:10]
for d, n in top:
print(f" {n:3} {d}")
Module 4: Guideline annotations (gene-driven access)
data/guidelineAnnotation supports both relatedGenes.symbol= and relatedChemicals.name=, plus source= (CPIC, DPWG, CPNDS, RNPGx). This is the canonical way to get gene→guideline coverage.
import requests
CLINPGX = "https://api.clinpgx.org/v1"
# All CPIC guidelines mentioning CYP2C19
data = requests.get(f"{CLINPGX}/data/guidelineAnnotation",
params={"relatedGenes.symbol": "CYP2C19",
"source": "CPIC",
"view": "base"}).json()["data"]
print(f"CYP2C19 CPIC guidelines: {len(data)}")
for g in data[:5]:
print(f" PA{g['id']}: {g['name'][:80]}")
# Guidelines for a specific drug across all bodies (CPIC, DPWG, …)
data = requests.get(f"{CLINPGX}/data/guidelineAnnotation",
params={"relatedChemicals.name": "clopidogrel",
"view": "base"}).json()["data"]
by_source = {}
for g in data:
for s in (g.get("crossReferences") or []):
by_source.setdefault(s.get("resource", "?"), 0)
by_source[s["resource"]] = by_source.get(s["resource"], 0) + 1
print(f"clopidogrel guidelines: {len(data)} ({list({g.get('source') for g in data})})")
Module 5: Regulatory drug labels (FDA / EMA)
data/label records are PharmGKB-curated annotations of FDA/EMA pharmacogenomic labeling. Filter by relatedChemicals.name= and source= (FDA, EMA, HCSC, PMDA, Swissmedic).
import requests, pandas as pd
CLINPGX = "https://api.clinpgx.org/v1"
data = requests.get(f"{CLINPGX}/data/label",
params={"relatedChemicals.name": "warfarin",
"source": "FDA",
"view": "base"}).json()["data"]
print(f"warfarin FDA labels: {len(data)}")
rows = [{
"name": d["name"][:60],
"biomarker_status": d.get("biomarkerStatus"),
"testing_required": d.get("testingRequired"),
"alternate_drug": d.get("alternateDrugAvailable"),
} for d in data]
print(pd.DataFrame(rows).to_string(index=False))
Module 6: CPIC genotype → recommendation chain
CPIC's PostgREST API uses column=eq.value for equality and column=cs.{...} for JSONB containment. The standard lookup chain is drug → drugid → recommendation, optionally filtered by phenotype.
import requests
CPIC = "https://api.cpicpgx.org/v1"
# Resolve drug name to drugid (RxNorm-prefixed)
drug = requests.get(f"{CPIC}/drug",
params={"name": "eq.clopidogrel"}).json()[0]
print(f"clopidogrel drugid: {drug['drugid']}")
# All phenotype-specific recommendations for clopidogrel
recs = requests.get(f"{CPIC}/recommendation",
params={"drugid": f"eq.{drug['drugid']}"}).json()
print(f"Total recommendations: {len(recs)}")
for rec in recs[:3]:
print(f" {rec['phenotypes']} [{rec['classification']}]")
print(f" {rec['drugrecommendation'][:90]}…")
# Phenotype filter via jsonb containment (cs.{...})
# The phenotypes column is a jsonb dict; cs. checks that the query is a subset.
recs = requests.get(f"{CPIC}/recommendation",
params={"drugid": f"eq.{drug['drugid']}",
"phenotypes": 'cs.{"CYP2C19":"Poor Metabolizer"}'}
).json()
for rec in recs:
print(f" [{rec['classification']}] {rec['drugrecommendation'][:90]}…")
# Gene-driven: list every drug with a CPIC pair for CYP2C19
pairs = requests.get(f"{CPIC}/pair",
params={"genesymbol": "eq.CYP2C19"}).json()
print(f"\nCYP2C19 CPIC pairs: {len(pairs)}")
drug_ids = sorted({p["drugid"] for p in pairs})
print(f"Sample drug IDs: {drug_ids[:5]}")
Key Concepts
Two-host architecture
| Question | Use | Why |
|---|---|---|
| What clinical annotations exist for this drug? | ClinPGx data/clinicalAnnotation |
Annotation-level evidence with curated levelOfEvidence.term |
| What CPIC guidelines cover this gene? | ClinPGx data/guidelineAnnotation |
Filter by relatedGenes.symbol; no working gene= filter on clinicalAnnotation |
| Given phenotype X, what should I prescribe? | CPIC recommendation + phenotypes |
Structured genotype→action rows; CPIC is the prescribing-rule oracle |
| What FDA labels mention this drug + gene? | ClinPGx data/label?source=FDA |
Curated regulatory PGx labeling |
| Free-text "anything about X" | ClinPGx POST /site/search |
Cross-record-type fan-out |
PharmGKB / ClinPGx evidence levels
Levels 1A → 4 in decreasing evidence quality:
- 1A — Annotation of a variant–drug pair in a clinical guideline or FDA label (strongest)
- 1B — Significant association replicated in multiple studies
- 2A — Variant in a known PGx gene, significant association
- 2B — Moderate evidence, often single study
- 3 — Limited evidence (single study or unreplicated)
- 4 — Case reports / biological plausibility only
Filter via levelOfEvidence.term on data/clinicalAnnotation. The term is a string, not an enum ("1A" not 1A).
ClinPGx response envelope and view modes
Every ClinPGx /data/... response is {"data": [...] | {...}, "status": "success" | "fail"}. On failure the body is {"status": "fail", "data": {"errors": [{"message": "..."}]}} — always read both keys.
view=base(default) — flat summary record; recommended for bulk filtersview=max— full nested objects (relatedDiseases, allelePhenotypes, scoreDetails, …). Larger payload, slower; use only for single-record details.
Common Workflows
Workflow 1: Pharmacogene panel CPIC coverage
Goal: Given a patient's pharmacogene panel, count how many CPIC guideline annotations cover each gene.
import requests, pandas as pd, time
CLINPGX = "https://api.clinpgx.org/v1"
pharmacogenes = ["CYP2D6", "CYP2C19", "CYP2C9", "DPYD", "TPMT", "SLCO1B1"]
rows = []
for g in pharmacogenes:
data = requests.get(f"{CLINPGX}/data/guidelineAnnotation",
params={"relatedGenes.symbol": g,
"source": "CPIC", "view": "base"},
timeout=20).json()["data"]
drugs = sorted({c["name"] for guideline in data
for c in (guideline.get("relatedChemicals") or [])})
rows.append({"gene": g, "cpic_guidelines": len(data),
"n_drugs": len(drugs), "sample": ", ".join(drugs[:3])})
time.sleep(0.3)
df = pd.DataFrame(rows).sort_values("cpic_guidelines", ascending=False)
print(df.to_string(index=False))
df.to_csv("pharmacogene_cpic_coverage.csv", index=False)
Workflow 2: Drug panel — CPIC prescribing rule lookup
Goal: Given a prescribed drug list, identify which have CPIC genotype-specific recommendations and surface the rule rows.
import requests, pandas as pd, time
CPIC = "https://api.cpicpgx.org/v1"
drugs = ["warfarin", "clopidogrel", "codeine", "simvastatin",
"metoprolol", "omeprazole", "azathioprine", "tacrolimus"]
rows = []
for name in drugs:
drug = requests.get(f"{CPIC}/drug", params={"name": f"eq.{name}"}, timeout=15).json()
if not drug:
rows.append({"drug": name, "in_cpic": False, "n_recs": 0, "phenotypes": ""}); continue
did = drug[0]["drugid"]
recs = requests.get(f"{CPIC}/recommendation",
params={"drugid": f"eq.{did}"}, timeout=15).json()
phens = sorted({f"{k}={v}" for rec in recs
for k, v in (rec.get("phenotypes") or {}).items()})
rows.append({"drug": name, "in_cpic": True, "n_recs": len(recs),
"phenotypes": "; ".join(phens[:3])})
time.sleep(0.3)
df = pd.DataFrame(rows).sort_values(["in_cpic", "n_recs"], ascending=[False, False])
print(df.to_string(index=False))
Workflow 3: Variant → drug interactions (rsID-driven)
Goal: Starting from a single rsID (e.g., SLCO1B1 *5 = rs4149056), find every clinical annotation that involves it.
The Data API does not accept rsID as a filter property. Use POST /site/search to discover related annotation IDs, then fetch each by ID.
import requests
CLINPGX = "https://api.clinpgx.org/v1"
rsid = "rs4149056"
hits = requests.post(f"{CLINPGX}/site/search",
json={"query": rsid}, timeout=15).json()["data"]["hits"]
print(f"{rsid}: {len(hits)} hits")
# Filter hits that look like clinical annotations
ann_hits = [h for h in hits if h.get("name", "").lower().startswith("clinical annotation")]
print(f"Clinical-annotation hits: {len(ann_hits)}")
for h in ann_hits[:5]:
print(f" id={h['id']} {h['name'][:90]}")
# Dereference one annotation by ID for full detail
if ann_hits:
ann = requests.get(f"{CLINPGX}/data/clinicalAnnotation/{ann_hits[0]['id']}",
params={"view": "max"}, timeout=15).json()["data"]
drugs = ", ".join(c["name"] for c in (ann.get("relatedChemicals") or []))
print(f"\nFirst annotation:")
print(f" drugs: {drugs}")
print(f" level: {(ann.get('levelOfEvidence') or {}).get('term')}")
Key Parameters
| Parameter | Module / Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
view |
all /data/... |
base |
base, min, max |
Field detail level; max includes all nested arrays (slow but complete) |
relatedChemicals.name |
clinicalAnnotation, variantAnnotation, guidelineAnnotation, label, pathway |
— | lowercase generic drug name | Filter records related to a drug |
relatedGenes.symbol |
guidelineAnnotation, pathway |
— | HGNC gene symbol | Filter records related to a gene (not available on clinicalAnnotation) |
levelOfEvidence.term |
clinicalAnnotation |
— | "1A", "1B", "2A", "2B", "3", "4" |
Minimum evidence level |
source |
guidelineAnnotation, label |
— | CPIC, DPWG, FDA, EMA, HCSC, PMDA, Swissmedic |
Issuing body |
symbol |
data/gene |
— | HGNC gene symbol | Gene record lookup |
name |
data/drug, data/variant |
— | drug name or rsID | Record lookup by canonical name |
CPIC column=eq.value |
all api.cpicpgx.org/v1/... |
— | PostgREST equality | Filter by exact match |
CPIC phenotypes=cs.{json} |
recommendation |
— | JSON-encoded jsonb subset | Filter by phenotype containment (must URL-encode if special chars) |
Best Practices
-
Resolve PA identifiers once. Never hand-construct ClinPGx PA IDs. Call
data/{type}?{symbol|name}=...(orsite/search) once and cache the returnedidfor reuse —gene/PA128for CYP2D6,drug/PA449088for clopidogrel,variant/PA166154579for rs4149056. -
Pick the right host for the question. Use ClinPGx for
what is annotatedand CPIC forwhat to prescribe. Trying to derive genotype-specific recommendations from ClinPGx alone misses the structuredrecommendation.phenotypesrows. -
Filter by evidence level upfront when building clinical workflows.
levelOfEvidence.term=1Areturns 312 actionable annotations across all of ClinPGx; Level 3/4 records are exploratory and shouldn't drive prescribing. -
Don't filter
clinicalAnnotationby gene — filter byguidelineAnnotationwithrelatedGenes.symbol. TheclinicalAnnotationendpoint has no working gene property and returns HTTP 400 for any attempt. -
Use
view=basefor bulk filters,view=maxfor single-record drill-downs. A list query withview=maxcan time out or hit 429; the difference is roughly 5–10× payload size. -
Throttle the ClinPGx host. Insert
time.sleep(0.3)between sequential queries in loops; the API returns occasional HTTP 429s on tight loops. CPIC tolerates faster iteration. -
URL-encode
cs.{...}jsonb filters when phenotype values contain spaces or special characters.requests.get(..., params={"phenotypes": 'cs.{"CYP2C19":"Poor Metabolizer"}'})works becauserequestsdoes the encoding; a manual URL string needsurllib.parse.quote.
Common Recipes
Recipe 1 — Free-text discovery via site/search
When to use: you have an arbitrary string (rsID, drug name, gene, allele) and want to find related ClinPGx records without knowing which endpoint to hit.
import requests
r = requests.post("https://api.clinpgx.org/v1/site/search",
json={"query": "VKORC1 warfarin"}, timeout=15)
hits = r.json()["data"]["hits"]
for h in hits[:10]:
print(f" {h.get('id'):>15} {h.get('name','')[:80]}")
Recipe 2 — Top drugs by Level 1A annotation count
When to use: build a leaderboard of the most actionable PGx drugs.
import requests, pandas as pd
data = requests.get("https://api.clinpgx.org/v1/data/clinicalAnnotation",
params={"levelOfEvidence.term": "1A", "view": "base"},
timeout=30).json()["data"]
counts = {}
for ann in data:
for c in ann.get("relatedChemicals") or []:
counts[c["name"]] = counts.get(c["name"], 0) + 1
df = pd.DataFrame(sorted(counts.items(), key=lambda x: -x[1]),
columns=["drug", "n_1A_annotations"]).head(15)
print(df.to_string(index=False))
Recipe 3 — Patient genotype → drug recommendations
When to use: given a phenotype call from a PGx test, surface every CPIC recommendation row.
import requests
CPIC = "https://api.cpicpgx.org/v1"
genotype = {"CYP2C19": "Poor Metabolizer"}
drug = "clopidogrel"
did = requests.get(f"{CPIC}/drug", params={"name": f"eq.{drug}"}).json()[0]["drugid"]
import json
recs = requests.get(f"{CPIC}/recommendation",
params={"drugid": f"eq.{did}",
"phenotypes": f"cs.{json.dumps(genotype)}"}).json()
for rec in recs:
print(f"[{rec['classification']}] {rec['drugrecommendation']}")
print(f" implications: {rec['implications']}")
Recipe 4 — Robust session with retry
When to use: long-running loops over many genes / drugs / variants.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
s.headers.update({"Accept": "application/json"})
s.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"])))
r = s.get("https://api.clinpgx.org/v1/data/gene",
params={"symbol": "CYP2D6", "view": "base"}, timeout=20)
r.raise_for_status()
print(r.json()["data"][0]["name"])
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404/405 on https://api.pharmgkb.org/v1/... |
Old PharmGKB host is dead; the service rebranded to ClinPGx in 2024 | Migrate to https://api.clinpgx.org/v1/.... Old /clinicalAnnotation?gene=X is now data/clinicalAnnotation with different filters. |
{"status":"fail","data":{"errors":[{"message":"No such property: 'gene'"}]}} |
data/clinicalAnnotation does not accept gene= or relatedGenes.symbol= |
Use data/guidelineAnnotation?relatedGenes.symbol=X for gene-driven access, or ?relatedChemicals.name=Y for drug-driven. |
{"status":"fail","data":{"errors":[{"message":"Missing criteria."}]}} |
A data/{type} list query has no filter and no ID |
Add at least one filter (name=, symbol=, relatedChemicals.name=, …) or fetch by ID via data/{type}/{paId}. |
HTTP 405 on GET /site/search?query=... |
site/search only accepts POST with a JSON body |
Use requests.post(url, json={"query": "..."}). |
| HTTP 429 mid-loop | Hit ClinPGx rate limit | Insert time.sleep(0.3–0.5) between calls; use the Retry session in Recipe 4. |
HTTP 400 on https://api.cpicpgx.org/v1/recommendation?phenotypes=cs.{...} |
The cs. JSON wasn't URL-encoded |
Pass via requests params={"phenotypes": 'cs.{"CYP2C19":"Poor Metabolizer"}'} (auto-encoded) or urllib.parse.quote manually. |
Empty data list for an obviously-real drug |
Drug name mismatch (brand vs. generic; capitalization) | Try lowercase generic name; fall back to POST /site/search to fan out and find the canonical PA ID. |
data/variant?name=rs... returns 1 record but data/clinicalAnnotation?location.name=rs... returns 404 |
rsID is stored under location.displayName/location.rsid, not exposed as a filterable property |
Use site/search to discover annotation IDs by rsID, then dereference each with data/clinicalAnnotation/{id}. (Workflow 3.) |
Related Skills
clinvar-database— germline pathogenicity / clinical significance for variants found in PharmGKB (complementary; ClinVar is disease-focused, ClinPGx is drug-response-focused)opentargets-database— drug-target associations and safety signals overlapping ClinPGx pharmacogene targetschembl-database-bioactivity— bioactivity and binding data for the drugs annotated in ClinPGxcosmic-database— somatic cancer mutations and tumor-specific PGx (orthogonal to germline PGx covered here)
References
- ClinPGx (formerly PharmGKB) website — Database front end; web links match the
data/{type}/{paId}URL shape - ClinPGx REST API documentation — Official endpoint reference,
data/...andsite/searchschemas - CPIC API documentation — Swagger UI for the PostgREST companion API
- CPIC guidelines — Source of
recommendationrows; canonical genotype-prescribing oracle - Relling & Klein (2011) Nature Reviews Drug Discovery — PharmGKB foundational paper
- Whirl-Carrillo et al. (2021) Clin Pharmacol Ther — PharmGKB data architecture update
skills/genomics-bioinformatics/databases/mouse-phenome-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill mouse-phenome-database -g -y
SKILL.md
Frontmatter
{
"name": "mouse-phenome-database",
"license": "CC-BY-4.0",
"description": "Retrieve mouse phenotype data from the Jackson Laboratory Mouse Phenome Database (MPD) via its REST API. Browse 520+ projects, look up per-project measure metadata, pull strain-level means (raw or LS-mean adjusted) and per-animal values, find measures by MP\/VT ontology terms, and resolve strain nomenclature or gene coordinates. Use for QTL support, cross-strain comparison, mouse model selection, and ontology-driven phenotype discovery. Use monarch-database for disease-gene-phenotype knowledge graphs; ensembl-database for mouse genome annotations."
}
mouse-phenome-database
Overview
The Mouse Phenome Database (MPD), maintained at the Jackson Laboratory, catalogs standardized phenotype measurements across inbred, recombinant inbred (e.g., BXD), and Collaborative Cross / Diversity Outbred mouse panels. It aggregates 520+ projects spanning metabolic, cardiovascular, behavioral, hematological, and immunological traits. The REST API at https://phenome.jax.org/api is free, requires no authentication, and is documented at https://phenome.jax.org/about/api. MPD measurement IDs (measnum) are project-scoped 5-digit integers — there is no global "measnum 10001 = body weight" mapping; valid measnums must be discovered per project via the measureinfo endpoint.
When to Use
- Selecting inbred strains with extreme phenotypes (highest/lowest fasted glucose, body weight, heart rate, etc.) as experimental models
- Pulling individual-animal data from BXD / CC / DO panels for QTL mapping with R/qtl2 or similar tools
- Comparing strain means and variance across metabolic, behavioral, or cardiovascular measures for genetic background studies
- Finding MPD projects that measure a trait of interest using ontology terms (MP, VT, MA) or free-text descriptions
- Validating mouse strain nomenclature (canonical JAX names ↔ stock numbers ↔ MGI IDs) before submitting orders or analyses
- Looking up coordinates and annotations for mouse genes in the MPD/MGI cross-reference
- Use
monarch-databaseinstead for disease-gene-phenotype knowledge graphs (HPO ↔ MP ↔ disease) - Use
ensembl-databaseinstead for transcript-level mouse gene annotations and variant consequence prediction
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: a project symbol (e.g.,
Jaxwest1,Auwerx1) or a measnum (e.g.,15101); strain names follow JAX canonical nomenclature (e.g.,C57BL/6J,DBA/2J) - Environment: internet connection; no API key required
- Rate limits: no published hard limit; keep bursts under ~5 requests/second and add
time.sleep(0.3)between requests in loops
pip install requests pandas matplotlib
Quick Start
import requests
MPD = "https://phenome.jax.org/api"
# 1) Pick a project (Jaxwest1 — cardiovascular phenotyping on inbred panel)
r = requests.get(f"{MPD}/projects/Jaxwest1/strains", timeout=30)
strains = r.json()["strains"]
print(f"Jaxwest1: {len(strains)} strains tested")
# 2) Discover its measures
r = requests.get(f"{MPD}/pheno/measureinfo/Jaxwest1", timeout=30)
measures = r.json()["measures_info"]
print(f"Jaxwest1 measures: {len(measures)}; first: measnum={measures[0]['measnum']} "
f"varname={measures[0]['varname']} ({measures[0]['descrip']}, {measures[0]['units']})")
# 3) Pull strain means for heart rate (varname=HR, measnum=15101)
r = requests.get(f"{MPD}/pheno/strainmeans/15101", timeout=30)
sm = r.json()["strainmeans"]
print(f"\nHeart rate strain means: {len(sm)} rows (one per strain × sex)")
top = sorted(sm, key=lambda x: x["mean"], reverse=True)[:5]
for s in top:
print(f" {s['strain']:<20} sex={s['sex']} mean={s['mean']:.0f} {s.get('varname','')} n={s['nmice']}")
Core API
Module 1: Browse Projects — /projects
Lists all MPD projects with full metadata. Filter via investigator, projsym, projid, mpdsector, largecollab, panelsym. Use /project_filters/{filtername} to see the allowed values of mpdsector, largecollab, or panelsym before filtering.
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# List allowed panel symbols (e.g., BXD, CC, DO)
filters = requests.get(f"{MPD}/project_filters/panelsym", timeout=30).json()
print(f"Available panels ({filters['count']}):", [t['term'] for t in filters['terms']][:10])
# All projects in the BXD recombinant inbred panel
r = requests.get(f"{MPD}/projects", params={"panelsym": "BXD"}, timeout=30)
projects = r.json()["projects"]
print(f"BXD projects: {len(projects)}")
df = pd.DataFrame([{
"projsym": p["projsym"],
"pi": p.get("pistring", "")[:40],
"nstrains": p.get("nstrains"),
"ages": p.get("ages"),
"sector": p.get("mpdsector"),
"title": (p.get("title") or "")[:60],
} for p in projects])
print(df.head(10).to_string(index=False))
# Filter by MPD sector — komp, pheno, qtla, snp, onestrain, phenoarchive
r = requests.get(f"{MPD}/projects", params={"mpdsector": "qtla"}, timeout=30)
qtl_projects = r.json()["projects"]
print(f"QTL-archive projects: {len(qtl_projects)}")
for p in qtl_projects[:5]:
print(f" {p['projsym']:<15} panel={p.get('panelsym') or '--':<6} nstrains={str(p.get('nstrains') or '--'):>4} {(p.get('title') or '')[:55]}")
Module 2: Project Detail — /projects/{projsym}/...
Each project has sub-resources for its dataset (CSV of every animal × every measure), the strain panel it tested, the publications it produced, and (for QTL projects) the genetic markers used.
import requests, io, pandas as pd
MPD = "https://phenome.jax.org/api"
# Full per-animal dataset as CSV (default). Use json=yes for JSON.
r = requests.get(f"{MPD}/projects/Jaxwest1/dataset", timeout=60)
df = pd.read_csv(io.StringIO(r.text))
print(f"Jaxwest1 dataset: {df.shape[0]} animals × {df.shape[1]} columns")
print(df.columns[:12].tolist())
print(df[["strain", "sex", "animal_id", "HR", "QRS", "bw"]].head(5).to_string(index=False))
# Strains tested in a project + publication list
strains = requests.get(f"{MPD}/projects/Jaxwest1/strains", timeout=30).json()
print(f"Jaxwest1 strains ({strains['count']}):")
for s in strains["strains"][:5]:
print(f" {s['strainname']:<20} stock={s['stocknum']} vendor={s['vendor']}")
pubs = requests.get(f"{MPD}/projects/Jaxwest1/publications", timeout=30).json()
print(f"\nPublications: {pubs['count']}")
Module 3: Measure Discovery — /pheno/measureinfo/{selector}
This is the canonical way to discover valid measnum values. The selector is either a project symbol (returns all measures in that project) or a measnum (returns metadata for one measure).
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# All measures in the Jaxwest1 cardiovascular project
r = requests.get(f"{MPD}/pheno/measureinfo/Jaxwest1", timeout=30)
measures = r.json()["measures_info"]
df = pd.DataFrame([{
"measnum": m["measnum"],
"varname": m["varname"],
"descrip": m["descrip"],
"units": m.get("units"),
"sex": m.get("sextested"),
"age": m.get("ageweeks"),
} for m in measures])
print(f"Jaxwest1 has {len(df)} measures")
print(df.head(10).to_string(index=False))
# Single-measure metadata lookup (protocol + dimensional details)
r = requests.get(f"{MPD}/pheno/measureinfo/15101", timeout=30).json()
m = r["measures_info"][0]
print(f"measnum {m['measnum']} ({m['varname']}): {m['descrip']}")
print(f" units: {m.get('units')}")
print(f" project: {m.get('projsym')} panel: {m.get('panelsym') or m.get('paneldesc')}")
print(f" sex tested: {m.get('sextested')} age: {m.get('ageweeks')}")
print(f" method: {(m.get('method') or '')[:120]}")
Module 4: Strain Means — /pheno/strainmeans/{selector}
Returns strain × sex summary statistics. The selector takes a project symbol (all strain means for the project) or one-or-more comma-separated measnums. Each row contains measnum, varname, strain, strainid, sex, mean, sd, sem, cv, minval, maxval, nmice, zscore.
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# Strain means for one measure (heart rate, measnum=15101 from Jaxwest1)
r = requests.get(f"{MPD}/pheno/strainmeans/15101", timeout=30)
sm = pd.DataFrame(r.json()["strainmeans"])
print(f"Rows: {len(sm)} ({sm['strain'].nunique()} strains × {sm['sex'].nunique()} sexes)")
# Rank strains by male HR
male = sm[sm["sex"] == "m"].sort_values("mean", ascending=False)
print(male[["strain", "mean", "sd", "sem", "nmice", "zscore"]].head(8).to_string(index=False))
# Optional: model-adjusted means (lsmeans) account for covariates fit in MPD's models.
# Use lsmeans when comparing strains across cohorts within a project.
r = requests.get(f"{MPD}/pheno/lsmeans/Jaxwest1", timeout=30).json()
print("LS-mean measures available for Jaxwest1:", r.get("ls_measures", [])[:10])
Module 5: Per-Animal Values — /pheno/animalvals/{measnum}
Raw per-animal observations for one measure. Each row carries animal_id, animal_projid, measnum, projsym, sex, stocknum, strain, strainid, value, varname, zscore. Use this for QTL mapping, mixed-effects modeling, or distribution analysis.
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# Per-animal heart rate values
r = requests.get(f"{MPD}/pheno/animalvals/15101", timeout=30)
ad = pd.DataFrame(r.json()["animaldata"])
print(f"Animals measured: {len(ad)}")
print(ad[["animal_id", "strain", "sex", "value", "zscore"]].head(8).to_string(index=False))
# Compare male-only strain distributions
male = ad[ad["sex"] == "m"]
stats = male.groupby("strain")["value"].agg(["mean", "std", "count"]).round(2)
print(f"\nMale HR by strain (top 5 by mean):")
print(stats.sort_values("mean", ascending=False).head(5))
Module 6: Ontology-Based Measure Discovery — /pheno/measures_by_ontology/{ont_term}
Find every MPD measure annotated to a Mammalian Phenotype (MP), Vertebrate Trait (VT), or Mouse Anatomy (MA) ontology term. Optional this_term_only=yes disables descendant-term expansion; omit_baseline=yes filters out baseline measures; collapse_series=yes collapses repeated time-points.
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# MP:0001262 = "decreased body weight"
r = requests.get(f"{MPD}/pheno/measures_by_ontology/MP:0001262",
params={"omit_baseline": "yes", "collapse_series": "yes"},
timeout=30).json()
print(f"Measures mapped to MP:0001262 ('{r['ontology_terms'][0]['descrip']}'): {r['count']}")
if r["count"]:
df = pd.DataFrame(r["measures"])
print(df[["measnum", "varname", "descrip", "projsym"]].head(8).to_string(index=False))
else:
print("(no direct measures; consider a broader parent term)")
Module 7: Strain Nomenclature — /straininfo
Validate and normalise strain names. Accepts name, stocknum, or mginum query params. Returns both jaxinfo[] (JAX availability/nomenclature) and mpdinfo[] (MPD's own metadata, including how many projects test this strain).
import requests
MPD = "https://phenome.jax.org/api"
# Validate C57BL/6J — note `requests` URL-encodes the slash automatically in params
r = requests.get(f"{MPD}/straininfo", params={"name": "C57BL/6J"}, timeout=30).json()
jax = r["jaxinfo"][0]
mpd = r["mpdinfo"][0]
print(f"JAX: {jax['nomenclature']} stock={jax['stocknum']} status={jax['avl_status']}")
print(f"MPD: longname={mpd['longname']} type={mpd['straintype']} "
f"projects={mpd['nproj']} snp_projects={mpd['nsnpproj']} MGI={mpd['mginum']}")
Module 8: Gene Info — /geneinfo/{symbol}
Mouse-gene coordinates (GRCm39, in bp), strand, MGI ID, and a short description. Note the response key is the literal string "gene info" (with a space).
import requests
MPD = "https://phenome.jax.org/api"
r = requests.get(f"{MPD}/geneinfo/Lep", timeout=30).json()
for g in r["gene info"]:
print(f"{g['descrip']} chr{g['chrom']}:{g['startbp']:,}–{g['endbp']:,} ({g['strand']})")
print(f" type: {g['featuretype']} MGI: {g['mginum']} cM: {g['centimorgan']}")
Key Concepts
Measnums Are Project-Scoped, Not Global
Every measnum belongs to exactly one project. 15101 is "heart rate" only within Jaxwest1; the same physiological trait in another project has a different measnum (e.g., 35702 for body weight in Lightfoot1). Never hardcode measnums for a trait — always resolve them by:
- Finding the project:
GET /projects?panelsym=...orGET /projects?investigator=... - Listing its measures:
GET /pheno/measureinfo/{projsym} - Picking the right
varname/descrip/unitstriple from the response
Or go the other way and discover measures by ontology term first (Module 6).
Selectors: Projsym vs Measnum
Most /pheno/* endpoints take a "selector" path parameter that's overloaded:
| Endpoint | Accepts as selector |
|---|---|
/pheno/strainmeans/{selector} |
projsym (e.g., Jaxwest1) or one or more comma-separated measnums |
/pheno/lsmeans/{selector} |
same |
/pheno/measureinfo/{selector} |
same |
/pheno/animalvals/{measnum} |
measnum only (use measureinfo to discover) |
/pheno/animalvals/series/{measnum} |
for timecourse/dose-response series |
If you pass an unrecognised selector you get a 400 JSON response ({"error": "...selector arg must either be measure IDs or a project symbol"}), not an HTML 404 — those are diagnostic and worth surfacing.
Strain Means vs LS-Means
strainmeansare unadjusted: simple per-strain × per-sex arithmetic means of the raw animal values.lsmeansare model-adjusted least-squares means from MPD's pre-fit ANOVA-style models (accounting for cohort, batch, or covariate effects when present).
For cross-project comparisons or analyses sensitive to batch effects, prefer lsmeans when available. For simple ranking and exploratory work, strainmeans is fine.
MPD Sectors (mpdsector filter)
| Sector | Content |
|---|---|
pheno |
Standard inbred-strain phenotyping projects |
qtla |
QTL Archive — historical mapping studies with markers |
komp |
Knockout Mouse Project (KOMP / JaxLIMS) data |
snp |
SNP genotype panels (use /snpdata) |
phenoarchive |
Archived legacy phenotype projects |
onestrain |
Single-strain deep phenotyping |
Discover the live list any time with GET /project_filters/mpdsector.
Common Workflows
Workflow 1: Pick a Trait → Find a Project → Plot Strain Means
Goal: From "I want to compare heart rate across inbred strains" → land on real data and produce a ranked barplot.
import requests, pandas as pd, matplotlib.pyplot as plt, time
MPD = "https://phenome.jax.org/api"
# 1) Find candidate projects whose name/description hints at the trait
projects = requests.get(f"{MPD}/projects", timeout=30).json()["projects"]
candidates = [p for p in projects
if any(kw in (p.get("title") or "").lower()
for kw in ["cardiovascular", "heart", "ekg", "ecg"])]
print(f"Candidate cardiovascular projects: {len(candidates)}")
for p in candidates[:5]:
print(f" {p['projsym']:<15} nstrains={str(p.get('nstrains') or '--'):>3} {(p.get('title') or '')[:60]}")
# 2) Inspect measures for the chosen project
projsym = "Jaxwest1"
mi = requests.get(f"{MPD}/pheno/measureinfo/{projsym}", timeout=30).json()["measures_info"]
hr = next(m for m in mi if m["varname"] == "HR")
print(f"\nPicked: {projsym} measnum={hr['measnum']} varname={hr['varname']} ({hr['descrip']}, {hr['units']})")
# 3) Pull strain means, plot male strains ranked
sm = pd.DataFrame(requests.get(f"{MPD}/pheno/strainmeans/{hr['measnum']}", timeout=30).json()["strainmeans"])
male = sm[sm["sex"] == "m"].sort_values("mean", ascending=False).reset_index(drop=True)
fig, ax = plt.subplots(figsize=(9, 4))
bars = ax.bar(male["strain"], male["mean"], yerr=male["sem"],
color="#1976D2", capsize=3, edgecolor="white")
ax.bar_label(bars, fmt="%.0f", padding=3, fontsize=8)
ax.set_xlabel("Strain")
ax.set_ylabel(f"Mean {hr['varname']} ({hr['units']})")
ax.set_title(f"{hr['descrip']} by inbred strain (male) — {projsym}")
plt.xticks(rotation=30, ha="right")
plt.tight_layout()
plt.savefig("mpd_strain_means.png", dpi=150, bbox_inches="tight")
print("Saved mpd_strain_means.png")
Workflow 2: Per-Animal Data → R/qtl2 CSV Export
Goal: Pull individual animal observations for a measure and shape them into a phenotype file ready for QTL mapping.
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
measnum = 15101 # heart rate in Jaxwest1
mi = requests.get(f"{MPD}/pheno/measureinfo/{measnum}", timeout=30).json()["measures_info"][0]
ad = pd.DataFrame(requests.get(f"{MPD}/pheno/animalvals/{measnum}", timeout=30).json()["animaldata"])
print(f"measnum {measnum}: {mi['varname']} ({mi['descrip']}, {mi['units']}) — {len(ad)} animals")
# R/qtl2-ready phenotype CSV: rows = individuals, cols = id + covariates + phenotype
out = ad[["animal_id", "strain", "sex", "value"]].rename(
columns={"animal_id": "id", "value": mi["varname"]}
)
out.to_csv(f"{measnum}_{mi['varname']}_qtl_pheno.csv", index=False)
print(f"Wrote {measnum}_{mi['varname']}_qtl_pheno.csv ({len(out)} animals)")
print(out.head().to_string(index=False))
Workflow 3: Multi-Measure Heatmap Across a Strain Panel
Goal: Build a wide-format strain × measure table (z-scored) from one project for comparative visualisation.
import requests, pandas as pd, matplotlib.pyplot as plt
MPD = "https://phenome.jax.org/api"
projsym = "Jaxwest1"
# Pull all strain means for the project in one call
sm = pd.DataFrame(requests.get(f"{MPD}/pheno/strainmeans/{projsym}", timeout=30).json()["strainmeans"])
# Use the precomputed z-scores; pivot to strain × varname (male only for simplicity)
male = sm[sm["sex"] == "m"]
wide = male.pivot_table(index="strain", columns="varname", values="zscore", aggfunc="mean")
print(f"Shape: {wide.shape} (strains × measures)")
# Subset to a handful of measures with full coverage
keep = wide.dropna(axis=1, thresh=int(0.8 * len(wide))).columns[:10]
wide = wide[keep].dropna()
print(f"After coverage filter: {wide.shape}")
fig, ax = plt.subplots(figsize=(8, max(3, 0.3 * len(wide))))
im = ax.imshow(wide.values, aspect="auto", cmap="RdBu_r", vmin=-2, vmax=2)
ax.set_xticks(range(len(wide.columns)))
ax.set_xticklabels(wide.columns, rotation=45, ha="right", fontsize=8)
ax.set_yticks(range(len(wide.index)))
ax.set_yticklabels(wide.index, fontsize=8)
fig.colorbar(im, ax=ax, label="z-score")
ax.set_title(f"{projsym} — strain × measure z-score heatmap (male)")
plt.tight_layout()
plt.savefig("mpd_strain_measure_heatmap.png", dpi=150, bbox_inches="tight")
print("Saved mpd_strain_measure_heatmap.png")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
panelsym |
/projects |
— | strain panel symbol (BXD, CC, DO, …) |
Filter projects to one mouse panel |
mpdsector |
/projects |
— | pheno, qtla, komp, snp, phenoarchive, onestrain |
Filter projects by data sector |
investigator |
/projects, /investigators |
— | investigator name substring | Filter projects by PI |
csv |
/projects, /investigators, /projects/{projsym}/dataset, etc. |
no |
yes |
Return CSV instead of JSON |
json |
/projects/{projsym}/dataset |
— | yes |
Return JSON instead of default CSV |
this_term_only |
/pheno/measures_by_ontology/{ont_term} |
no |
yes |
Disable descendant-term expansion |
omit_baseline |
/pheno/measures_by_ontology/{ont_term} |
no |
yes |
Drop baseline measures from results |
collapse_series |
/pheno/measures_by_ontology/{ont_term} |
no |
yes |
Collapse timecourse/dose series into single entries |
region, dataset, strains |
/snpdata |
required | genomic region, dataset name, strain CSV | Pull SNP genotypes for region across strains |
name / stocknum / mginum |
/straininfo |
one required | strain name, JAX stock #, or MGI ID | Validate / look up strain |
Best Practices
-
Always discover measnums via
/pheno/measureinfo/{projsym}before querying data. Measnums are project-scoped 5-digit integers; there is no global trait → measnum table. Hardcoding measnums you got from elsewhere will silently 404 or return "no data". -
Use plural resource paths. MPD uses
/projects,/projects/{projsym}/strains,/projects/{projsym}/dataset— not singular. Old MPD documentation and several third-party wrappers list singular paths that return HTML 404s. -
Prefer the project-level dataset CSV for bulk analysis. When you want every animal × every measure for a project,
GET /projects/{projsym}/datasetreturns a single CSV in one request — much faster than loopinganimalvalsper measnum. -
Use
lsmeansinstead ofstrainmeanswhen MPD has fit a model. LS-means adjust for covariates (cohort, age, batch) baked into MPD's project-level statistical models. For comparative ranking across strains within a project, lsmeans is the more honest summary when available (/pheno/lsmeans/{projsym}returns the list ofls_measures). -
Validate strain names with
/straininfobefore assuming a match. MPD uses strict JAX canonical nomenclature; nearby synonyms (B6,C57Bl/6,C57BL/6) won't always resolve./straininfo?name=...returns both JAX and MPD records and tells you the canonical form. -
Be polite — add
time.sleep(0.3)in loops. MPD doesn't publish a hard rate limit, but the server runs on shared academic infrastructure. Keep bursts under ~5 req/s.
Common Recipes
Recipe: Find Projects Testing a Trait by Free-Text Search
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
def find_projects(keyword):
"""Search project titles for a keyword (case-insensitive)."""
projects = requests.get(f"{MPD}/projects", timeout=30).json()["projects"]
kw = keyword.lower()
hits = [p for p in projects if kw in (p.get("title") or "").lower()]
return pd.DataFrame([{
"projsym": p["projsym"],
"panel": p.get("panelsym"),
"nstrains": p.get("nstrains"),
"year": p.get("projyear"),
"title": (p.get("title") or "")[:80],
} for p in hits])
print(find_projects("glucose").head(10).to_string(index=False))
Recipe: Pull a Whole Project as a DataFrame in One Call
import requests, io, pandas as pd
MPD = "https://phenome.jax.org/api"
def load_project_dataset(projsym):
"""Fetch /projects/{projsym}/dataset CSV directly into a DataFrame."""
r = requests.get(f"{MPD}/projects/{projsym}/dataset", timeout=120)
r.raise_for_status()
return pd.read_csv(io.StringIO(r.text))
df = load_project_dataset("Jaxwest1")
print(f"Jaxwest1: {df.shape[0]} animals × {df.shape[1]} columns")
print("Numeric columns:", df.select_dtypes("number").columns.tolist()[:8])
Recipe: Cross-Strain Comparison from Strain Means
import requests, pandas as pd
MPD = "https://phenome.jax.org/api"
# Multiple measnums in one call (comma-separated selector)
selector = "15101,15102,15103" # HR, QRS, PR from Jaxwest1
sm = pd.DataFrame(requests.get(f"{MPD}/pheno/strainmeans/{selector}", timeout=30).json()["strainmeans"])
wide = (sm[sm["sex"] == "m"]
.pivot_table(index="strain", columns="varname", values="mean", aggfunc="mean")
.round(1))
print(wide.head(8).to_string())
Recipe: Resolve Gene → Coordinates → Adjacent Region
import requests
MPD = "https://phenome.jax.org/api"
def gene_window(symbol, flank_kb=100):
r = requests.get(f"{MPD}/geneinfo/{symbol}", timeout=30).json()
if not r.get("gene info"):
return None
g = r["gene info"][0]
return {
"symbol": symbol,
"chrom": g["chrom"],
"start": max(0, g["startbp"] - flank_kb * 1000),
"stop": g["endbp"] + flank_kb * 1000,
"mgi": g["mginum"],
"descrip": g["descrip"],
}
print(gene_window("Lep", flank_kb=50))
# {'symbol': 'Lep', 'chrom': '6', 'start': 29010220, 'stop': 29123877, 'mgi': 'MGI:104663', ...}
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
HTTP 404 with HTML body on /strain/..., /procedure, /pheno/query, /measurement/..., /project/... |
These paths don't exist — MPD's real API uses plural resource names and a different layout | Use /projects (plural), /projects/{projsym}/dataset, /pheno/strainmeans/{selector}, /pheno/measureinfo/{selector}, /straininfo |
HTTP 400 with {"error": "...selector arg must either be measure IDs or a project symbol"} |
The path's selector arg got something else (a strain name, a varname, a category) | Resolve the right projsym or measnum first via /projects or /pheno/measureinfo/{projsym} |
HTTP 404 with {"error": "No strainmeans data found for {selector}"} |
The selector is the right kind but has no data (e.g., a measnum from a different project, or a typo) | Confirm the measnum exists via /pheno/measureinfo/{measnum}; check it belongs to the project you think |
KeyError: 'gene info' when parsing /geneinfo/{sym} |
Response key has a literal space: "gene info", not gene_info |
Access via r.json()["gene info"] exactly |
dataset endpoint returns plain text instead of JSON |
Default content type is CSV | Pass params={"json": "yes"} to force JSON; or parse the CSV with pd.read_csv(io.StringIO(r.text)) |
Strain name returns empty mpdinfo from /straininfo |
Non-canonical name (e.g., B6, C57Bl/6) |
Use exact JAX nomenclature (C57BL/6J); try stocknum= lookup if you have the JAX stock number |
/pheno/measures_by_ontology/{term} returns count: 0 but the term exists |
No direct mappings; the term is too specific | Re-query with the term's parent (the response includes ontology_terms[].parent); or drop this_term_only=yes |
HTTP 5xx intermittently on large CSV pulls |
MPD's per-project datasets can be tens of MB | Increase timeout=120; for very large projects use csv=yes + stream with requests.get(..., stream=True) |
Related Skills
monarch-database— disease-gene-phenotype knowledge graph with HPO ↔ MP cross-mappings; complement MPD's mouse-only data with human disease linksensembl-database— mouse genome annotation (GRCm39 coordinates, transcripts, VEP) — pairs with MPD/geneinfofor fine-grained gene-model detailsgwas-database— human GWAS Catalog SNP-trait associations; conceptual analogue of MPD's QTL projects for human populationsclinvar-database— clinical variant interpretation; relevant when mapping a mouse QTL to a human disease gene
References
- Mouse Phenome Database portal — interactive dataset browser and download UI
- MPD REST API documentation — authoritative endpoint reference (the one used to build this skill)
- Bogue et al., Mammalian Genome 2020 — MPD overview paper (architecture, data content, strain coverage)
- Jackson Laboratory Inbred Strain Catalog — canonical strain nomenclature and stock numbers
- Mammalian Phenotype Ontology — MP term browser (used by
/pheno/measures_by_ontology/{term}) - MPD Data Use Policy — CC-BY-4.0 license terms and citation requirements
skills/medical-imaging/imaging-data-commons/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill imaging-data-commons -g -y
SKILL.md
Frontmatter
{
"name": "imaging-data-commons",
"license": "MIT",
"description": "Query and download NCI Imaging Data Commons (IDC) cancer radiology and pathology datasets via the idc-index Python client. No authentication required: the parquet index ships inside the pip wheel, SQL runs locally via DuckDB, and DICOM downloads stream from public S3\/GCS buckets through s5cmd. Use sql_query() for DuckDB cohort selection, get_collections\/get_patients\/get_dicom_studies\/get_dicom_series for hierarchical browsing, download_from_selection() for downloads, and get_viewer_URL() for OHIF\/Slim links. Use pydicom-medical-imaging for local DICOM reading; histolab for whole-slide pathology preprocessing."
}
NCI Imaging Data Commons (idc-index)
Overview
NCI Imaging Data Commons (IDC) is the largest public collection of cancer imaging data, hosting 175+ DICOM collections (CT, MR, PET, slide microscopy, segmentations, structured reports). The idc-index Python client ships the entire IDC metadata catalog as a parquet file bundled inside the pip wheel; IDCClient() loads it into DuckDB, so sql_query() runs locally with zero network calls.
Image downloads stream from public AWS S3 (default) or Google Cloud Storage buckets via the bundled s5cmd executable. No GCP/AWS credentials, no BigQuery billing, no service account JSON.
When to Use
- Searching publicly available cancer imaging datasets by modality, cancer type, anatomical site, or DICOM tag
- Building reproducible ML cohorts (segmentation, classification, multimodal) from versioned IDC releases
- Querying DICOM metadata at scale using SQL across all 175+ collections without any downloads
- Downloading specific DICOM series for local processing or model training
- Generating OHIF/Slim viewer URLs to share or inspect series interactively in a browser
- Use pydicom-medical-imaging instead when you only need to read, edit, or anonymize DICOM files that you already have locally
- For whole-slide pathology preprocessing (tiling, stain normalization) after download, use histolab instead
Prerequisites
- Python packages:
idc-index(>=0.12),pandas,pydicom(for reading DICOM files after download) - Data requirements: none for querying. For downloads, free disk space matching
series_size_MB - Environment: no authentication required. All data is publicly accessible. The wheel bundles both the parquet index and the
s5cmdexecutable used for high-speed S3 transfers - Rate limits: none for local SQL queries (DuckDB on local parquet). Bulk downloads are limited by network bandwidth, not by API quotas
# Skip when already provisioned in a pixi or conda env
pip install idc-index pydicom
Quick Start
from idc_index import IDCClient
client = IDCClient()
print('IDC version:', client.get_idc_version())
print('Collections:', len(client.get_collections()))
print('First 5:', client.get_collections()[:5])
df = client.sql_query("""
SELECT collection_id, COUNT(DISTINCT SeriesInstanceUID) AS n_series
FROM index
WHERE Modality = 'CT'
GROUP BY collection_id
ORDER BY n_series DESC LIMIT 5
""")
print(df)
Core API
Module 1: Client Initialization and Collections
IDCClient() is the single entry point. It loads the parquet index, registers it as the DuckDB table named index, and validates the bundled s5cmd. get_collections() returns a plain Python list of lowercase collection IDs such as nsclc_radiomics, lidc_idri, and tcga_gbm.
from idc_index import IDCClient
client = IDCClient()
collections = client.get_collections()
print("total:", len(collections), "type:", type(collections).__name__)
print("lung-related:", [c for c in collections if "lung" in c or "nsclc" in c][:5])
Module 2: sql_query (DuckDB Over the Local Index)
The recommended cohort-selection API. The table name is index; additional tables (prior_versions_index and optional sm_index, clinical_index) are auto-registered when installed. Returns a pandas DataFrame. Use this for any filter involving Modality, BodyPartExamined, series_size_MB, or arbitrary DICOM tags. The legacy get_series(collection_id=..., modality=...) signature no longer exists in idc-index.
# Cohort: small CT series in NSCLC Radiomics, sorted by size for cheap testing
df = client.sql_query("""
SELECT SeriesInstanceUID, StudyInstanceUID, PatientID, Modality, series_size_MB
FROM index
WHERE collection_id = 'nsclc_radiomics'
AND Modality = 'CT'
ORDER BY series_size_MB ASC
LIMIT 5
""")
print(df[["PatientID", "Modality", "series_size_MB"]])
# Cross-collection lung CT count, using DuckDB ILIKE for case-insensitive matching
df = client.sql_query("""
SELECT collection_id, COUNT(DISTINCT SeriesInstanceUID) AS n
FROM index
WHERE Modality = 'CT' AND BodyPartExamined ILIKE '%LUNG%'
GROUP BY collection_id
ORDER BY n DESC LIMIT 5
""")
print(df)
Module 3: Hierarchical Browsing (Patients, Studies, Series)
For DICOM-hierarchy navigation (Collection -> Patient -> Study -> Series), use the typed helpers. Each accepts an outputFormat of dict (default), df, or list. Note: get_dicom_series() takes a studyInstanceUID (not collection_id). Use sql_query() if you want to filter series by collection or modality.
patients = client.get_patients('nsclc_radiomics', outputFormat='df')
print("patients:", patients.shape, list(patients.columns)[:5])
# Walk down the hierarchy from one patient
pid = patients["PatientID"].iloc[0]
studies = client.get_dicom_studies(pid, outputFormat='df')
print("studies for", pid, ":", studies.shape)
study_uid = studies["StudyInstanceUID"].iloc[0]
series = client.get_dicom_series(study_uid, outputFormat='df')
print("series in study:", series.shape, "modalities:", series["Modality"].unique().tolist())
Module 4: download_from_selection (Modern Download Path)
The preferred download method. Accepts any combination of collection_id, patientId, studyInstanceUID, seriesInstanceUID, sopInstanceUID, or crdc_series_uuid (each a string or list). Filters apply in sequence. Use dry_run=True to size the cohort before pulling bytes. Files land under dirTemplate (default: %collection_id/%PatientID/%StudyInstanceUID/%Modality_%SeriesInstanceUID).
import tempfile, glob, os
# Pick the smallest CT series for a fast smoke test (about 40 MB)
df = client.sql_query("""
SELECT SeriesInstanceUID FROM index
WHERE collection_id = 'nsclc_radiomics' AND Modality = 'CT'
ORDER BY series_size_MB ASC LIMIT 1
""")
uid = df["SeriesInstanceUID"].iloc[0]
out = tempfile.mkdtemp(prefix='idc_dl_')
client.download_from_selection(
downloadDir=out,
seriesInstanceUID=[uid],
quiet=True,
show_progress_bar=False,
source_bucket_location='aws', # 'aws' (default) or 'gcs'
)
files = glob.glob(os.path.join(out, "**/*.dcm"), recursive=True)
print(f"downloaded {len(files)} DICOM files to {out}")
# Size a cohort before downloading
client.download_from_selection(
downloadDir='./preview',
collection_id='nsclc_radiomics',
dry_run=True,
)
Module 5: download_dicom_series (Convenience Wrapper)
Single-series download. Internally calls download_from_selection(seriesInstanceUID=...). Accepts a string or list of UIDs.
import tempfile, glob, os
out = tempfile.mkdtemp(prefix='idc_dl_')
client.download_dicom_series(
seriesInstanceUID=uid, # string or list
downloadDir=out,
quiet=True,
show_progress_bar=False,
)
print("files:", len(glob.glob(os.path.join(out, "**/*.dcm"), recursive=True)))
Module 6: get_viewer_URL (Browser Visualization)
Returns a shareable URL to the IDC OHIF (radiology) or Slim (slide microscopy) viewer. Auto-selects the viewer based on modality if viewer_selector is omitted.
url = client.get_viewer_URL(seriesInstanceUID=uid)
print("Open in browser:", url)
# Force a specific viewer
url_v2 = client.get_viewer_URL(seriesInstanceUID=uid, viewer_selector='ohif_v2')
print(url_v2)
Module 7: Inspecting Downloaded DICOM with pydicom
After download, files are organized under dirTemplate. Use pydicom for header and pixel inspection.
import pydicom, glob, os
dcm_files = sorted(glob.glob(os.path.join(out, "**/*.dcm"), recursive=True))
ds = pydicom.dcmread(dcm_files[0])
print("PatientID:", ds.PatientID)
print("Modality :", ds.Modality)
print("Rows x Cols:", ds.Rows, "x", ds.Columns)
print("Pixel array:", ds.pixel_array.shape)
Key Concepts
Local-First, Auth-Free Architecture
The IDC client is unusual: client = IDCClient() is fully offline for queries. The parquet index (hundreds of MB, ships in idc-index-data) is read into memory and registered as the DuckDB table named index. Every sql_query(), get_collections(), get_patients(), etc. is a local pandas/DuckDB operation. Network traffic only happens during download_from_selection() / download_dicom_series(), which shell out to the bundled s5cmd executable to copy from public buckets (s3://idc-open-data/... by default; switch to gcs via source_bucket_location=gcs). Consequently: no gcloud auth, no GCP project ID, no BigQuery billing, no API keys.
DICOM Hierarchy and Identifiers
IDC follows the standard DICOM model: Collection (collection_id, lowercase with underscores like nsclc_radiomics) -> Patient (PatientID) -> Study (StudyInstanceUID) -> Series (SeriesInstanceUID) -> Instance (SOPInstanceUID). Downloads operate at the series level by default. The crdc_series_uuid is an alternative immutable identifier preferred for long-term references.
Versioning
The index is pinned to the installed idc-index-data package version (client.get_idc_version(), e.g., v24). For reproducibility, pin both idc-index and idc-index-data in your environment. Older releases remain accessible via the prior_versions_index table inside sql_query().
Common Workflows
Workflow 1: Cohort Selection -> Manifest -> Download
Goal: Build a CSV manifest of small CT series for ML prototyping, then download them.
from idc_index import IDCClient
import pandas as pd, tempfile, os, glob
client = IDCClient()
# Step 1: SQL cohort: small CTs across three lung collections
cohort = client.sql_query("""
SELECT SeriesInstanceUID, PatientID, collection_id, Modality, series_size_MB
FROM index
WHERE collection_id IN ('nsclc_radiomics', 'tcga_luad', 'tcga_lusc')
AND Modality = 'CT'
AND series_size_MB < 60
ORDER BY series_size_MB ASC
""")
print(f"Cohort: {len(cohort)} series, total {cohort['series_size_MB'].sum():.1f} MB")
# Step 2: Save manifest
cohort.to_csv('ct_lung_manifest.csv', index=False)
# Step 3: Download a small subset
out = tempfile.mkdtemp(prefix='idc_cohort_')
client.download_from_selection(
downloadDir=out,
seriesInstanceUID=cohort["SeriesInstanceUID"].head(2).tolist(),
quiet=True,
show_progress_bar=False,
)
print("downloaded", len(glob.glob(os.path.join(out, "**/*.dcm"), recursive=True)), "files")
Workflow 2: Hierarchical Inspection of One Patient
Goal: Drill from collection -> patient -> studies -> series -> DICOM headers without writing SQL.
from idc_index import IDCClient
import tempfile, glob, os, pydicom
client = IDCClient()
# Pick first patient in the collection
patients = client.get_patients('nsclc_radiomics', outputFormat='df')
pid = patients["PatientID"].iloc[0]
studies = client.get_dicom_studies(pid, outputFormat='df')
series_df = client.get_dicom_series(studies["StudyInstanceUID"].iloc[0], outputFormat='df')
print(f"Patient {pid}: {len(studies)} studies, {len(series_df)} series in first study")
print("modalities:", series_df["Modality"].unique().tolist())
# Pick the smallest image series (CT/MR, not SR/SEG) and download
imaging = series_df[series_df["Modality"].isin(["CT", "MR", "PT"])]
target_uid = imaging.sort_values("series_size_MB").iloc[0]["SeriesInstanceUID"]
out = tempfile.mkdtemp(prefix='idc_one_')
client.download_dicom_series(seriesInstanceUID=target_uid, downloadDir=out,
quiet=True, show_progress_bar=False)
ds = pydicom.dcmread(sorted(glob.glob(os.path.join(out, "**/*.dcm"), recursive=True))[0])
print("first slice:", ds.Modality, ds.pixel_array.shape)
print("viewer:", client.get_viewer_URL(seriesInstanceUID=target_uid))
Key Parameters
| Parameter | Module / Function | Default | Range / Options | Effect |
|---|---|---|---|---|
| outputFormat | get_patients / get_dicom_studies / get_dicom_series | dict | dict, df, list | Return type of accessor methods |
| seriesInstanceUID | download_from_selection / download_dicom_series | None | str or list[str] | Filter by DICOM SeriesInstanceUID |
| collection_id | download_from_selection / get_patients | None / required | lowercase string(s) like nsclc_radiomics | Filter by collection (lowercase, underscored) |
| source_bucket_location | download_from_selection / download_dicom_series | aws | aws, gcs | Public bucket to pull from |
| dirTemplate | download_from_selection | %collection_id/%PatientID/%StudyInstanceUID/%Modality_%SeriesInstanceUID | template string with %-prefixed DICOM tags, or None for flat layout | On-disk folder hierarchy |
| dry_run | download_from_selection / download_dicom_series | False | True/False | Compute cohort size without downloading |
| use_s5cmd_sync | download_from_selection / download_dicom_series | False | True/False | Use s5cmd sync (resume partial) instead of cp |
| viewer_selector | get_viewer_URL | None (auto) | ohif_v2, ohif_v3, slim | Force a specific browser viewer |
Best Practices
-
Use sql_query() for any filter beyond a single hierarchy step: It returns DataFrames, scales to the full ~10M-row index instantly via DuckDB, and supports arbitrary DICOM tags. Reach for get_patients / get_dicom_studies / get_dicom_series only when you already have the parent UID.
-
Always dry-run large cohorts: Calling download_from_selection(..., dry_run=True) prints the total size before any bytes move. A small collection like lidc_idri is ~120 GB, while nlst exceeds 10 TB.
-
Pin both idc-index and idc-index-data: The index version drives reproducibility. Record client.get_idc_version() (e.g., v24) in your dataset card or paper methods.
-
Prefer AWS for most downloads: source_bucket_location=aws is the default and is fastest from most academic networks. Switch to gcs only when running on GCP compute that is co-located with the bucket.
-
Do not try to use the legacy BigQuery flow as a default: bigquery-public-data.idc_current.dicom_all still works, but requires GCP auth and billing and is no longer the recommended path. See Recipes for one optional snippet.
Common Recipes
Recipe: Cross-Modal Patient Cohort (CT and MR for the Same Patient)
When to use: Build a multimodal study where each patient must have both modalities.
df = client.sql_query("""
SELECT PatientID
FROM index
WHERE collection_id = 'tcga_gbm'
GROUP BY PatientID
HAVING SUM(CASE WHEN Modality = 'CT' THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN Modality = 'MR' THEN 1 ELSE 0 END) > 0
""")
print(f"Patients with both CT and MR in TCGA-GBM: {len(df)}")
Recipe: Generate a Batch of Viewer URLs for QA
When to use: Send collaborators a shortlist of series to inspect visually.
sample = client.sql_query("""
SELECT SeriesInstanceUID, Modality
FROM index WHERE collection_id = 'lidc_idri' AND Modality = 'CT'
LIMIT 5
""")
for _, row in sample.iterrows():
print(row.Modality, client.get_viewer_URL(seriesInstanceUID=row.SeriesInstanceUID))
Recipe: Optional Advanced (BigQuery for Cross-Dataset Joins)
When to use: You need to join IDC metadata against another BigQuery public dataset (e.g., TCGA clinical) and have a GCP project with billing already configured. For all other use cases, prefer sql_query().
# Requires: a google-cloud-bigquery install and gcloud application-default credentials.
from google.cloud import bigquery
bq = bigquery.Client(project='your-gcp-project-id')
df = bq.query("""
SELECT collection_id, Modality, COUNT(DISTINCT SeriesInstanceUID) AS n
FROM `bigquery-public-data.idc_current.dicom_all`
WHERE Modality IN ('CT', 'MR', 'PET')
GROUP BY collection_id, Modality
ORDER BY n DESC LIMIT 20
""").to_dataframe()
print(df)
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
ImportError: No module named idc_index |
Package not installed | Run pip install idc-index (no auth setup needed) |
| IDCClient() slow on first call | Loading parquet index into memory (1-3 s, one-time per process) | Reuse the client across queries; do not re-instantiate per call |
| sql_query: Catalog Error: Table idc_index does not exist | Wrong table name | The DuckDB table is named index, not idc_index or dicom_all |
| get_dicom_series(collection_id=..., modality=...) raises TypeError | Outdated API; that signature was removed | Use sql_query with WHERE collection_id and Modality filters instead |
| download_from_selection errors with s5cmd: command not found | Bundled s5cmd not on PATH | Reinstall idc-index; the wheel bundles s5cmd. On NixOS/non-glibc systems, set IDC_INDEX_S5CMD_EXE to a system s5cmd |
| Downloads stall or are very slow | Network bottleneck, not auth | Try source_bucket_location=gcs if on GCP; otherwise use use_s5cmd_sync=True to resume |
| ValueError: collection_id ... does not exist | Collection ID is case-sensitive and underscored | Use lowercase with underscores: lidc_idri not LIDC-IDRI; check client.get_collections() |
| pydicom cannot read pixel data | Compressed transfer syntax | Install pylibjpeg, pylibjpeg-libjpeg, gdcm, then retry ds.pixel_array |
Related Skills
- pydicom-medical-imaging - Read, edit, and anonymize the DICOM files you downloaded from IDC
- histolab-wsi-processing - Tile and stain-normalize whole-slide images (IDC SM modality)
- pathml - End-to-end ML pipelines for computational pathology that consume IDC slide collections
References
- idc-index GitHub - Source, README, and issue tracker (authoritative API)
- idc-index on PyPI - Latest releases
- learn.canceridc.dev - Official IDC tutorials, notebooks, and conceptual guides
- IDC portal - Browser UI for exploring collections and viewer URLs
- IDC BigQuery dataset - Optional advanced/cross-dataset SQL (requires GCP billing)
skills/proteomics-protein-engineering/pride-database/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill pride-database -g -y
SKILL.md
Frontmatter
{
"name": "pride-database",
"license": "Apache-2.0",
"description": "Search the PRIDE Archive v3 REST API for proteomics datasets: discover projects by keyword + faceted filters (organism, instrument, disease, software), fetch project metadata, list and download RAW\/PEAK\/RESULT\/FASTA files (with FTP\/Aspera URLs), look up which projects mention a UniProt accession, and find similar projects. PRIDE v3 no longer exposes peptide\/PSM-level identification endpoints — for spectrum-level data download the project's RESULT files. Use uniprot-protein-database for protein sequences; interpro-database for domain architecture."
}
PRIDE Database
Overview
The PRIDE Archive (ProteomicsIDEntifications database) at EMBL-EBI is the world's largest public mass-spectrometry proteomics repository — 39,000+ projects and 3.4M+ deposited files as of 2026. Programmatic access is via a JSON REST API at https://www.ebi.ac.uk/pride/ws/archive/v3/. No authentication is required. The OpenAPI/Swagger spec is at https://www.ebi.ac.uk/pride/ws/archive/v3/v3/api-docs. PRIDE v3 returns plain JSON arrays for list endpoints (no HAL+JSON _embedded envelope) and intentionally does not expose per-peptide or per-PSM identification endpoints — for spectrum-level identifications, download the project's RESULT files (mzIdentML, MaxQuant txt, etc.) and parse them locally.
When to Use
- Finding published proteomics datasets by free-text keyword and facet filters (organism, tissue, disease, instrument, software, PTM) for meta-analysis or benchmarking
- Downloading raw mass-spectrometry data (RAW, mzML, MGF) or pre-processed identifications (RESULT files) from a specific PRIDE project accession
- Looking up which PRIDE projects mention a specific UniProt protein accession (project-level occurrence map only — no PSM/coverage counts at the API surface)
- Finding similar projects to one of interest for reanalysis or cross-study comparison
- Fetching SDRF (Sample-Data Relationship Format) files for projects so you can model the sample-to-MS-run mapping programmatically
- Discovering valid filter values via faceted search before constructing a structured query
- For protein sequences, Swiss-Prot annotations, and ID mapping use
uniprot-protein-database - For protein domain and family classification use
interpro-database— PRIDE only reports project-level occurrence, not domain-level features - PRIDE v3 has no
/peptides,/psms, or/proteins?proteinAccession=endpoints — if you need peptide- or PSM-level data, download the RESULT files from/projects/{accession}/filesand parse them withpyteomicsor a search-engine-specific reader
Prerequisites
- Python packages:
requests,pandas,matplotlib - Data requirements: a PRIDE project accession (
PXD######format) or a search keyword, optionally a UniProt accession for protein-occurrence lookup - Environment: internet connection; no API key required
- Rate limits: not formally published; keep bursts under ~5 requests/second and add
time.sleep(0.3)in loops
pip install requests pandas matplotlib
Quick Start
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
# 1) Free-text search for cancer proteomics projects
projects = requests.get(f"{PRIDE}/search/projects",
params={"keyword": "prostate cancer", "pageSize": 5},
timeout=30).json()
print(f"Top {len(projects)} projects:")
for p in projects[:3]:
instr = ", ".join(p.get("instruments", []))[:50]
print(f" {p['accession']} {(p['title'] or '')[:70]} [{instr}]")
# 2) Drill into one project
acc = projects[0]["accession"]
proj = requests.get(f"{PRIDE}/projects/{acc}", timeout=30).json()
print(f"\n{proj['accession']}: {proj['title'][:70]}")
print(f" Submitted: {proj.get('submissionDate')} DOI: {proj.get('doi')}")
print(f" Organisms: {[o['name'] for o in proj.get('organisms', [])]}")
print(f" Instruments: {[i['name'] for i in proj.get('instruments', [])]}")
# 3) List files and total size
files = requests.get(f"{PRIDE}/projects/{acc}/files/all", timeout=60).json()
total_mb = sum(f.get("fileSizeBytes", 0) for f in files) / 1e6
print(f"\n {len(files)} files, {total_mb:.0f} MB total")
Core API
Module 1: Project Search — /search/projects
Free-text search with optional facet-based filtering, pagination, and sorting. Returns a plain JSON array of project records — there is no HAL+JSON _embedded/page wrapper.
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def search_projects(keyword=None, organism=None, instrument=None,
disease=None, software=None,
page_size=25, page=0, sort_field="submission_date",
sort_direction="DESC"):
"""Search PRIDE v3 for projects.
Filter syntax (for the `filter` arg) is `field==value, field==value` using `_facet` field names
that are discoverable via /facet/projects."""
filters = []
if organism: filters.append(f"organisms_facet=={organism}")
if instrument: filters.append(f"instruments_facet=={instrument}")
if disease: filters.append(f"diseases_facet=={disease}")
if software: filters.append(f"softwares_facet=={software}")
params = {"pageSize": page_size, "page": page,
"sortFields": sort_field, "sortDirection": sort_direction}
if keyword: params["keyword"] = keyword
if filters: params["filter"] = ",".join(filters)
r = requests.get(f"{PRIDE}/search/projects", params=params, timeout=30)
r.raise_for_status()
return r.json() # plain list[dict]
projects = search_projects(keyword="cancer", organism="Homo sapiens (human)",
instrument="Q Exactive", page_size=5)
df = pd.DataFrame([{
"accession": p["accession"],
"title": (p.get("title") or "")[:70],
"submission_date": p.get("submissionDate"),
"diseases": ", ".join(p.get("diseases", []))[:60],
"instruments": ", ".join(p.get("instruments", []))[:50],
} for p in projects])
print(df.to_string(index=False))
# Paginate through all matches for a keyword. The API doesn't return total counts inline;
# walk pages until the next one is empty.
def search_all_projects(keyword, page_size=100, max_pages=20):
all_records, page = [], 0
while page < max_pages:
batch = search_projects(keyword=keyword, page_size=page_size, page=page)
if not batch:
break
all_records.extend(batch)
if len(batch) < page_size:
break # last page
page += 1
return all_records
results = search_all_projects("phosphoproteomics", page_size=100, max_pages=3)
print(f"Phosphoproteomics projects collected (max 300): {len(results)}")
Module 2: Faceted Filter Discovery — /facet/projects
Before constructing a filtered search, query the facet endpoint to see which instrument / organism / disease / software values actually exist for a given keyword, along with their counts. The response is a dict of facet groups, each mapping {value: count}.
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def get_facets(keyword=None, facet_page_size=20):
"""Return facet counts for projects matching `keyword`. Keys are facet groups
(instruments, organisms, diseases, softwares, experimentTypes, ...); values are
dicts of {value: count}."""
params = {"facetPageSize": facet_page_size}
if keyword: params["keyword"] = keyword
r = requests.get(f"{PRIDE}/facet/projects", params=params, timeout=30)
r.raise_for_status()
return r.json()
facets = get_facets(keyword="cancer", facet_page_size=10)
print(f"Facet groups: {list(facets.keys())}")
print(f"\nTop instruments for 'cancer':")
for instr, n in sorted(facets.get("instruments", {}).items(), key=lambda kv: -kv[1])[:8]:
print(f" {instr:<35} {n}")
print(f"\nTop diseases:")
for d, n in sorted(facets.get("diseases", {}).items(), key=lambda kv: -kv[1])[:6]:
print(f" {d:<55} {n}")
Module 3: Project Detail — /projects/{accession}
Full metadata for a single project: submitters, labPIs, instruments, organisms (CV-coded), diseases, experiment types, references, DOI, submission/publication dates. Lists are CvParam-style objects with accession, cvLabel, name, optionally value.
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def get_project(accession):
r = requests.get(f"{PRIDE}/projects/{accession}", timeout=30)
r.raise_for_status()
return r.json()
p = get_project("PXD004131")
print(f"Accession : {p['accession']}")
print(f"Title : {p['title'][:80]}")
print(f"Submission : {p.get('submissionDate')}")
print(f"Publication : {p.get('publicationDate')}")
print(f"DOI : {p.get('doi')}")
print(f"License : {p.get('license')}")
print(f"Type : {p.get('submissionType')}")
print(f"Organisms : {[o['name'] for o in p.get('organisms', [])]}")
print(f"Instruments : {[i['name'] for i in p.get('instruments', [])]}")
print(f"Experiment : {[e['name'] for e in p.get('experimentTypes', [])]}")
print(f"PIs : {[pi.get('name') for pi in p.get('labPIs', [])]}")
print(f"References : {[r.get('doi') for r in p.get('references', [])[:3]]}")
Module 4: Project Files — /projects/{accession}/files + /files/all
List the files associated with a project. Use the paginated endpoint for large projects; /files/all returns every file in one shot. Each file record carries fileCategory.value (one of RAW, PEAK, RESULT, FASTA, OTHER), fileSizeBytes (note the Bytes suffix — not fileSize), and a list of publicFileLocations each labeled FTP Protocol or Aspera Protocol.
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def get_project_files(accession, file_type=None, page_size=100):
"""Walk paginated /files for a project. Optionally filter by category code
(RAW, PEAK, RESULT, FASTA, OTHER). Returns a DataFrame."""
rows, page = [], 0
while True:
r = requests.get(f"{PRIDE}/projects/{accession}/files",
params={"pageSize": page_size, "page": page},
timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
break
for f in batch:
cat = f.get("fileCategory") or {}
ftp = next((loc["value"] for loc in f.get("publicFileLocations", [])
if loc.get("name") == "FTP Protocol"), "")
asp = next((loc["value"] for loc in f.get("publicFileLocations", [])
if loc.get("name") == "Aspera Protocol"), "")
rows.append({
"file_name": f.get("fileName"),
"category": cat.get("value"), # RAW/PEAK/RESULT/FASTA/OTHER
"size_mb": round((f.get("fileSizeBytes") or 0) / 1e6, 2),
"ftp_url": ftp,
"aspera_url": asp,
"downloads": f.get("totalDownloads"),
})
if len(batch) < page_size:
break
page += 1
df = pd.DataFrame(rows)
if file_type:
df = df[df["category"] == file_type]
return df
files_df = get_project_files("PXD004131")
print(f"Total files: {len(files_df)}")
print(files_df.groupby("category")["size_mb"].agg(["count", "sum"]).round(1).to_string())
raw_only = files_df[files_df["category"] == "RAW"]
print(f"\nRAW files: {len(raw_only)}; combined {raw_only['size_mb'].sum():.0f} MB")
print(raw_only[["file_name", "size_mb", "downloads"]].head(5).to_string(index=False))
# /files/all returns every file in one response — convenient for small projects
files = requests.get(f"{PRIDE}/projects/PXD000001/files/all", timeout=60).json()
print(f"PXD000001 files (all): {len(files)}")
for f in files[:4]:
print(f" [{f.get('fileCategory',{}).get('value','?'):<6}] {f['fileName']} "
f"{f.get('fileSizeBytes',0)/1e6:.2f} MB")
Module 5: SDRF File — /files/sdrf/{projectAccession}
PRIDE projects that follow the modern submission standard include an SDRF (Sample-Data Relationship Format) TSV that maps each MS run to its biological sample, treatment, label, fraction, etc. Pull it once, parse it as a TSV.
import requests, pandas as pd, io
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def get_sdrf(accession):
"""Fetch the SDRF sample-to-run mapping for a project (404 if not provided)."""
r = requests.get(f"{PRIDE}/files/sdrf/{accession}", timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return pd.read_csv(io.StringIO(r.text), sep="\t")
# Many older projects have no SDRF — newer ones typically do
sdrf = get_sdrf("PXD000001")
if sdrf is None or sdrf.empty:
print("No SDRF available for this project")
else:
print(f"SDRF rows: {len(sdrf)} cols: {len(sdrf.columns)}")
print(f"First columns: {list(sdrf.columns)[:8]}")
Module 6: Protein → Project Mapping — /proteins/{accession}
PRIDE v3's protein endpoint returns only the list of project accessions that contain identifications for the given UniProt accession. It does not return PSM counts, peptide counts, or sequence coverage — those are not exposed at the API surface in v3. For depth metrics you must download a project's RESULT files and parse them locally.
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def get_protein_projects(uniprot_acc):
"""Return the list of PRIDE project accessions that mention this UniProt accession.
No PSM/peptide/coverage counts are available at this endpoint."""
r = requests.get(f"{PRIDE}/proteins/{uniprot_acc}", timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
data = r.json()
return data.get("projects", [])
tp53 = get_protein_projects("P04637")
print(f"TP53 (P04637) is reported in {len(tp53)} PRIDE projects")
print(f"First 8: {tp53[:8]}")
unknown = get_protein_projects("Q99999")
print(f"\nQ99999 (no real protein): "
f"{'no PRIDE evidence' if not unknown else f'{len(unknown)} projects'}")
Module 7: Discovery Helpers — Similar Projects, Autocomplete
/projects/{accession}/similarProjects returns projects with related metadata signatures (organism, instrument, experiment type, tags). /search/autocomplete?keyword=... returns project titles starting with the prefix — useful to suggest searches.
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
# Find similar projects to one of interest
similar = requests.get(f"{PRIDE}/projects/PXD004131/similarProjects",
params={"pageSize": 5}, timeout=30).json()
print(f"Similar to PXD004131: {len(similar)} projects")
for p in similar[:5]:
print(f" {p['accession']} {(p.get('title') or '')[:70]}")
# Autocomplete suggestions for a project-title prefix
suggestions = requests.get(f"{PRIDE}/search/autocomplete",
params={"keyword": "tp53"}, timeout=30).json()
print(f"\nAutocomplete for 'tp53': {len(suggestions)} suggestions")
for s in suggestions[:5]:
print(f" {s}")
Module 8: Repository-Wide Counts — /projects/count, /files/count
Get total counts across the repository — useful for status displays and sanity checks. Both endpoints return a plain integer body (no JSON object wrapper).
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
n_projects = int(requests.get(f"{PRIDE}/projects/count", timeout=30).text)
n_files = int(requests.get(f"{PRIDE}/files/count", timeout=30).text)
print(f"PRIDE Archive current scale:")
print(f" Projects: {n_projects:,}")
print(f" Files: {n_files:,}")
Key Concepts
Plain JSON Arrays, No HAL Envelope
PRIDE v3 list endpoints return plain JSON arrays — for example /search/projects returns [{...}, {...}, ...] directly. There is no _embedded.compactprojects, no page.totalElements/totalPages, no _links.next.href. Older PRIDE v2 clients that parsed data["_embedded"]["compactprojects"] will silently return empty against the current API. To paginate, walk page=0, 1, 2, ... until you get an empty array (or a partial page shorter than pageSize).
What v3 Removed
The endpoint families below no longer exist in v3 (and v2 is now an alias for v3 internally — error messages from /v2/peptides literally report path: "/pride/ws/archive/v3/peptides"):
| Removed endpoint | Status in v3 | Replacement |
|---|---|---|
GET /peptides?projectAccessions=X |
404 | None — download project's RESULT files and parse |
GET /psms?projectAccessions=X |
404 | None — download RESULT files |
GET /proteins?proteinAccession=X (query-param style) |
404 | GET /proteins/{accession} (path-param) |
HAL+JSON _embedded/page wrapper |
Gone | Plain JSON array |
/projects?keyword=...&organisms=...&tissues=... filters |
Silently ignored | /search/projects?keyword=...&filter=field==value |
Filter Syntax on /search/projects
The filter query parameter takes a comma-separated list of field==value constraints. Field names use the _facet suffix (the underlying Solr-style field). Discover valid field names and values via /facet/projects before constructing the filter:
# Valid filter forms
"organisms_facet==Homo sapiens (human)"
"instruments_facet==Q Exactive"
"diseases_facet==Prostate adenocarcinoma"
"softwares_facet==MaxQuant"
# Combine with commas
filter="organisms_facet==Homo sapiens (human),instruments_facet==Orbitrap Fusion Lumos"
File Categories
Each file in a project carries a fileCategory CV-param. The .value is a category code; the .name is the human-readable label:
value code |
Description | Common formats |
|---|---|---|
RAW |
Unprocessed instrument output | .raw (Thermo), .d (Bruker/Agilent), .wiff (Sciex) |
PEAK |
Centroided / deconvoluted spectra | .mzML, .mzXML, .mgf |
RESULT |
Identification results | .mzid, .mzTab, MaxQuant txt, PRIDE XML |
FASTA |
Protein sequence database used in search | .fasta |
OTHER |
Supplementary / scripts / tables | .txt, .xlsx, .csv |
For reanalysis pipelines, RESULT is the cheapest entry point — pre-identified peptides without re-searching spectra. PEAK lets you re-search with a different engine. RAW is only needed for full vendor-format reprocessing.
Accession Formats
PRIDE project accessions follow ProteomeXchange format PXD######. These are stable across PRIDE, MassIVE, jPOST, and iProX. File accessions inside PRIDE are SHA-256-style hashes (e.g., 5bda360133398f66021c8889e01dce921cb51300c7269e1f2b0f20368ab20af6) — opaque identifiers; use fileName for human-readable filenames.
Common Workflows
Workflow 1: Faceted Discovery — From Disease Keyword to Filtered Project List
Goal: Start from a disease keyword, see which instruments and softwares are common in matching datasets via facet counts, then pull a filtered project list using one of the top values.
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
disease_kw = "colorectal cancer"
# 1) Inspect facet counts to learn which filter values dominate
facets = requests.get(f"{PRIDE}/facet/projects",
params={"keyword": disease_kw, "facetPageSize": 10},
timeout=30).json()
top_instr = sorted(facets.get("instruments", {}).items(), key=lambda kv: -kv[1])[:5]
top_org = sorted(facets.get("organisms", {}).items(), key=lambda kv: -kv[1])[:3]
print(f"Top instruments for '{disease_kw}':")
for k, v in top_instr: print(f" {k:<35} {v}")
print(f"Top organisms:")
for k, v in top_org: print(f" {k:<35} {v}")
# 2) Build a filtered search using one top instrument
target_instr = top_instr[0][0]
projects = requests.get(f"{PRIDE}/search/projects",
params={"keyword": disease_kw,
"filter": f"organisms_facet==Homo sapiens (human),instruments_facet=={target_instr}",
"pageSize": 50,
"sortFields": "submission_date",
"sortDirection": "DESC"},
timeout=30).json()
df = pd.DataFrame([{
"accession": p["accession"],
"title": (p.get("title") or "")[:70],
"submission_date": p.get("submissionDate"),
"tissues": ", ".join(p.get("organismsPart", []))[:40],
"submitter": (p.get("submitters") or [""])[0] if p.get("submitters") else "",
} for p in projects])
print(f"\nFiltered projects: {len(df)} (target instrument: {target_instr})")
print(df.head(10).to_string(index=False))
df.to_csv(f"{disease_kw.replace(' ', '_')}_{target_instr.replace(' ', '_')}_projects.csv",
index=False)
Workflow 2: File Download Manifest for One Project
Goal: Pull the file list for a project, filter to the categories you actually want (RAW + RESULT), and emit an aria2c-ready URL list for parallel FTP download.
import requests, pandas as pd
from pathlib import Path
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
accession = "PXD004131"
keep_categories = {"RAW", "RESULT"}
output_dir = Path(f"/data/pride/{accession}")
files = requests.get(f"{PRIDE}/projects/{accession}/files/all", timeout=120).json()
manifest = []
for f in files:
cat = (f.get("fileCategory") or {}).get("value")
if cat not in keep_categories:
continue
ftp = next((loc["value"] for loc in f.get("publicFileLocations", [])
if loc.get("name") == "FTP Protocol"), None)
if not ftp:
continue
manifest.append({
"file_name": f["fileName"],
"category": cat,
"size_mb": round((f.get("fileSizeBytes") or 0) / 1e6, 2),
"ftp": ftp,
})
mdf = pd.DataFrame(manifest).sort_values(["category", "file_name"])
print(f"{accession}: keeping {len(mdf)}/{len(files)} files "
f"({mdf['size_mb'].sum():.0f} MB total)")
print(mdf.groupby("category")[["size_mb"]].sum().round(0))
# aria2c -i pride_dl.list -d /data/pride/PXD004131 -x 8 -j 4
with open("pride_dl.list", "w") as fh:
fh.write("\n".join(mdf["ftp"]))
print(f"\nWrote pride_dl.list with {len(mdf)} URLs (use aria2c -i)")
Workflow 3: Protein Cross-Project Occurrence
Goal: For a candidate protein panel (e.g., from a differential-expression analysis), look up how many PRIDE projects mention each one and shortlist the most-evidenced proteins. Note: this is a project-count signal only — there are no PSM/peptide counts at the API surface in v3, so a high project count is breadth, not depth.
import requests, time, pandas as pd, matplotlib.pyplot as plt
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
candidates = {
"P04637": "TP53", "P38398": "BRCA1", "P31749": "AKT1",
"P40763": "STAT3", "O15530": "PDPK1", "P10275": "AR",
}
rows = []
for acc, sym in candidates.items():
r = requests.get(f"{PRIDE}/proteins/{acc}", timeout=30)
projs = r.json().get("projects", []) if r.status_code == 200 else []
rows.append({"uniprot": acc, "symbol": sym, "n_projects": len(projs)})
time.sleep(0.3)
df = pd.DataFrame(rows).sort_values("n_projects", ascending=False)
print(df.to_string(index=False))
fig, ax = plt.subplots(figsize=(8, 3.5))
bars = ax.bar(df["symbol"], df["n_projects"], color="#3182BD")
ax.bar_label(bars, fmt="%d", fontsize=9, padding=2)
ax.set_ylabel("# PRIDE projects mentioning the protein")
ax.set_title("PRIDE project-level occurrence — candidate panel")
plt.tight_layout()
plt.savefig("pride_protein_occurrence.png", dpi=150, bbox_inches="tight")
print("Saved pride_protein_occurrence.png")
Key Parameters
| Parameter | Endpoint | Default | Range / Options | Effect |
|---|---|---|---|---|
keyword |
/search/projects, /facet/projects, /search/autocomplete |
— | free-text string | Full-text search across title, description, tags |
filter |
/search/projects |
— | field_facet==value, field_facet==value |
Server-side filter using facet field names |
pageSize |
/search/projects, /projects, /projects/{acc}/files, /projects/{acc}/similarProjects |
100 | positive integer | Results per page |
page |
same as above | 0 | 0-indexed integer | Page number (no metadata returned — walk pages until empty) |
sortFields |
/search/projects |
submission_date |
comma-separated field names | Sort key(s) |
sortDirection |
/search/projects |
DESC |
ASC or DESC |
Sort order |
facetPageSize |
/facet/projects |
20 | positive integer | Values returned per facet group |
dateGap |
/search/projects, /facet/projects |
— | e.g. +1MONTH, +1YEAR |
Date-range aggregation granularity |
(path) accession |
/projects/{acc}, /projects/{acc}/files, /projects/{acc}/similarProjects, /proteins/{acc} |
required | PXD###### or UniProt acc | Identifies the resource |
Best Practices
-
Use
/search/projectsfor searching, not/projects. Plain/projectsis a paginated listing endpoint and silently ignores keyword / organism / disease filters. Filtering only works through/search/projectswith thefilter=field_facet==valuesyntax. -
Discover filter values via
/facet/projectsbefore filtering. Facet field values must match exactly (e.g.,organisms_facet==Homo sapiens (human), parentheses and all). The facet endpoint tells you which values exist and how many projects each has — saves a lot of trial-and-error. -
Don't try to query peptide- or PSM-level data over the API. Those endpoints were removed in v3. Download the project's RESULT files and parse them locally with
pyteomics,pyOpenMS, or a search-engine reader (MaxQuant, ProteomeDiscoverer, etc.). -
Prefer FTP URLs for bulk file downloads. Each file record carries both
FTP ProtocolandAspera ProtocolURLs. FTP is more universally supported; pair it witharia2c -x 8 -j 4for parallel chunks. Use Aspera only if you have an Aspera client and need >100 Mbit transfer speeds. -
Watch the field name
fileSizeBytes. The current v3 field isfileSizeBytes, notfileSize(old v2 docs may sayfileSize). Sizes are in bytes — divide by1e6for MB,1e9for GB. -
Filter file downloads by
fileCategory.value. A project can have hundreds of files spanning RAW (GB-scale) and OTHER (KB-scale). Always filter to the categories you actually need before queueing downloads — otherwise you'll easily download tens of gigabytes of vendor RAW files when you only wanted the identification tables. -
Pagination has no metadata — walk until empty. Unlike old PRIDE v2, the v3 API doesn't return
totalElements/totalPages. Iteratepage=0, 1, 2, ...and stop when a page returns an empty array, or when its length is less thanpageSize.
Common Recipes
Recipe: Quick Project File Summary
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def project_file_summary(accession):
files = requests.get(f"{PRIDE}/projects/{accession}/files/all", timeout=60).json()
by_cat = {}
for f in files:
cat = (f.get("fileCategory") or {}).get("value", "OTHER")
by_cat.setdefault(cat, [0, 0])
by_cat[cat][0] += 1
by_cat[cat][1] += (f.get("fileSizeBytes") or 0) / 1e6
print(f"\n{accession} file summary:")
for cat, (n, mb) in sorted(by_cat.items()):
print(f" {cat:<8} {n:>4} file(s) {mb:>10.1f} MB")
total_mb = sum(mb for _, mb in by_cat.values())
total_n = sum(n for n, _ in by_cat.values())
print(f" {'TOTAL':<8} {total_n:>4} file(s) {total_mb:>10.1f} MB")
project_file_summary("PXD000001")
Recipe: Check If a Protein Has Any PRIDE Evidence
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
def pride_evidence(uniprot_acc):
"""Return (has_evidence, n_projects). PRIDE v3 only exposes project list, no PSM counts."""
r = requests.get(f"{PRIDE}/proteins/{uniprot_acc}", timeout=30)
if r.status_code != 200:
return False, 0
projs = r.json().get("projects", [])
return bool(projs), len(projs)
for acc in ["P04637", "Q99999"]:
has, n = pride_evidence(acc)
print(f"{acc}: evidence={has} projects={n}")
Recipe: Recent Submissions for a Keyword (sorted by date)
import requests, pandas as pd
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
r = requests.get(f"{PRIDE}/search/projects",
params={"keyword": "single-cell proteomics",
"sortFields": "submission_date",
"sortDirection": "DESC",
"pageSize": 15},
timeout=30)
recent = r.json()
df = pd.DataFrame([{
"submission_date": p.get("submissionDate"),
"accession": p["accession"],
"title": (p.get("title") or "")[:80],
} for p in recent]).sort_values("submission_date", ascending=False)
print(df.to_string(index=False))
Recipe: Suggest-as-You-Type via Autocomplete
import requests
PRIDE = "https://www.ebi.ac.uk/pride/ws/archive/v3"
for prefix in ["alzheimer", "single cell", "brca"]:
s = requests.get(f"{PRIDE}/search/autocomplete",
params={"keyword": prefix}, timeout=30).json()
print(f"\n'{prefix}' → {len(s)} suggestions:")
for sug in s[:3]:
print(f" · {sug[:80]}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Parsing returns empty list even when r.json() has data |
Code is doing data["_embedded"]["compactprojects"] — old HAL+JSON wrapper that v3 no longer returns |
Parse the response directly as a list: projects = r.json() |
HTTP 404 on /peptides, /psms, or /proteins?proteinAccession=X |
These endpoints were removed in v3 | For peptide/PSM data, download the project's RESULT files and parse locally. For protein lookup, use /proteins/{accession} (path param) |
/projects?keyword=cancer returns the same 100 results as /projects with no keyword |
The /projects endpoint only accepts pageSize / page — keyword and other filters are silently ignored |
Use /search/projects?keyword=...&filter=... instead |
/projects/{acc}/files shows file size 0 |
Reading fileSize instead of fileSizeBytes |
The v3 field is fileSizeBytes (bytes); compute MB via fileSizeBytes / 1e6 |
| Filter has no effect | Facet value doesn't exactly match a real value | Call /facet/projects?keyword=... first to enumerate valid values (Homo sapiens (human), not Homo sapiens) |
pageSize beyond the actual result set returns an empty array |
Normal pagination behavior | Stop iterating when the returned array length is < pageSize, or when it is empty |
findAllOrganismsCount returns HTTP 406 Not Acceptable |
The endpoint requires a non-JSON Accept header | Skip this endpoint — facet counts via /facet/projects cover the same need |
HTTP 429 or ConnectionError on bursts |
Shared EBI infrastructure | Add time.sleep(0.3) in loops; retry on 5xx with exponential backoff |
Related Skills
uniprot-protein-database— UniProt sequences, Swiss-Prot annotations, ID mapping; pair with PRIDE protein lookups to enrich each UniProt accession with sequence and functional informationinterpro-database— Protein domain architecture (Pfam, SMART, PANTHER) for proteins reported in PRIDEpdb-database— Resolved 3D structures for proteins with PRIDE evidencepyteomics(off-skill Python library) — Parse mzIdentML / mzML / mzTab files downloaded from/projects/{accession}/files; the path for spectrum- and PSM-level analysis now that the REST API no longer exposes those
References
- PRIDE Archive v3 REST API root — base URL
- PRIDE v3 OpenAPI/Swagger spec — authoritative endpoint inventory
- PRIDE Archive web portal — interactive dataset browser
- Perez-Riverol et al., Nucleic Acids Research 2022 — PRIDE 2022 update describing repository and architecture
- ProteomeXchange Consortium — standard accession system shared across PRIDE, MassIVE, jPOST, iProX
- SDRF-Proteomics specification — format used by
/files/sdrf/{projectAccession}
skills/structural-biology-drug-discovery/mdtraj-trajectory-analysis/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill mdtraj-trajectory-analysis -g -y
SKILL.md
Frontmatter
{
"name": "mdtraj-trajectory-analysis",
"license": "LGPL-2.1",
"description": "mdtraj molecular dynamics trajectory analysis (Python). Reads DCD\/XTC\/TRR\/NetCDF\/H5\/PDB topologies and trajectories; computes RMSD vs time, radius of gyration, per-residue RMSF, residue-residue contact frequency maps, phi\/psi torsions for Ramachandran plots (general + Gly\/Pro), and 8-state DSSP secondary structure. Modules: trajectory I\/O, geometry (distances\/angles\/dihedrals), structural analysis (RMSD\/Rg\/RMSF\/SASA), contacts, hydrogen bonds, secondary structure (DSSP), NMR observables. For broader atom-selection grammar use mdanalysis-trajectory; for running MD simulations use OpenMM\/GROMACS."
}
mdtraj Trajectory Analysis
Overview
mdtraj is a dependency-light Python library for analyzing MD trajectories. Reads DCD/XTC/TRR/NetCDF/H5/AMBER/GROMACS/CHARMM/OpenMM into a Trajectory object backed by NumPy arrays, then exposes geometry, RMSD/Rg/RMSF/SASA, contacts, hydrogen bonds, torsions, and 8-state DSSP as pure-Python functions.
Units: mdtraj uses nm and ps internally. Multiply distances by 10 for Å, divide time by 1000 for ns. Torsions are in radians —
np.degrees().
When to Use
- RMSD vs time, Rg, per-residue RMSF for stability across MD replicates
- Residue-residue contact frequency maps from a trajectory ensemble
- Backbone phi/psi for Ramachandran (general, Gly, Pro)
- 8-state DSSP per residue per frame for secondary-structure time series
- Lightweight ad-hoc analyses where MDAnalysis's full framework is overkill
- NMR observables (J-couplings, chemical shifts via SHIFTX2)
- Use mdanalysis-trajectory instead when you need MDAnalysis's selection grammar, AnalysisBase parallelism, or LAMMPS/NAMD-specific readers
- Use OpenMM/GROMACS to run the simulation — this skill is post-simulation only
Prerequisites
mdtraj,numpy,pandas,matplotlib- Topology (PDB/GRO/PRMTOP/PSF) + trajectory (DCD/XTC/TRR/NetCDF/H5)
- Python 3.9+, conda-forge build recommended
Check before installing — inside a pixi/conda env mdtraj is usually present:
python3 -c "import mdtraj" 2>/dev/null || conda install -c conda-forge mdtraj numpy pandas matplotlib
Quick Start
import mdtraj as md
traj = md.load("traj.xtc", top="topology.pdb")
ca = traj.topology.select("name CA")
traj.superpose(traj, frame=0, atom_indices=ca)
rmsd_ang = md.rmsd(traj, traj, frame=0, atom_indices=ca) * 10.0 # nm -> Å
print(f"Frames: {traj.n_frames} RMSD: {rmsd_ang.min():.2f}–{rmsd_ang.max():.2f} Å")
Core API
Module 1: Trajectory I/O
Load whole or streamed. Format auto-detected from extension.
import mdtraj as md
traj = md.load("rep1.xtc", top="protein.pdb")
# Stream large trajectories — avoids OOM
for chunk in md.iterload("rep1.xtc", top="protein.pdb", chunk=500):
rmsd_chunk = md.rmsd(chunk, chunk, frame=0)
# Save subset
ca = traj.topology.select("name CA")
traj.atom_slice(ca).save_dcd("ca_only.dcd")
Selecting atoms and slicing frames:
backbone = traj.topology.select("backbone")
chain_a = traj.topology.select("chainid 0")
first_ns = traj[:1000]
every_10th = traj[::10]
last_half_bb = traj[traj.n_frames // 2:].atom_slice(backbone)
Module 2: RMSD, Rg, RMSF
md.rmsd superposes internally; RMSF you compute manually after explicit superpose.
import mdtraj as md, numpy as np
traj = md.load("rep1.xtc", top="protein.pdb")
ca = traj.topology.select("name CA")
rmsd_ang = md.rmsd(traj, traj, frame=0, atom_indices=ca) * 10.0 # Å
rg_ang = md.compute_rg(traj) * 10.0 # Å
time_ns = traj.time / 1000.0
print(f"<RMSD>={rmsd_ang.mean():.2f} Å, <Rg>={rg_ang.mean():.2f} Å")
# Per-CA RMSF — average-structure reference
ca_traj = traj.atom_slice(ca)
ca_traj.superpose(ca_traj, frame=0)
diff = ca_traj.xyz - ca_traj.xyz.mean(axis=0)
rmsf_ang = np.sqrt((diff ** 2).sum(axis=2).mean(axis=0)) * 10.0
res_ids = [a.residue.resSeq for a in ca_traj.topology.atoms]
print(f"Max RMSF: residue {res_ids[np.argmax(rmsf_ang)]} = {rmsf_ang.max():.2f} Å")
Module 3: Contacts and Distance Maps
Threshold distances to get contact frequency.
import mdtraj as md, numpy as np
traj = md.load("rep1.xtc", top="protein.pdb")
distances_nm, pairs = md.compute_contacts(traj, contacts="all", scheme="closest-heavy")
# distances_nm: (n_frames, n_pairs); pairs: (n_pairs, 2) of residue indices
contact_freq = (distances_nm < 0.5).mean(axis=0) # 5 Å cutoff
n_res = traj.n_residues
freq_map = np.zeros((n_res, n_res))
for (i, j), f in zip(pairs, contact_freq):
freq_map[i, j] = freq_map[j, i] = f
print(f"Persistent contacts (>0.8): {(contact_freq > 0.8).sum()}")
Module 4: Torsions (Ramachandran)
phi/psi returned in radians; intersect on residue since first residue has no phi and last has no psi.
import mdtraj as md, numpy as np
traj = md.load("rep1.xtc", top="protein.pdb")
phi_ix, phi_rad = md.compute_phi(traj)
psi_ix, psi_rad = md.compute_psi(traj)
def res_of(indices): return np.array([traj.topology.atom(ix[1]).residue.index for ix in indices])
phi_res, psi_res = res_of(phi_ix), res_of(psi_ix)
common = np.intersect1d(phi_res, psi_res)
phi_deg = np.degrees(phi_rad[:, np.isin(phi_res, common)])
psi_deg = np.degrees(psi_rad[:, np.isin(psi_res, common)])
Filter to Gly / Pro residues:
res_names = [traj.topology.residue(r).name for r in common]
gly_cols = [i for i, n in enumerate(res_names) if n == "GLY"]
pro_cols = [i for i, n in enumerate(res_names) if n == "PRO"]
phi_gly, psi_gly = phi_deg[:, gly_cols].ravel(), psi_deg[:, gly_cols].ravel()
phi_pro, psi_pro = phi_deg[:, pro_cols].ravel(), psi_deg[:, pro_cols].ravel()
Module 5: DSSP Secondary Structure (8-state)
md.compute_dssp(traj, simplified=False) returns (n_frames, n_residues) of one-character codes:
| Code | Meaning |
|---|---|
H |
alpha-helix |
B |
beta-bridge |
E |
beta-sheet |
G |
3_10-helix |
I |
pi-helix |
T |
turn |
S |
bend |
(space) or C |
coil |
import mdtraj as md
traj = md.load("rep1.xtc", top="protein.pdb")
dssp = md.compute_dssp(traj, simplified=False)
dssp[dssp == " "] = "C"
print(f"DSSP grid: {dssp.shape} unique codes: {set(dssp.flatten())}")
Module 6: Hydrogen Bonds and SASA
import mdtraj as md
import pandas as pd
traj = md.load("rep1.xtc", top="protein.pdb")
# Baker–Hubbard: returns (n_hbonds, 3) of [donor, H, acceptor] atom indices
hbonds = md.baker_hubbard(traj, freq=0.5, periodic=False)
label = lambda a: f"{traj.topology.atom(a).residue}-{traj.topology.atom(a).name}"
hbonds_df = pd.DataFrame({"donor": [label(d) for d, _, _ in hbonds],
"acceptor": [label(a) for _, _, a in hbonds]})
# SASA in nm^2 per residue per frame
sasa_nm2 = md.shrake_rupley(traj, mode="residue")
total_ang2 = sasa_nm2.sum(axis=1) * 100.0 # nm^2 -> Å^2
print(f"Persistent H-bonds: {len(hbonds_df)}, <SASA>: {total_ang2.mean():.0f} Ų")
Key Concepts
Units (nm, ps, radians)
traj.xyz is in nm, traj.time in ps, torsions in radians. Convert before plotting:
import numpy as np
xyz_ang = traj.xyz * 10.0
time_ns = traj.time / 1000.0
phi_deg = np.degrees(phi_rad)
Pair indices vs resSeq
md.compute_contacts(..., contacts="all") returns 0-based residue indices (contiguous across chains), not PDB resSeq. Reshape into (n_res, n_res) before heatmaps. Map back via traj.topology.residue(i).resSeq only when labeling.
8-state vs simplified DSSP
simplified=False → 8 states. simplified=True → 3 states (H/E/C), which loses 3_10 (G), pi-helix (I), beta-bridge (B), bend (S), turn (T).
Common Workflows
Workflow 1: Multi-Replicate Stability + Flexibility + Contacts + Ramachandran + DSSP
Goal: structural-analysis report for three MD replicates — RMSD/Rg over time, per-residue RMSF, residue contact frequency map (rep1), Ramachandran general + Gly + Pro (rep1), DSSP 8-state time series (rep1). Plots use matplotlib defaults; the consumer picks palette downstream.
import mdtraj as md, numpy as np, matplotlib.pyplot as plt
from pathlib import Path
replicas = {"rep1": "rep1.xtc", "rep2": "rep2.xtc", "rep3": "rep3.xtc"}
topology = "protein.pdb"
outdir = Path("figures"); outdir.mkdir(exist_ok=True)
trajs = {name: md.load(p, top=topology) for name, p in replicas.items()}
ca = next(iter(trajs.values())).topology.select("name CA")
# 1. RMSD vs time + Rg (Å) for all replicates
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
for name, traj in trajs.items():
traj.superpose(traj, frame=0, atom_indices=ca)
ax1.plot(traj.time / 1000.0, md.rmsd(traj, traj, frame=0, atom_indices=ca) * 10.0, lw=1, label=name)
ax2.plot(traj.time / 1000.0, md.compute_rg(traj) * 10.0, lw=1, label=name)
ax1.set(xlabel="Time (ns)", ylabel="RMSD (Å)", title="Backbone RMSD")
ax2.set(xlabel="Time (ns)", ylabel="Rg (Å)", title="Radius of gyration")
for ax in (ax1, ax2): ax.legend(frameon=False)
fig.tight_layout(); fig.savefig(outdir / "01_rmsd_rg.png"); plt.close(fig)
# 2. Per-residue RMSF (Å) across replicates
fig, ax = plt.subplots(figsize=(10, 4))
for name, traj in trajs.items():
ct = traj.atom_slice(ca); ct.superpose(ct, frame=0)
rmsf = np.sqrt(((ct.xyz - ct.xyz.mean(axis=0)) ** 2).sum(axis=2).mean(axis=0)) * 10.0
ax.plot([a.residue.resSeq for a in ct.topology.atoms], rmsf, lw=1, label=name)
ax.set(xlabel="Residue", ylabel="RMSF (Å)", title="Per-residue flexibility")
ax.legend(frameon=False); fig.tight_layout()
fig.savefig(outdir / "02_rmsf.png"); plt.close(fig)
# 3. Contact frequency map (rep1, 5 Å cutoff)
rep1 = trajs["rep1"]
dist_nm, pairs = md.compute_contacts(rep1, contacts="all", scheme="closest-heavy")
freq = (dist_nm < 0.5).mean(axis=0)
freq_map = np.zeros((rep1.n_residues, rep1.n_residues))
for (i, j), f in zip(pairs, freq):
freq_map[i, j] = freq_map[j, i] = f
np.fill_diagonal(freq_map, 1.0)
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(freq_map, vmin=0, vmax=1, origin="lower")
ax.set(xlabel="Residue", ylabel="Residue", title="Contact frequency (rep1)")
fig.colorbar(im, ax=ax, label="Fraction of frames in contact")
fig.tight_layout(); fig.savefig(outdir / "03_contact_map.png"); plt.close(fig)
# 4. Ramachandran (rep1) — phi/psi 2D density
phi_ix, phi_rad = md.compute_phi(rep1)
psi_ix, psi_rad = md.compute_psi(rep1)
def res_of(ix, t): return np.array([t.topology.atom(i[1]).residue.index for i in ix])
phi_res, psi_res = res_of(phi_ix, rep1), res_of(psi_ix, rep1)
common = np.intersect1d(phi_res, psi_res)
phi_deg = np.degrees(phi_rad[:, np.isin(phi_res, common)]).ravel()
psi_deg = np.degrees(psi_rad[:, np.isin(psi_res, common)]).ravel()
H, _, _ = np.histogram2d(phi_deg, psi_deg, bins=72, range=[[-180, 180], [-180, 180]])
fig, ax = plt.subplots(figsize=(5, 5))
ax.imshow(H.T, origin="lower", extent=[-180, 180, -180, 180], aspect="equal")
ax.set(xlabel=r"$\phi$ (°)", ylabel=r"$\psi$ (°)", title="Ramachandran (rep1)")
fig.tight_layout(); fig.savefig(outdir / "04_ramachandran_all.png"); plt.close(fig)
# 5. Glycine + proline Ramachandran
res_names = [rep1.topology.residue(r).name for r in common]
for label, cols, fname in [("Glycine", [i for i, n in enumerate(res_names) if n == "GLY"], "05_rama_gly.png"),
("Proline", [i for i, n in enumerate(res_names) if n == "PRO"], "06_rama_pro.png")]:
if not cols: continue
phi = np.degrees(phi_rad[:, cols]).ravel()
psi = np.degrees(psi_rad[:, cols]).ravel()
H, _, _ = np.histogram2d(phi, psi, bins=60, range=[[-180, 180], [-180, 180]])
fig, ax = plt.subplots(figsize=(5, 5))
ax.imshow(H.T, origin="lower", extent=[-180, 180, -180, 180], aspect="equal")
ax.set(xlabel=r"$\phi$ (°)", ylabel=r"$\psi$ (°)", title=f"{label} Ramachandran (rep1)")
fig.tight_layout(); fig.savefig(outdir / fname); plt.close(fig)
# 6. DSSP 8-state time series (rep1)
dssp = md.compute_dssp(rep1, simplified=False); dssp[dssp == " "] = "C"
codes_order = ["C", "E", "B", "S", "T", "H", "I", "G"]
code_to_int = {c: i for i, c in enumerate(codes_order)}
dssp_int = np.vectorize(lambda c: code_to_int.get(c, 0))(dssp)
fig, ax = plt.subplots(figsize=(10, 5))
ax.imshow(dssp_int.T, aspect="auto", origin="lower", interpolation="nearest",
extent=[rep1.time[0] / 1000.0, rep1.time[-1] / 1000.0, 0, rep1.n_residues])
ax.set(xlabel="Time (ns)", ylabel="Residue", title="DSSP 8-state (rep1)")
fig.tight_layout(); fig.savefig(outdir / "07_dssp_8state.png"); plt.close(fig)
# Dump DSSP code mapping so downstream code can pick its own legend/palette
np.savez(outdir / "07_dssp_8state.npz", dssp=dssp, dssp_int=dssp_int, codes_order=codes_order)
print("All figures written to figures/")
Workflow 2: H-Bond Persistence + SASA Across Replicates
Goal: persistent hydrogen bonds (≥50% of frames) and total SASA time series per replicate.
import mdtraj as md, numpy as np, pandas as pd
replicas = {"rep1": "rep1.xtc", "rep2": "rep2.xtc", "rep3": "rep3.xtc"}
top = "protein.pdb"
rows = []
for name, p in replicas.items():
t = md.load(p, top=top)
hbonds = md.baker_hubbard(t, freq=0.5, periodic=False)
sasa_ang2 = (md.shrake_rupley(t, mode="residue").sum(axis=1) * 100.0)
rows.append({"replica": name, "n_persistent_hbonds": len(hbonds),
"sasa_mean_ang2": sasa_ang2.mean(), "sasa_std_ang2": sasa_ang2.std()})
print(pd.DataFrame(rows).to_string(index=False))
Key Parameters
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
chunk (md.iterload) |
I/O | 100 |
100-5000 |
Frames per stream chunk; trade speed vs RAM |
atom_indices (md.rmsd) |
Stability | None |
array of ints | Restrict superposition + RMSD (e.g., CA only) |
frame (md.rmsd) |
Stability | 0 |
0-n_frames-1 |
Reference frame |
contacts (md.compute_contacts) |
Contacts | "all" |
"all", [(i,j),...] |
All residue pairs or custom list |
scheme (md.compute_contacts) |
Contacts | "closest-heavy" |
"ca", "closest", "closest-heavy", "sidechain", "sidechain-heavy" |
Atom subset for inter-residue distance |
cutoff (contact threshold, user-set) |
Contacts | 0.5 nm |
0.4-0.8 nm |
In-contact distance (5 Å for CA, 4 Å for closest-heavy) |
simplified (md.compute_dssp) |
DSSP | True |
True/False |
False → 8-state codes |
freq (md.baker_hubbard) |
H-bonds | 0.1 |
0.0-1.0 |
Min frame fraction to report |
mode (md.shrake_rupley) |
SASA | "atom" |
"atom", "residue" |
Per-atom or summed per residue |
probe_radius (md.shrake_rupley) |
SASA | 0.14 nm |
0.12-0.20 nm |
Solvent probe (1.4 Å standard) |
periodic (geometry funcs) |
Geometry | True |
bool | Minimum-image convention if box info present |
Best Practices
- Convert units before plotting — nm → Å (×10), ps → ns (÷1000), rad → deg (
np.degrees). #1 cause of "my RMSD is 10× too small". - Superpose before RMSF —
md.rmsdsuperposes internally; RMSF doesn't. Without explicittraj.superpose(...), RMSF picks up rigid-body motion. md.iterloadfor big trajectories — 1 µs at 1 ps/frame is millions of frames. Streaming avoids OOM.simplified=Falsefor fine-grained DSSP — 3-state collapses 3_10 / pi-helix / bend transitions.- Pair indices, not
resSeq—md.compute_contactsreturns 0-based contiguous indices. Map toresSeqonly for labeling. - Strip waters early —
traj.atom_slice(traj.topology.select("protein"))cuts memory/time 5–20× on solvated systems.
Common Recipes
Recipe: Convert Trajectory Format
import mdtraj as md
traj = md.load("input.nc", top="topology.parm7")
traj.save_xtc("output.xtc")
traj.save_pdb("output.pdb")
print(f"Converted {traj.n_frames} frames -> output.xtc")
Recipe: Per-Residue Mean ± SD RMSF Across Replicates
Ensemble flexibility input for flexibility-aware docking or homology refinement.
import mdtraj as md, numpy as np, pandas as pd
paths = {"rep1": "rep1.xtc", "rep2": "rep2.xtc", "rep3": "rep3.xtc"}
top = "protein.pdb"
rmsf_by_rep = {}
for name, p in paths.items():
t = md.load(p, top=top)
ct = t.atom_slice(t.topology.select("name CA")); ct.superpose(ct, frame=0)
rmsf_by_rep[name] = np.sqrt(((ct.xyz - ct.xyz.mean(axis=0)) ** 2).sum(axis=2).mean(axis=0)) * 10.0
df = pd.DataFrame(rmsf_by_rep)
df["mean"], df["std"] = df.mean(axis=1), df.std(axis=1)
df.to_csv("rmsf_replicates.csv", index_label="residue_index")
Recipe: Save Frames Above RMSD Threshold
Curate "open" conformations for clustering or downstream analysis.
import mdtraj as md
traj = md.load("rep1.xtc", top="protein.pdb")
ca = traj.topology.select("name CA")
traj.superpose(traj, frame=0, atom_indices=ca)
rmsd_ang = md.rmsd(traj, traj, frame=0, atom_indices=ca) * 10.0
open_state = traj[rmsd_ang > 4.0]
open_state.save_dcd("open_state.dcd")
print(f"Saved {open_state.n_frames}/{traj.n_frames} frames")
Recipe: Cross-Replicate Helix Fraction
import mdtraj as md, numpy as np
paths = ["rep1.xtc", "rep2.xtc", "rep3.xtc"]
helix_fracs = [(md.compute_dssp(md.load(p, top="protein.pdb"), simplified=False) == "H").mean() for p in paths]
print(f"α-helix per replicate: {[f'{h:.2f}' for h in helix_fracs]}")
print(f"Mean ± std: {np.mean(helix_fracs):.2f} ± {np.std(helix_fracs):.2f}")
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
RuntimeError: No topology |
XTC/DCD/TRR carry no topology | Pass top="protein.pdb" (or .prmtop) to md.load |
| RMSD 10× too small | mdtraj returns nm | Multiply by 10.0 |
| RMSF spikes at termini | Intrinsic terminal flexibility, not a bug | Slice termini off or report explicitly |
DSSP returns only H/E/C |
simplified=True default |
Pass simplified=False |
Empty/' ' DSSP cells |
Space = coil, not missing | dssp[dssp == " "] = "C" |
| MemoryError on big trajectory | Whole trajectory in RAM | Switch to md.iterload(..., chunk=500) |
| phi/psi arrays differ in residue count | First residue has no phi; last has no psi | Intersect residue index sets before pairing |
| Ramachandran off-center | Angles still in radians | Wrap with np.degrees; set range=[[-180, 180], [-180, 180]] |
md.compute_contacts returns NaN |
Some pairs lack heavy atoms for closest-heavy (e.g., glycine sidechain) |
Use scheme="closest" or "ca"; drop NaN columns |
| Contact map asymmetric | Only filled upper triangle | Mirror: freq_map[j, i] = freq_map[i, j] |
traj.time all zeros |
Some writers leave time blank | Reconstruct: traj.time = np.arange(traj.n_frames) * dt_ps |
Related Skills
- mdanalysis-trajectory — richer atom-selection grammar and AnalysisBase framework
- autodock-vina-docking / smina-molecular-docking — feed representative MD frames as docking targets
- chembl-database-bioactivity — overlay ligand bioactivity on MD-derived flexibility maps
References
- mdtraj documentation — official docs, API reference, tutorials
- McGibbon et al. (2015) — "MDTraj: A Modern Open Library for the Analysis of Molecular Dynamics Trajectories", Biophys J
- DSSP algorithm — Kabsch & Sander (1983)
- Baker–Hubbard H-bond criterion — Baker & Hubbard (1984)
- Shrake–Rupley SASA — Shrake & Rupley (1973)
- mdtraj GitHub — source, issues, examples
skills/structural-biology-drug-discovery/smina-molecular-docking/SKILL.md
npx skills add jaechang-hits/SciAgent-Skills --skill smina-molecular-docking -g -y
SKILL.md
Frontmatter
{
"name": "smina-molecular-docking",
"license": "GPL-2.0",
"description": "smina molecular docking CLI. AutoDock Vina fork with customizable scoring functions, native SDF\/MOL2\/PDB ligand input, autoboxing, local energy minimization, and per-atom score breakdowns. Pipeline: receptor PDBQT prep -> ligand prep (RDKit\/OpenBabel) -> dock via autobox or explicit grid -> rescore\/minimize with custom scoring -> rank poses by affinity. Choose smina over Vina when you need custom scoring terms (--custom_scoring), local optimization of an existing pose (--local_only), per-atom contributions (--atom_term_data), or SDF\/MOL2 ligands without manual PDBQT conversion. For unknown binding sites use diffdock; for the Python-bindings\/Vinardo workflow use autodock-vina-docking."
}
smina Molecular Docking
Overview
smina is an AutoDock Vina 1.1.2 fork focused on flexible scoring and minimization. Accepts SDF/MOL2/PDB ligands directly (no manual PDBQT), autoboxes from a reference ligand, ships six built-in scoring functions plus arbitrary --custom_scoring terms, and prints per-atom score contributions. CLI-only — drive from Python via subprocess.
When to Use
- Re-scoring or locally minimizing an existing pose (
--local_only,--minimize) without a full search - Single-pose binding energy without docking (
--score_only) - Docking with a custom or empirical scoring function tuned to a target class
- SDF/MOL2/multi-ligand input without per-ligand PDBQT conversion
- Autoboxing the grid around a co-crystallized reference ligand
- Per-atom energy decomposition (
--atom_term_data) for medchem analog design - Batch virtual screening that parallelizes well across nodes (one CLI process per ligand)
- Use autodock-vina-docking instead when you need Vina Python bindings, Vinardo scoring, or Vina 1.2's expanded force field; use diffdock when the binding site is unknown
Prerequisites
- smina binary — conda-forge or SourceForge build
- Python:
rdkit,openbabel-wheel(or systemopenbabel),prody,pandas,py3Dmol - ADFR Suite for
prepare_receptor(receptor PDBQT only — ligands handled by smina) - Data: protein (PDB / PDB ID), ligand(s) as SMILES / SDF / MOL2
Check before installing — inside a pixi/conda env smina is usually already on PATH. If command -v smina succeeds, skip install; inside a pixi project invoke as pixi run smina ....
command -v smina || conda install -c conda-forge smina openbabel
pip install rdkit prody pandas py3Dmol
# ADFR Suite: https://ccsb.scripps.edu/adfr/downloads/
Quick Start
End-to-end docking using autobox from a reference ligand:
import subprocess
result = subprocess.run([
"smina",
"-r", "1hpv_receptor.pdbqt",
"-l", "candidate.sdf",
"--autobox_ligand", "1hpv_ref_ligand.pdb",
"--autobox_add", "8", # padding around reference (Å)
"-o", "candidate_docked.sdf",
"--exhaustiveness", "16",
"--num_modes", "9",
"--seed", "42",
], check=True, capture_output=True, text=True)
print(result.stdout.splitlines()[-15:]) # affinity table at stdout tail
Workflow
Step 1: Prepare the Receptor (PDBQT)
Strip waters/hetatms, then run ADFR Suite's prepare_receptor.
import subprocess, prody
pdb_id = "1HPV"
prody.fetchPDB(pdb_id, compressed=False)
protein = prody.parsePDB(f"{pdb_id}.pdb").select("protein")
prody.writePDB(f"{pdb_id}_protein.pdb", protein)
receptor_pdbqt = f"{pdb_id}_receptor.pdbqt"
subprocess.run([
"prepare_receptor",
"-r", f"{pdb_id}_protein.pdb",
"-o", receptor_pdbqt,
"-A", "hydrogens",
], check=True)
print(f"Receptor: {receptor_pdbqt} ({protein.numAtoms()} atoms)")
Step 2: Prepare the Ligand (SDF)
smina reads SDF directly. Generate 3D coords with RDKit.
from rdkit import Chem
from rdkit.Chem import AllChem
mol = Chem.MolFromSmiles("CC(C)(C)NC(=O)[C@@H]1CN(CCc2ccccc2)C[C@H]1O")
mol = Chem.AddHs(mol)
AllChem.EmbedMolecule(mol, randomSeed=42)
AllChem.MMFFOptimizeMolecule(mol)
w = Chem.SDWriter("candidate.sdf"); w.write(mol); w.close()
print(f"Ligand SDF: candidate.sdf ({mol.GetNumAtoms()} atoms)")
Step 3: Extract a Reference Ligand for Autoboxing
--autobox_ligand derives the grid from a reference structure.
import prody
ref = prody.parsePDB(f"{pdb_id}.pdb").select("hetero and not water and not ion")
if ref is None:
raise RuntimeError("No reference ligand — supply explicit --center_x/--size_x")
prody.writePDB(f"{pdb_id}_ref_ligand.pdb", ref)
print(f"Ref ligand: {ref.numAtoms()} atoms, center {ref.getCoords().mean(axis=0).round(2)}")
Step 4: Run Docking with Autobox
Affinity table is printed to stdout — capture it.
import subprocess
proc = subprocess.run([
"smina",
"-r", receptor_pdbqt,
"-l", "candidate.sdf",
"--autobox_ligand", f"{pdb_id}_ref_ligand.pdb",
"--autobox_add", "8",
"-o", "candidate_docked.sdf",
"--exhaustiveness", "16",
"--num_modes", "9",
"--energy_range", "3",
"--cpu", "4",
"--seed", "42",
], check=True, capture_output=True, text=True)
for line in proc.stdout.splitlines()[-15:]:
print(line)
Step 5: Parse Poses and Affinities
Affinities go into the SDF <minimizedAffinity> property.
from rdkit import Chem
import pandas as pd
rows = []
for i, mol in enumerate(Chem.SDMolSupplier("candidate_docked.sdf", removeHs=False)):
if mol is None:
continue
aff = float(mol.GetProp("minimizedAffinity")) if mol.HasProp("minimizedAffinity") else None
rmsd = float(mol.GetProp("minimizedRMSD")) if mol.HasProp("minimizedRMSD") else None
rows.append({"pose": i + 1, "affinity_kcal_mol": aff, "rmsd_to_best": rmsd})
df = pd.DataFrame(rows).sort_values("affinity_kcal_mol")
print(df.to_string(index=False))
print(f"Best: {df.iloc[0]['affinity_kcal_mol']:.2f} kcal/mol")
Step 6: Local Minimization of an Existing Pose
--local_only refines an input pose without global search.
import subprocess
subprocess.run([
"smina",
"-r", receptor_pdbqt,
"-l", "candidate_pose.sdf",
"--autobox_ligand", "candidate_pose.sdf", # box around the pose itself
"--autobox_add", "4",
"-o", "candidate_min.sdf",
"--local_only",
"--minimize_iters", "1000",
], check=True, capture_output=True, text=True)
print("Local minimization complete: candidate_min.sdf")
Step 7: Visualize the Docked Complex
import py3Dmol
with open(f"{pdb_id}_protein.pdb") as f: rec = f.read()
with open("candidate_docked.sdf") as f: lig = f.read()
view = py3Dmol.view(width=800, height=600)
view.addModel(rec, "pdb"); view.setStyle({"model": 0}, {"cartoon": {}})
view.addModel(lig.split("$$$$")[0] + "$$$$", "sdf")
view.setStyle({"model": 1}, {"stick": {}})
view.zoomTo({"model": 1}); view.show()
Step 8: Batch Virtual Screening
One smina process per ligand parallelizes well across cores or cluster nodes.
import subprocess, pandas as pd
from rdkit import Chem
from rdkit.Chem import AllChem
library = pd.DataFrame({
"name": ["cpd_001", "cpd_002", "cpd_003"],
"smiles": ["CC(=O)Oc1ccccc1C(=O)O",
"CC(C)Cc1ccc(cc1)C(C)C(=O)O",
"OC(=O)c1ccccc1O"],
})
results = []
for _, row in library.iterrows():
lig_sdf, out_sdf = f"{row['name']}.sdf", f"{row['name']}_docked.sdf"
mol = Chem.MolFromSmiles(row["smiles"]); mol = Chem.AddHs(mol)
AllChem.EmbedMolecule(mol, randomSeed=42); AllChem.MMFFOptimizeMolecule(mol)
w = Chem.SDWriter(lig_sdf); w.write(mol); w.close()
proc = subprocess.run([
"smina", "-r", receptor_pdbqt, "-l", lig_sdf,
"--autobox_ligand", f"{pdb_id}_ref_ligand.pdb", "--autobox_add", "8",
"-o", out_sdf, "--exhaustiveness", "8", "--num_modes", "1",
"--cpu", "2", "--seed", "42",
], capture_output=True, text=True)
if proc.returncode != 0:
results.append({"name": row["name"], "affinity_kcal_mol": None})
continue
docked = next(iter(Chem.SDMolSupplier(out_sdf, removeHs=False)))
aff = float(docked.GetProp("minimizedAffinity")) if docked and docked.HasProp("minimizedAffinity") else None
results.append({"name": row["name"], "affinity_kcal_mol": aff})
ranked = pd.DataFrame(results).sort_values("affinity_kcal_mol")
ranked.to_csv("screening_results.csv", index=False)
print(ranked.to_string(index=False))
Key Parameters
| Parameter | Default | Range / Options | Effect |
|---|---|---|---|
--exhaustiveness |
8 |
1-128 |
MC search effort; 16-32 production, 64+ publication |
--num_modes |
9 |
1-20 |
Poses written to output SDF |
--energy_range |
3 |
1-5 kcal/mol |
Max ΔE from best pose in output |
--scoring |
default |
default, vina, vinardo, dkoes_scoring, dkoes_scoring_old, ad4_scoring |
Built-in scoring (default ≈ Vina) |
--custom_scoring |
— | path | User-defined weighted terms; overrides --scoring |
--autobox_ligand |
— | PDB/SDF path | Derive grid from reference |
--autobox_add |
4 |
2-12 Å |
Padding around autobox extent |
--center_x/y/z, --size_x/y/z |
— | Å | Manual grid (alternative to autobox) |
--local_only |
off | flag | Skip global search; local optimization only |
--score_only |
off | flag | Energy without minimization or search |
--minimize |
off | flag | Energy-minimize without scoring search |
--minimize_iters |
0 (auto) |
0-100000 |
Local minimizer iterations |
--atom_term_data |
off | flag | Per-atom score contributions in output |
--cpu |
all | 1-N |
Threads per job |
--seed |
random | int | RNG seed for reproducibility |
--no_lig |
off | flag | Score receptor-only as baseline |
Common Recipes
Recipe: Score-Only (Single-Point Energy)
Compare pose energy between scoring functions without re-docking.
import subprocess
proc = subprocess.run([
"smina", "-r", "receptor.pdbqt", "-l", "pose.sdf",
"--score_only", "--scoring", "vinardo", # try vina, vinardo, dkoes_scoring
], capture_output=True, text=True, check=True)
for line in proc.stdout.splitlines():
if line.startswith("Affinity:"):
print(line)
Recipe: Custom Scoring Function
Target-class-tuned empirical scoring; reproduce dkoes terms or a custom-fit set.
import subprocess
with open("my_scoring.txt", "w") as f:
f.write("""\
-0.035579 gauss(o=0,_w=0.5,_c=8)
-0.005156 gauss(o=3,_w=2,_c=8)
0.840245 repulsion(o=0,_c=8)
-0.035069 hydrophobic(g=0.5,_b=1.5,_c=8)
-0.587439 non_dir_h_bond(g=-0.7,_b=0,_c=8)
1.923 num_tors_div
""")
subprocess.run([
"smina", "-r", "receptor.pdbqt", "-l", "candidate.sdf",
"--custom_scoring", "my_scoring.txt",
"--autobox_ligand", "ref.pdb", "--autobox_add", "8",
"-o", "custom_docked.sdf", "--exhaustiveness", "16",
], check=True)
Recipe: Per-Atom Score Decomposition
Identify which ligand atoms drive binding.
import subprocess
from rdkit import Chem
subprocess.run([
"smina", "-r", "receptor.pdbqt", "-l", "pose.sdf",
"--score_only", "--atom_term_data", "-o", "pose_decomp.sdf",
], check=True)
mol = next(iter(Chem.SDMolSupplier("pose_decomp.sdf", removeHs=False)))
for prop in mol.GetPropNames():
if "atom_term" in prop:
print(f"{prop}: {mol.GetProp(prop)[:120]}")
Recipe: Re-Docking Validation
Re-dock the co-crystallized ligand; confirm RMSD < 2.0 Å.
import subprocess
from rdkit import Chem
from rdkit.Chem import AllChem
subprocess.run([
"smina", "-r", "receptor.pdbqt", "-l", "ref_ligand.sdf",
"--autobox_ligand", "ref_ligand.sdf", "--autobox_add", "8",
"-o", "redocked.sdf", "--exhaustiveness", "32",
"--num_modes", "1", "--seed", "42",
], check=True)
ref = next(iter(Chem.SDMolSupplier("ref_ligand.sdf", removeHs=False)))
docked = next(iter(Chem.SDMolSupplier("redocked.sdf", removeHs=False)))
rmsd = AllChem.GetBestRMS(ref, docked)
print(f"Re-docking RMSD: {rmsd:.2f} Å -> {'PASS' if rmsd < 2.0 else 'FAIL'}")
Expected Outputs
*_docked.sdf— multi-model SDF, one molecule per pose; affinity in<minimizedAffinity>, RMSD-to-best in<minimizedRMSD>screening_results.csv— name, SMILES, affinity (kcal/mol)*_receptor.pdbqt— prepared receptor- stdout — affinity table: pose, affinity (kcal/mol), rmsd lower/upper bound
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
smina: command not found |
Binary not on PATH | conda install -c conda-forge smina; in pixi env use pixi run smina |
Parse error on line ... (ligand) |
Malformed SDF/MOL2 | Re-export with RDKit SDWriter after AddHs + EmbedMolecule |
Could not figure out box dimensions |
Missing both --autobox_ligand and --center_x/--size_x |
Supply autobox reference or explicit grid |
| Highly positive affinities (>0) | Ligand outside grid or steric clash on input | Raise --autobox_add; verify reference pose is inside binding site |
--local_only returns input unchanged |
--minimize_iters 0 = "no minimization" in some builds |
Set --minimize_iters 1000 explicitly |
| Identical poses across runs | Fixed --seed + low --exhaustiveness |
Raise to 32+; vary --seed for ensemble |
| Receptor PDBQT generation fails | prepare_receptor not on PATH |
Install ADFR Suite, add <adfr>/bin to PATH |
| Empty SDF after dock | smina aborted silently | Drop capture_output, inspect proc.stderr; check receptor charges |
Score differs between --score_only and --local_only |
Local opt moves the pose | Expected — score-only for fixed pose, local-only for refined energy |
--custom_scoring file rejected |
Term typo or missing weight column | Each line: <weight><whitespace><term_name(args)> — whitespace strict |
| Per-atom decomposition empty | Used with --minimize / dock, not --score_only |
--atom_term_data most reliable with --score_only |
References
- smina on SourceForge — original distribution
- smina GitHub mirror — maintained builds
- Koes, Baumgartner, Camacho (2013) — smina + dkoes scoring paper, J Chem Inf Model
- Trott & Olson (2010) — upstream Vina paper, J Comput Chem
- ADFR Suite —
prepare_receptorfor receptor PDBQT - RDKit documentation — ligand 3D generation and SDF I/O
- Open Babel — alternative ligand format conversion


