Agent Skills › jaechang-hits/SciAgent-Skills

jaechang-hits/SciAgent-Skills

GitHub

用于Opentrons OT-2/Flex液路处理机器人的Python协议开发。支持移液、稀释、PCR及模块控制,提供本地模拟功能。多厂商自动化建议使用pylabrobot。

199 个 Skill 274

安装全部 Skills

npx skills add jaechang-hits/SciAgent-Skills --all -g -y
更多选项

预览集合内 Skills

npx skills add jaechang-hits/SciAgent-Skills --list

集合内 Skills (199)

用于Opentrons OT-2/Flex液路处理机器人的Python协议开发。支持移液、稀释、PCR及模块控制,提供本地模拟功能。多厂商自动化建议使用pylabrobot。
编写Opentrons机器人控制脚本 进行液体处理或PCR实验自动化 控制温度或磁力模块 在本地模拟Opentrons协议
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

  1. Load magnetic module with deep-well plate, reservoir with wash buffers
  2. Engage magnets → aspirate supernatant → dispense to waste
  3. Disengage magnets → add wash buffer → mix → engage → remove wash (repeat 2x)
  4. 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

  1. Always simulate first: Run opentrons_simulate my_protocol.py before uploading to the robot. Catches labware conflicts, volume errors, and tip shortages without wasting consumables.

  2. Use compound operations over basic: Prefer transfer(), distribute(), consolidate() over manual pick_up_tip/aspirate/dispense/drop_tip sequences — they handle tip management automatically.

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

  4. Track liquids for setup validation: Use define_liquid() and load_liquid() to enable volume tracking in the Opentrons App.

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

  6. Control flow rates for difficult liquids: Reduce aspirate speed for viscous solutions (glycerol, PEG), increase for water-like liquids.

  7. Use pauses for manual intervention: protocol.pause(msg=...) is safer than protocol.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

提供基于Plotly的交互式科学可视化能力,支持40+图表类型及悬停、缩放等功能。涵盖Plotly Express快速绘图与Graph Objects精细控制,适用于探索性分析、3D/地理数据展示及Web嵌入,推荐静态出版图使用matplotlib。
需要创建带有悬停提示和缩放功能的交互式图表 构建用于数据分析的多面板探索性仪表板 可视化3D数据或地理地图数据 将数据以可嵌入网页的HTML格式呈现
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 matplotlib instead
  • For statistical grammar-of-graphics style, use seaborn instead

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 15 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.01.0 Trace transparency for overlapping data

Best Practices

  1. Start with Plotly Express, customize with Graph Objects: px functions return go.Figure objects, so you can always add Graph Objects methods after. Don't start with go unless px genuinely 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)
    
  2. Use plotly_white template for publication figures: The default plotly template has a gray background that looks unprofessional in papers. plotly_white or simple_white gives a clean look.

  3. Always set explicit width/height for static export: Without dimensions, write_image uses the default viewport size which may not match your target (journal column width, slide dimensions).

  4. Use scale=2 or higher for print-quality images: Default scale=1 produces 72 DPI equivalent. For publications, use scale=3 (216 DPI effective).

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

基于Seaborn和Matplotlib的统计可视化技能,支持分布、关系、分类及回归图表生成,自动计算置信区间。适用于探索性数据分析、多面板绘图及相关热力图展示。
需要绘制统计图表 进行数据分布分析 生成相关性热力图 创建箱线图或小提琴图
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 pairplot for 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.13.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

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

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

  3. Use set_theme() once at the start: Set style, context, and palette globally before creating plots. Reset with sns.set_theme().

  4. Use "colorblind" palette for accessibility: sns.set_palette("colorblind") ensures your plots are distinguishable for readers with color vision deficiency.

  5. Always call plt.tight_layout() before saving: Prevents axis labels from being clipped. For figure-level functions, use g.tight_layout().

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

  7. Anti-pattern — wide-form data with semantic mappings: Functions like scatterplot(hue=...) require long-form data. Use pd.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

提供单细胞RNA测序细胞类型注释的最佳实践,涵盖基于标记基因、参考图谱和自动化分类的方法。强调区分细胞类型与状态,建议使用多标记面板,并指导根据数据特征选择合适策略。
单细胞RNA-seq数据分析 细胞类型注释 scRNA-seq聚类后处理 参考图谱映射
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:

  1. Always combine multiple approaches - Use marker-based validation even with automated tools
  2. Check cluster purity - Ensure clusters represent single cell types
  3. Validate with multiple marker sets - Don't rely on single markers
  4. Consider biological context - Tissue type, disease state, developmental stage
  5. Document confidence levels - Note uncertain annotations
  6. Use hierarchical annotation - Broad categories first, then subtypes

Don'ts:

  1. Don't over-cluster - Too fine resolution creates artificial distinctions
  2. Don't ignore batch effects - Correct before annotation
  3. Don't trust automation blindly - Always validate predictions
  4. Don't mix cell states with cell types - Activated vs. resting cells are states, not types
  5. Don't annotate low-quality cells - Remove them first

Common Pitfalls

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

Marker Databases & Atlases:

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

基于PyMC 5的贝叶斯建模技能,涵盖模型定义、NUTS/ADVI采样、诊断(R-hat, ESS)、LOO/WAIC比较及预测。支持层级、逻辑回归等变体,强调不确定性量化与先验后验检查。
需要进行贝叶斯统计建模或参数估计 需要量化不确定性并计算可信区间 拟合层级或多水平模型 使用LOO-CV或WAIC进行模型比较 执行先验或后验预测检查
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

  1. Use weakly informative priors, not flat priors. Flat priors (Uniform over large ranges) cause sampling difficulties and are rarely appropriate.
  2. Always run prior predictive checks before fitting. If prior-implied data is implausible, adjust priors before sampling.
  3. Standardize continuous predictors to mean=0, sd=1 for better NUTS geometry and faster sampling.
  4. Use non-centered parameterization for hierarchical models: offset ~ Normal(0,1); param = mu + sigma * offset instead of param ~ Normal(mu, sigma).
  5. Run at least 4 chains for reliable R-hat and ESS diagnostics. Never trust a single chain.
  6. Check all three diagnostics (R-hat < 1.01, ESS > 400, 0 divergences) before interpreting posteriors.
  7. Include log_likelihood=True when fitting if you plan to compare models via LOO or WAIC.
  8. Use dims instead of shape for named dimensions -- integrates with ArviZ for labeled summaries and subsetting.
  9. Report credible intervals (HDI), not just point estimates. The posterior distribution is the result, not a single number.
  10. 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 original references/distributions.md (320 lines) and references/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-style shape=5), basic dims usage (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 original references/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):

  1. distributions.md (320 lines) -> consolidated with sampling_inference.md into new references/distributions_inference.md
  2. sampling_inference.md (424 lines) -> consolidated with distributions.md into new references/distributions_inference.md
  3. workflows.md (526 lines) -> migrated as new references/advanced_workflows.md

Original scripts/ (2 files):

  1. 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 in references/advanced_workflows.md; compare_prior_posterior() utility covered in references/advanced_workflows.md
  2. model_comparison.py (387 lines) -> compare_models() and check_loo_reliability() patterns inlined in Workflow Step 7 (using az.compare and az.loo directly); model_averaging() pattern covered in references/advanced_workflows.md; cross_validation_comparison() guidance covered in references/advanced_workflows.md; plot_model_comparison() is a thin wrapper around az.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 + x2 syntax); 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

基于scikit-learn的生存分析库,支持Cox、随机生存森林等模型处理右删失数据。提供C-index等评估指标及生存曲线估计,兼容Pipeline与GridSearchCV,适用于临床或可靠性场景的时间至事件建模。
需要进行生存分析或时间至事件建模 处理包含右删失数据的预测任务 计算C-index、Brier分数或绘制Kaplan-Meier曲线
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 pycox or torchlife

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

  1. Fit CoxnetSurvivalAnalysis with l1_ratio=0.9 (Lasso-dominant)
  2. Use GridSearchCV over alpha_min_ratio values with C-index scorer
  3. Extract non-zero coefficients from best model
  4. Refit standard CoxPH or RSF on selected features for final model
  5. 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

  1. Always standardize features for Cox and SVM models: Coefficients and margins are scale-dependent. Use StandardScaler inside a Pipeline to prevent leakage.

  2. Use Uno's C-index over Harrell's: More robust to censoring distribution differences; required for comparing models across datasets. Pass y_train as the first argument.

  3. Anti-pattern -- using built-in RSF feature importance: It is biased toward continuous and high-cardinality features. Use sklearn.inspection.permutation_importance() instead.

  4. Report multiple metrics: C-index alone measures only discrimination. Add Brier score for calibration and time-dependent AUC for time-specific performance.

  5. 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).

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

  7. Use scikit-learn Pipelines: Wrapping preprocessing + model prevents data leakage and simplifies cross-validation and deployment.

  8. Anti-pattern -- treating competing events as censored for KM: Kaplan-Meier overestimates event probability when competing risks exist. Use cumulative_incidence_competing_risks() instead.

  9. GBS early stopping: For GradientBoosting, set n_estimators=1000 with n_iter_no_change=10 to 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

指导统计学分析全流程,涵盖假设检验选择、前提条件验证、效应量计算及APA格式报告。支持频率学派与贝叶斯方法,提供t检验、ANOVA等常用测试的假设检查流程及效应量标准参考。
需要选择合适的统计检验方法 验证统计模型的前提假设 计算并解释效应量 生成符合APA规范的统计结果报告 进行功效分析或样本量估算
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:

  1. Independence: Observations are independent of each other
  2. Normality: Data (or residuals) are approximately normally distributed
  3. Homogeneity of variance: Groups have similar variances (for group comparisons)
  4. 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

  1. Pre-register analyses when possible to distinguish confirmatory from exploratory findings. Specify primary outcome, tests, and correction methods before data collection
  2. 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
  3. Report effect sizes with confidence intervals for every test. p-values alone are insufficient -- effect sizes convey practical importance
  4. Report all planned analyses including non-significant findings. Selective reporting inflates false positive rates
  5. 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
  6. Visualize data before and after analysis. Box plots for group comparisons, scatter plots for correlations, residual plots for regression diagnostics
  7. Conduct sensitivity analyses to assess robustness: re-run with outliers removed, different transformations, or alternative tests
  8. Anti-pattern -- p-hacking: Testing multiple outcomes, subgroups, or model specifications until p < .05 inflates false positives. Pre-register to avoid
  9. Anti-pattern -- HARKing (Hypothesizing After Results are Known): Presenting exploratory findings as confirmatory undermines scientific integrity
  10. 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

  1. 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 = ..."

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

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

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

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

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

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

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

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

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

  1. Define research question and hypotheses

    • State H0 and H1 explicitly
    • Specify primary outcome and covariates
  2. Select statistical test (use Decision Framework above)

    • Match test to data type, design, and assumptions
    • Plan multiple comparison corrections if needed
  3. 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
  4. 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.py script provides automated normality, homogeneity, and outlier detection with visualization
  5. 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
  6. 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)
  7. Conduct post-hoc and secondary analyses

    • Post-hoc pairwise comparisons (Tukey HSD, Bonferroni)
    • Sensitivity analyses (remove outliers, alternative methods)
    • Exploratory analyses (clearly labeled)
  8. 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.md for 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
用于Python统计建模,提供回归、离散选择和时间序列分析的严谨推断与诊断。强调系数解释和假设检验,区别于scikit-learn的预测导向。支持OLS、GLM、ARIMA等模型及R风格公式接口。
需要进行线性回归或广义线性模型的统计推断 执行时间序列分析如ARIMA建模 检查模型假设如异方差性或自相关 计算置信区间、P值或进行假设检验
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-learn instead
  • For Bayesian modeling with posterior inference, use pymc instead

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

  1. Always add a constant for OLS/GLM: sm.add_constant(X) or use formula API (adds intercept automatically)

  2. Match model to outcome type: Binary → Logit/Probit, Counts → Poisson/NegBin, Continuous → OLS/WLS, Time series → ARIMA

  3. Check diagnostics before interpreting: Run Breusch-Pagan (heteroskedasticity), Jarque-Bera (normality), Ljung-Box (autocorrelation) on residuals

  4. Use robust SEs when assumptions fail: results = model.fit(cov_type="HC3") for heteroskedasticity-robust inference

  5. Report effect sizes, not just p-values: Include coefficients, confidence intervals, and R² alongside significance tests

  6. Prefer formula API for exploratory work: smf.ols("y ~ x1 * x2 + C(group)", data=df) is more readable and handles categoricals automatically

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

基于深度学习的细胞/细胞核分割工具,适用于荧光和明场显微图像。提供预训练模型,无需重新训练即可泛化不同染色条件。输出标签掩码,支持形态测量、追踪及3D体数据分割,替代传统阈值方法。
需要分割荧光或明场显微镜下的细胞或细胞核 处理大规模显微数据集且希望免参数调整 进行细胞形态测量或追踪前的预处理 传统阈值分割因细胞粘连或强度变化失效时
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

FlowIO用于读取和写入流式细胞术FCS文件,支持v2.0-3.1版本。可将事件数据提取为NumPy数组,解析通道元数据,处理多数据集文件,并支持转换为CSV或创建新FCS文件,适用于数据预处理管道。
解析FCS文件以提取事件数据 读取流式细胞术文件的通道元数据 将流式数据转换为NumPy数组或CSV 批量处理FCS文件目录
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:

  1. Gain scaling: Multiply by PnG gain values
  2. Log transform: Apply PnE exponential transform if present (value = a × 10^(b × raw))
  3. 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

  1. Use only_text=True for metadata scanning: When processing many files, skip DATA segment parsing for 10-100x speedup.

  2. Use preprocess=False for data modification: Always work with raw values when filtering/modifying events, then re-export. Preprocessing is irreversible.

  3. Anti-pattern — modifying flow.events directly: FlowIO does not support in-place event modification. Extract with as_array(), modify, then create_fcs() to save.

  4. Preserve metadata on re-export: Pass flow.text as metadata to create_fcs() to retain original acquisition info.

  5. Check for multi-dataset files: Catch MultipleDataSetsError and use read_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

napari是用于显微图像的交互式多维查看器,支持2D/3D/4D数据及多种图层类型。适用于生物图像质检、深度学习标注、多通道叠加及插件分析。提供Python原生可视化,支持无头环境截图,与ImageJ/FIJI互补。
需要查看或检查显微镜图像和分割掩码 为深度学习模型标注训练数据 叠加显示多个荧光通道(如DAPI, GFP) 浏览3D堆栈或4D时间序列实验 在Python生物图像分析流程中进行交互可视化
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

提供生物图像预处理、特征检测及实时显微镜视频分析的计算机视觉技能。利用OpenCV C++加速实现荧光/明场图像增强、形态学分割、轮廓检测、模板匹配和光流追踪,适用于快速预处理和实时成像场景。
生物图像预处理 细胞轮廓或斑点检测 实时显微镜视频流处理 荧光图像背景扣除或CLAHE增强 基于传统方法的细胞追踪
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-python for 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

PyImageJ是连接Python与ImageJ2/Fiji的桥接工具,支持在Python中调用Fiji插件(如TrackMate、Bio-Formats)、执行宏脚本及Ops操作。实现NumPy数组与ImageJ数据结构的互转,适用于批量处理、自动化分析及结果导出至Pandas。
需要在Python中调用Fiji特定插件进行图像处理 通过Python自动化执行ImageJ宏或批处理任务 需要将NumPy数组转换为ImageJ格式以利用其生态工具
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 .ijm macro files as part of a Python workflow without rewriting them
  • Use scikit-image instead when you need pure Python processing without Fiji plugins — scikit-image is faster to install and avoids JVM overhead
  • Use napari instead 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.510.0 Spatial smoothing radius in pixels; higher reduces noise but blurs edges
minSize (Analyze Particles) Plugin / Macro 0 pixels² or 0Infinity 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

  1. Initialize once per session and reuse ij: Starting a new JVM is expensive (5–15 seconds). Create ij at module level or pass it as a parameter rather than calling imagej.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
        ...
    
  2. Use conda for Java management: pip-only installs frequently fail because JAVA_HOME is not set or the wrong JDK version is on PATH. A conda environment with openjdk=11 avoids 90% of JVM-not-found errors.

  3. Clear Results and ROI Manager between images in batch loops: ImageJ accumulates results across calls in the same session. Always call run("Clear Results") and rm.reset() before each image to prevent row contamination.

    ij.macro.run("run('Clear Results');")
    rm = get_roi_manager(ij)
    rm.reset()
    
  4. Prefer ij.macro.run() for simple commands over ij.command().run(): Macro strings are shorter, easier to read, and use the same syntax as the Fiji Macro Recorder. Use ij.command().run() only when you need programmatic access to command outputs (module return values).

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

  6. Use ij.py.to_java() / ij.py.from_java() for ImgLib2 Ops: The to_imageplus() / from_imageplus() pair works with the classic ImageProcessor; to_java() / from_java() target the modern ImgLib2 type system required by ij.op().

  7. Set a Fiji update site plugins list during init for reproducibility: Specify a pinned Fiji version (sc.fiji:fiji:2.14.0) rather than sc.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

用于生物图像分析的科学图像处理技能。支持显微镜图像的读取、去噪、分割(阈值/分水岭)、形态学操作及对象测量。基于NumPy数组,适用于荧光显微和组化分析,提供预处理到特征提取的完整流程。
需要处理荧光显微镜或组织切片图像时 执行细胞核或细胞器的图像分割任务时 计算图像中对象的面积、强度或形状特征时 对生物图像进行去噪、背景扣除或形态学滤波时
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 OpenCV instead for real-time video processing or GPU-accelerated operations
  • For deep-learning cell segmentation, use CellPose instead (better accuracy for touching cells)
  • Use napari instead for interactive multi-dimensional image visualization and annotation
  • For whole-slide image tiling, use PathML or histolab instead

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

  1. Always check dtype before processing: Operations like subtraction on uint8 silently clip to 0. Convert to float: img = img_as_float(img) as the first step.

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

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

  4. Use watershed for touching objects: Simple thresholding cannot separate touching nuclei. Always apply watershed with distance transform markers for densely packed cells.

  5. Measure in physical units: Convert pixel measurements to microns using the pixel size from microscope metadata: area_um2 = area_px * pixel_size_um**2.

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

trackpy是用于视频显微镜下单粒子追踪的Python库。基于Crocker-Grier算法实现亚像素定位、轨迹链接及MSD计算,支持2D/3D数据与漂移校正,适用于定量分析扩散系数及运动模式。
需要提取荧光显微镜视频中单个粒子的运动轨迹 计算均方位移(MSD)和扩散系数以分析布朗运动或受限运动 处理3D共聚焦Z堆栈的时间序列数据 对大量轨迹进行集合平均以提取群体扩散行为
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 napari with napari-trackpy instead 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=True for dark spots on bright background)
  • Environment: Works in Jupyter notebooks and scripts; pims handles most microscopy formats; for ND2 or CZI files install pims-nd2 or aicsimageio
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 150 pixels Max displacement between frames. Set to ~1.5× max expected per-frame movement
memory link 0 010 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

  1. Always tune diameter and minmass on a single frame first: Run tp.locate() on one representative frame and use tp.annotate() to visually check detections before committing to tp.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
    
  2. Set search_range conservatively: Too large a search range causes spurious links between unrelated particles in dense samples. Estimate typical per-frame displacement from tp.locate() output scatter before linking.

  3. Subtract drift before computing MSD: Stage drift inflates MSD, causing overestimation of D. Always call tp.compute_drift() + tp.subtract_drift() before tp.emsd().

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

  5. Do not mix mpp units between locate and MSD steps: tp.locate() returns positions in pixels. mpp is applied only in tp.imsd()/tp.emsd(). Avoid rescaling positions manually before linking, as this breaks the pixel-unit search_range.

  6. For 3D tracking with anisotropic voxels: Pass diameter and search_range as tuples matching (z, y, x) axis order. The z-step is usually 2-5× coarser than xy pixel size; set the z component of diameter and search_range accordingly.

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

用于生成出版级科学图表的Python绘图技能。支持线图、散点图、热力图等,提供精细元素控制、多面板布局及3D绘图功能。兼容NumPy/Pandas,可导出PDF/SVG等矢量格式,适用于学术论文插图制作。
需要绘制高质量科学图表 要求精确控制图形元素细节 构建复杂的多面板子图布局 需要将图表导出为PDF或SVG矢量格式 创建3D表面或散点图
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 seaborn instead
  • For interactive/web-based plots with hover and zoom, use plotly instead

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

  1. Always use the OO interface (fig, ax = plt.subplots()) for production code. Reserve plt.plot() for quick interactive exploration only

  2. Use constrained_layout=True to prevent overlapping labels and titles:

    fig, ax = plt.subplots(figsize=(8, 5), constrained_layout=True)
    
  3. Choose accessible colormaps: Use viridis, cividis, or plasma (perceptually uniform, colorblind-safe). Avoid jet and rainbow

  4. Close figures after saving to prevent memory leaks in loops:

    plt.close(fig)  # After savefig
    
  5. Set DPI appropriately: 300 for print/journal, 150 for web/slides, 72 for screen-only

  6. Use rasterized=True for large datasets to reduce PDF/SVG file size:

    ax.scatter(x, y, rasterized=True)  # Vector labels + rasterized data
    
  7. 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

提供基于Plotly的交互式科学可视化技能,支持px和go双API。适用于火山图、剂量反应仪表盘、表达热图及3D分子视图等场景,支持悬停、缩放及HTML导出,与seaborn/matplotlib互补。
需要交互式图表展示基因数据或元数据 构建多面板交互式仪表盘 生成可分享的HTML可视化文件 绘制3D散点或曲面图 创建需缩放的热图或关联矩阵
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 seaborn instead when you need automatic statistical aggregation (confidence intervals, regression fits) with minimal code.
  • Use matplotlib when 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.01.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 14 Resolution multiplier for PNG export (use 2 for ~150 DPI)

Best Practices

  1. Prefer px for DataFrame data, fall back to go for multi-trace composition. Use px.scatter() and its siblings for 80% of plots. Switch to go when 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, ...))
    
  2. Always include hovertemplate for scientific figures. The default tooltip shows raw coordinates without units or gene names. A custom template with customdata provides 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>",
    ))
    
  3. Export HTML for sharing, PNG/SVG for journals. fig.write_html() produces a self-contained file with no external dependencies. Use scale=2 or higher with write_image() to achieve sufficient resolution for print.

  4. 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. Use write_html() or write_image() exclusively.

  5. Use color_continuous_midpoint=0 for 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)
    
  6. 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

指导科学可视化制作,涵盖按数据结构选择图表、无障碍配色理论、多面板布局及期刊格式要求,旨在提升数据呈现的清晰度与准确性。
准备发表论文或会议报告的图表 需要选择合适的数据可视化类型 设计符合学术规范的科学插图
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

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

  2. Choose CVD-safe color palettes by default: Use Okabe-Ito or viridis/cividis for sequential data. Test your figure with a CVD simulator (e.g., Coblis) before submission.

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

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

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

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

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

  1. 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.
  2. 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.
  3. 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, or inferno for sequential; RdBu or coolwarm for diverging data. These are the defaults in seaborn >= 0.12.
  4. 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.
  5. 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.
  6. 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.
  7. 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.rcParams style dictionary at the top of your figure script and apply it to all panels.

Workflow

  1. 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.
  2. Prepare the data

    • Aggregate, tidy, and validate your data before plotting.
    • Check for outliers that should be investigated, not silently dropped.
  3. 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.
  4. 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).
  5. 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.
  6. 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.
  7. Accessibility check

    • Simulate CVD using an online tool or GIMP color blindness filter.
    • Print in grayscale and verify panels remain distinguishable.

Protocol Guidelines

  1. For matplotlib/seaborn: Set rcParams at the top of every figure script; use fig.set_size_inches() to enforce journal dimensions; export with dpi=300 minimum.
  2. For R/ggplot2: Use theme_classic() or a custom theme; set ggsave(width=..., units="mm", dpi=300).
  3. For multi-panel assembly: Use matplotlib.gridspec, patchworklib (Python), or cowplot/patchwork (R) for aligned panel grids.
  4. For interactive figures: Plotly or Bokeh for HTML outputs; Vega-Lite for web embedding. Always provide a static fallback for publication.
  5. 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

Related Skills

  • matplotlib-figures — Python implementation of publication-quality figures with matplotlib and seaborn
  • data-visualization — general Python plotting recipes
  • biostatistics — statistical test selection to accompany figure annotations
基于matplotlib和pandas的统计可视化库,支持自动聚合、置信区间及分组。涵盖分布、分类、关系、回归、矩阵等图表类型,适用于基因表达比较、相关性分析及探索性数据分析,提供出版级静态图。
生成箱线图或小提琴图以展示数据分布和统计摘要 绘制散点图或回归线分析变量间关系 使用热图或聚类图可视化相关性矩阵 进行多变量配对关系的快速探索性分析
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 pairplot to survey all pairwise relationships at once
  • Use matplotlib directly when you need pixel-level control over figure elements, complex mixed-type layouts, or non-statistical custom plots
  • Use plotly when 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.13.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

  1. 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. Use pd.melt() to convert wide-form data.

  2. 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 with sns.set_theme().

    sns.set_theme(style="ticks", context="paper", font_scale=1.1,
                  rc={"axes.spines.right": False, "axes.spines.top": False})
    
  3. Use axes-level functions for mixed-type custom layouts: Figure-level functions (relplot, catplot) create their own figure and cannot be placed in an existing Axes. When combining different plot types (e.g., scatter + violin + heatmap), allocate a plt.subplots() grid and use axes-level functions with ax=.

  4. Use colorblind-safe palettes: sns.set_palette("colorblind") or palette="colorblind" produces a palette distinguishable by readers with common color vision deficiencies. For diverging data, use "RdBu_r" or "coolwarm" with center=0.

  5. Overlay individual data points on summary plots: Violin and bar plots hide distribution shape and sample size. Overlaying a stripplot or swarmplot with alpha=0.4 and small size conveys data density without obscuring the summary statistic.

  6. Size figure-level plots with height and aspect, not figsize: Figure-level functions ignore figsize. Use height= (inches per panel) and aspect= (width-to-height ratio per panel). For axes-level, set figsize on the plt.subplots() call.

  7. Anti-pattern — calling plt.savefig() on a figure-level grid: Figure-level functions return a FacetGrid/JointGrid object. Save it with g.savefig("out.png", dpi=300, bbox_inches="tight"), not plt.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

指导在箱线图等比较图中添加统计显著性标记(星号)。涵盖p值转换、多重比较校正、选择关键对比组及matplotlib实现,确保图表符合出版标准。
需要在统计比较图中添加显著性标记 准备发表级别的科学图表 处理多组比较的p值标注
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

  1. 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.
  2. 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.
  3. Limit annotated pairs: Annotate only comparisons relevant to the analysis conclusion. Over-annotating clutters the figure and dilutes focus.
  4. 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.
  5. 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.
  6. Include sample sizes: Show n per group in the axis labels (e.g., "Control (n=30)") or figure legend.
  7. Use bold titles: Set fontweight='bold' on figure titles for publication readiness.

Common Pitfalls

  1. 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.
  2. 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 (DESeq2 padj).
  3. 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.
  4. 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").
  5. Inconsistent notation across figures

    • How to avoid: Use the same pvalue_to_asterisk() function throughout the analysis. Define it once and reuse.
  6. 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.
  7. 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

  1. 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.
  2. Use consistent style: Use the same add_significance_bracket function and pvalue_to_asterisk conversion across all figures in an analysis.
  3. 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.
  4. Adjust y-axis limits: After adding brackets, extend the y-axis upper limit to prevent clipping. Use ax.set_ylim(top=...) or ax.margins(y=0.15).
  5. For DESeq2/edgeR results: Use padj (adjusted p-value) directly. Do not re-test the raw counts.

Further Reading

Related Skills

  • seaborn-statistical-plots — Seaborn plotting fundamentals; use this guide's annotation workflow on top of seaborn figures
  • scientific-visualization — General scientific figure design principles
BWA-MEM2是高速短读长DNA比对工具,适用于WGS/WES/ChIP-seq等场景。比原版快2倍,输出含RG头的GATK兼容SAM/BAM文件。支持构建索引、配对末端比对及排序。需安装bwa-mem2和samtools,准备参考基因组。
需要对Illumina短读长DNA序列进行全基因组或外显子组比对 需要生成符合GATK标准的带有Read Group信息的BAM文件 需要进行ChIP-seq或ATAC-seq的DNA序列比对以用于峰调用
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 @RG read 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 / conda env). Run command -v bwa-mem2 first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run bwa-mem2 rather than bare bwa-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

提供Pythonic接口读写SAM/BAM/CRAM、VCF/BCF及FASTA/FASTQ文件。支持区域查询、pileup统计、变异过滤及NG质控。基于htslib,暴露samtools/bcftools功能,适用于构建生物信息学分析流程。
读取或写入BAM/SAM/CRAM对齐文件 分析VCF/BCF变异数据 提取FASTA参考序列 计算覆盖度或pileup统计 进行NGS数据质量控制
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

  1. Always use context managers (with statement) for automatic file cleanup. Unclosed files can leak file descriptors.

  2. Create and verify index files first: Most random-access operations fail silently or raise cryptic errors without indexes. Check for .bai/.tbi/.fai files before queries.

  3. Use count() instead of iterating to count reads: bam.count("chr1", 1000, 2000) is much faster than sum(1 for _ in bam.fetch(...)).

  4. Use count_coverage() for coverage, pileup() for base-level detail: count_coverage() is faster when you only need depth numbers. Use pileup() only when you need per-read, per-base information.

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

  6. Anti-pattern — forgetting truncate=True in pileup: Without truncate=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

用于处理SAM/BAM/CRAM比对文件的CLI工具集,涵盖排序、索引、格式转换、质控及去重等NGS流程核心操作。
需要对BAM文件进行坐标排序或创建索引 需要将SAM/BAM/CRAM格式相互转换以节省空间 需要执行比对数据的质控统计(如flagstat) 需要根据映射质量或区域过滤读取数据 需要在变异检测前标记或去除PCR重复
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 pysam instead for Python-native BAM manipulation in custom scripts
  • Use deeptools bamCoverage instead when you need normalized bigWig coverage tracks
  • Use mosdepth instead 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 faidx for FASTA indexing; samtools sort before samtools index

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v samtools first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run samtools rather than bare samtools.

# 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 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

  1. Always sort before indexing: samtools index requires coordinate-sorted input. Attempting to index an unsorted BAM will fail or produce incorrect results.

  2. Use -@ for all production runs: Most samtools commands are I/O-bound. Adding -@ 8 provides near-linear speedup for compression/decompression with minimal overhead.

  3. Run flagstat before any analysis: samtools flagstat runs in seconds and catches alignment failures (low mapping rate, unexpected paired-end rates) before wasting time on downstream steps.

  4. Use the collate → fixmate → sort → markdup pipeline: Running samtools markdup directly on coordinate-sorted BAM without fixmate produces incorrect duplicate detection. The mate information added by fixmate -m is essential.

  5. Prefer CRAM for archiving: CRAM reduces storage 40-50% vs BAM with no loss. Always store the reference FASTA alongside CRAM files.

  6. Use -L bed_file for targeted analyses: Restricting samtools view to 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

STAR 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.
RNA-seq reads alignment to reference genome BAM file generation for downstream analysis like variant calling or visualization Discovering novel splice junctions and alternative splicing events ENCODE-compliant RNA-seq pipeline execution Gene count table generation alongside BAM alignment
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 / conda env). Run command -v STAR first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run STAR rather than bare STAR.

# 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

用于细菌和古菌基因组及质粒的快速标准化注释。整合Prodigal等工具识别CDS、RNA等特征,生成NCBI兼容格式(GFF3/GenBank)及JSON摘要和环状图,支持MAGs分析。
需要对细菌或古菌基因组进行功能注释 需要生成NCBI兼容的GFF3或GenBank文件 需要为质粒或环状复制子添加特定标记 需要生成基因组环状可视化图表
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 --plasmid and --complete flags
  • 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_plot command
  • Annotating MAGs (metagenome-assembled genomes) with --meta to 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 / conda env). Run command -v bakta first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run bakta rather than bare bakta.

# 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

使用Prokka对细菌、古菌及病毒基因组进行快速注释,识别CDS及非编码RNA。支持多种输出格式,适用于新组装基因组分析、比较基因组学及MAG注释。
对新组装的细菌或古菌基因组进行功能注释 从草稿或完整基因组获取蛋白质功能注释(如EC号、GO术语) 为比较基因组学(如Roary)准备数据 对病毒或噬菌体基因组进行注释 对宏基因组组装基因组(MAG)进行注释
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 --metagenome flag
  • 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 / conda env). Run command -v prokka first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run prokka rather than bare prokka.

# 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-41e-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

用于计算细菌泛基因组,基于GFF3注释生成基因存在/缺失矩阵、核心/附属基因分区及多序列比对。适用于10-10,000个菌株的比较基因组学和系统发育分析。
需要计算细菌泛基因组 根据Prokka或Bakta的GFF3文件生成基因存在/缺失矩阵 构建用于系统发育推断的核心基因多FASTA比对 生成非冗余基因目录作为泛基因组参考
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.csv matrix 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 -e core 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.gff style

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v roary first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run roary rather than bare roary.

# 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 7099 (% identity) Minimum BLASTP percentage identity for clustering
-cd 99 90100 (%) 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.25.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

基于Dask并行加速的GRN推断工具,支持GRNBoost2和GENIE3算法。用于从单细胞或批量RNA-seq数据中推断转录因子与靶基因的调控关系,生成网络邻接矩阵,是pySCENIC流程的核心组件。
需要推断基因调控网络(GRN) 分析转录因子与靶基因的关系 执行pySCENIC流程的第一步
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, matplotlib for 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, importance
  • filtered_network.tsv -- high-confidence subset after threshold or top-N filtering
  • consensus_network.tsv -- aggregated network from multi-seed runs; columns: TF, target, mean_importance, n_seeds
  • adjacencies.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
Biopython分子生物学工具包,提供序列操作、文件格式解析、NCBI数据库访问、BLAST搜索、蛋白质结构分析及系统发育树构建等功能。适用于生物信息学批量处理、格式转换及自定义流水线开发。
需要解析或转换FASTA、GenBank等生物文件格式 从NCBI获取序列或文献数据 执行BLAST搜索或序列比对 分析蛋白质三维结构或计算序列统计量
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 pysam instead for reading SAM/BAM/CRAM alignment files and working with mapped reads; use scikit-bio instead 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的序列分析能力,涵盖NCBI数据检索、格式解析、BLAST比对、多序列对齐及系统发育树构建。适用于基因家族研究、比较基因组学及NCBI流水线处理,聚焦从数据获取到进化推断的分析工作流。
从NCBI下载并比对基因家族序列 解析GenBank或GFF文件提取CDS 运行BLAST搜索并获取完整序列 构建系统发育树 进行大规模FASTA/FASTQ文件的随机访问索引
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 nt or nr, 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.Applications wrappers)
  • NCBI access: Set Entrez.email before 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 / conda env). Run command -v python first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run python rather than bare python.

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 110000 Max IDs returned per search; use history server for >10000
hitlist_size NCBIWWW.qblast() 50 15000 Max BLAST alignments returned
expect NCBIWWW.qblast() 10.0 1e-1001000 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

  1. Always set Entrez.email and respect rate limits. NCBI blocks IPs that exceed 3 req/s without a key. Add time.sleep(0.4) between requests in loops, or use Entrez.api_key for 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
    
  2. Use SeqIO.index() for large FASTA files instead of list(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
    
  3. Use PairwiseAligner not the legacy pairwise2. Bio.pairwise2 is deprecated since Biopython 1.80 and will be removed. PairwiseAligner is faster, supports substitution matrices directly, and returns Alignment objects with coordinate arrays.

  4. 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
    
  5. 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 with makeblastdb and call blastp/blastn via subprocess with -outfmt 5 (XML) for NCBIXML parsing.

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

  7. Strip alignment gaps before building SeqRecord collections. BLAST and alignment results may include gap characters (-). Seq operations like .translate() will raise errors on gapped sequences; strip with seq.replace("-", "") or use ungap().

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

查询ARCHS4 REST API,获取100万+人类/小鼠RNA-seq样本的基因表达谱、组织特异性Z评分、共表达网络及元数据搜索。支持无认证访问,适用于基因表达验证、候选基因筛选及大规模跨研究分析。
查询特定基因在多种组织中的表达水平或Z评分 寻找与目标基因共表达的基因列表 根据组织、疾病等元数据搜索RNA-seq样本 比较多个基因的组织表达差异以筛选湿实验候选者
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 1500 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.01.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

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

  2. Sleep between batch queries: ARCHS4 enforces a soft rate limit of ~10 requests/second. Add time.sleep(0.1) between sequential gene queries to avoid 429 Too Many Requests errors.

  3. 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 h5py slicing for fast matrix access; this avoids hitting rate limits and is 100× faster for bulk extraction.

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

  5. 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 genes
  • gget-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

提供统一Python接口访问40+生物信息数据库(UniProt、KEGG等),支持蛋白查询、通路发现、ID映射及BLAST搜索。适用于跨库工作流,单库深度查询建议用专用工具。内置缓存与速率限制。
跨数据库生物数据查询 蛋白质信息与通路分析 生物标识符批量转换
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

  1. Always set verbose=False in production — default True floods stdout with HTTP details. Use True only for debugging failed requests

  2. 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)
    
  3. 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

  4. Cache service objects, not results — create one UniProt() instance and reuse it across calls. Each constructor call initializes a new HTTP session

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

  6. Specify organism in UniProt searches — unfiltered searches return results across all species. Add AND organism_id:9606 for 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 5
  • pathway_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

通过cBioPortal REST API获取癌症基因组数据(突变、CNAs、表达及临床信息),支持TCGA等研究。用于TMB计算、生存分析及基因变异与临床结局关联,需Python环境及Gene符号。
查询癌症类型或研究列表 检索特定基因的体细胞突变谱 获取拷贝数变异(CNA)数据 提取临床生存或分期数据 下载基因表达数据进行差异分析
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-database instead 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 StudiesMolecular ProfilesSample-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

  1. Fetch sample IDs before data queries: All mutation and molecular data endpoints require explicit sampleIds. Retrieve them with GET /studies/{studyId}/samples?projection=ID before each query.

  2. Verify profile IDs from the API: Profile IDs are not guaranteed to follow the _mutations / _gistic pattern in every study. Always confirm with GET /molecular-profiles?studyId={studyId} rather than guessing.

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

  4. Use Entrez IDs, not Hugo symbols, in data fetch endpoints: The mutation and molecular data endpoints accept entrezGeneIds (integers). Resolve symbols first with POST /genes/fetch.

  5. 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/fetch to 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 cBioPortal
  • pydeseq2-differential-expression — differential expression analysis that can be applied to cBioPortal RNA-seq exports

References

通过NCBI E-utilities查询ClinVar数据库,获取变体的临床意义、致病性及疾病关联。支持按基因、rsID或条件搜索,返回ClinSig和提交者数据。需配置邮箱及处理速率限制。
查询特定变体(如rsID)的临床显著性分类 检索某基因下的所有致病性或可能致病性变体 识别不同提交实验室间的解释冲突 构建优先筛选临床可操作变体的过滤管道
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 use gwas-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 email parameter)
  • 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 110000 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

  1. Always set email: NCBI requires an email in all E-utility calls for rate-limit attribution and policy compliance.

  2. Use FTP bulk download for large queries: For more than ~1000 variants, download variant_summary.txt.gz from the ClinVar FTP rather than looping over EFetch — it's faster and avoids rate limits.

  3. Filter by review status: Automated pipelines should filter to ≥2-star variants to reduce noise from single-submitter assertions without peer review.

  4. Use API key for production: Register at https://www.ncbi.nlm.nih.gov/account/ to get a free API key (api_key parameter) and triple your rate limit (3 → 10 req/s).

  5. 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 curation
  • cosmic-database — Somatic cancer variant database (complementary to ClinVar's germline focus)
  • pubmed-database — Retrieve supporting publications cited in ClinVar submissions

References

用于查询COSMIC癌症体细胞突变、基因普查、突变特征及耐药变异的技能。支持REST API v3.1进行基因/样本/变异查询,需注册获取API密钥。
查询特定癌症基因的体细胞突变频率和分布 检索基因的所有体细胞突变数据 获取Cancer Gene Census分类信息 查找突变特征归属 识别药物耐药变异
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 use opentargets-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 1200 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

  1. Authenticate via Base64: COSMIC uses HTTP Basic Auth with base64-encoded email:password. Store credentials in environment variables, not in code.

  2. Paginate large gene queries: Popular cancer genes (TP53, KRAS) have 100,000+ mutation records; use skip/limit pagination and cache results locally.

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

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

  5. Complement with clinical data: COSMIC captures somatic mutations from cancer sequencing; complement with clinvar-database for germline pathogenicity and opentargets-database for 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 focus
  • opentargets-database — Drug-target associations for COSMIC cancer driver genes
  • ensembl-database — Variant consequence predictions (VEP) for COSMIC variants
  • gwas-database — Population-level SNP associations for cancer risk (vs. COSMIC's somatic mutations)

References

通过NCBI E-utilities或Variation Services REST API查询dbSNP数据库,支持按rsID、基因或区域检索SNP记录,获取等位基因频率、变异类型及临床关联信息。
根据rsID查询SNP详细信息 按基因或染色体区域搜索遗传变异 解析rsID对应的基因组坐标和HGVS表示法 批量获取多个rsID的变体数据
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 email parameter)
  • 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_KEY to 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-database for 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 110000 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

  1. Register for a free NCBI API key: Adds api_key=YOUR_KEY to requests and triples your rate limit (3 → 10 req/sec) with no other changes. Register at https://www.ncbi.nlm.nih.gov/account/.

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

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

  4. Check snp_class before 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.

  5. Combine dbSNP with ClinVar lookups: dbSNP records the clinical_significance field as a string (e.g., "pathogenic") but does not contain submitter details, review status, or HGVS details. Use clinvar-database for 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 studies
  • ensembl-database — Ensembl REST API for variant consequences and gene annotations
  • snpeff-variant-annotation — Annotate VCF files with SnpEff and SnpSift, which adds dbSNP rsIDs and functional predictions

References

提供DepMap CRISPR基因效应(Chronos)分析指南,重点说明必需性符号约定、逐基因NaN安全的Spearman相关性计算及数据加载对齐。
DepMap CRISPR数据分析 基因必需性相关性计算 Chronos分数符号解释
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

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

  2. 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 using scipy.stats.spearmanr with pairwise-complete observations.

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

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

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

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

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

  1. 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.
  2. Using bulk DataFrame correlation methods: Methods like DataFrame.corrwith(method='spearman') or DataFrame.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.
  3. 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.
  4. 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.
  5. 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."
  6. 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) and common_genes = expr.columns.intersection(crispr.columns), then subset both DataFrames before any computation.
  7. Ignoring the gene column format: DepMap gene columns use the format GENE_NAME (ENTREZ_ID). Attempting to match against plain gene symbols (e.g., TP53 instead of TP53 (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].

Workflow

  1. 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"
    
  2. 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]
    
  3. 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}")
    
  4. Step 4: Negate CRISPR scores if computing essentiality correlations

    # Negate: in DepMap, negative raw score = essential
    # After negation, positive = essential
    essentiality = -crispr_aligned
    
  5. 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)
    
  6. 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.")
    
  7. 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 issue
    

    If any of these patterns appear in the code, replace them with the per-gene loop from Step 5.

Further Reading

Related Skills

  • nan-safe-correlation -- General techniques for NaN-safe correlation computation across omics datasets; this guide applies those principles specifically to DepMap CRISPR data
  • degenerate-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
提供欧洲核苷酸档案(ENA)的REST API访问能力,支持序列搜索、原始数据下载、组装检索及分类学查询。适用于生物信息学数据分析,需遵守50请求/秒限速,无认证要求。
查询ENA数据库中的测序研究或样本 下载FASTQ或BAM格式的原始测序数据 获取基因组组装质量统计信息 按 accession 检索核酸序列 探索生物分类谱系
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 bioservices instead
  • For NCBI-specific queries (PubMed literature, GenBank records), use pubmed-database or 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=0 for 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

  1. Use tax_tree() over tax_eq() for organism queries — it includes subspecies and strains automatically
  2. Request only needed fields — reduces response size and server load significantly
  3. Prefer JSON format for programmatic access; TSV for large bulk exports (lower overhead)
  4. Use limit=0 for streaming large result sets directly to file, avoiding memory issues
  5. Check fastq_ftp and submitted_ftp — some runs have submitted files but no processed FASTQ
  6. Verify downloads with MD5 — file reports include fastq_md5 for integrity checking
  7. 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

提供ENCODE Portal REST API访问,用于检索TF ChIP-seq、ATAC-seq等调控基因组实验数据。支持按细胞类型或基因查询峰值文件、cCRE注释及元数据,适用于变异注释和开放染色质分析。
下载特定转录因子和细胞类型的ChIP-seq峰文件 查找某细胞类型中的ATAC-seq/DNase-seq峰值以识别开放染色质区域 检索与特定基因组区域重叠的SCREEN cCRE注释 为变异注释流水线构建参考调控轨道
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-database instead 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-analysis instead 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 1500 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

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

  2. Filter by status=released and specify assembly: ENCODE stores data for multiple genome builds. Always include files.assembly=GRCh38 (or your target assembly) to avoid mixing coordinate systems from archived experiments.

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

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

  5. Check biosample_summary vs biosample_ontology.term_name: The biosample_summary field (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 data
  • gwas-database — NHGRI-EBI GWAS Catalog for published SNP-trait associations; combine with ENCODE peaks for functional annotation of GWAS hits
  • macs3-peak-calling — Call peaks from your own ChIP-seq or ATAC-seq BAM files; use ENCODE peaks as reference comparison sets
  • deeptools-ngs-analysis — Generate bigWig coverage tracks, correlation heatmaps, and profile plots from your own aligned BAM files

References

提供Ensembl REST API访问,用于查询300+物种的基因、转录本及变异注释。支持ID转换、序列获取、调控特征查询及比较基因组学分析,无需API密钥。
查询基因或转录本的官方注释信息 在HGNC符号与Ensembl ID之间进行转换 获取特定基因的基因组或蛋白质序列 查找SNP变异的后果及功能影响 查询指定区域的调控特征如启动子
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 pyensembl instead
  • For pathway and metabolic annotations, use kegg-database or reactome-database instead

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=1 and 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

  1. Use batch endpoints: POST /lookup/symbol/{species} and POST /vep/human/hgvs accept up to 1000 IDs; single-ID GET requests in a loop will hit rate limits quickly.

  2. Pin assembly version: For region-based queries always specify coord_system_version=GRCh38 (or use grch37.rest.ensembl.org for legacy coordinates) to avoid silent mismatch errors.

  3. 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)
    
  4. Use expand=0 for metadata: When you only need gene coordinates and biotype (not transcript details), keep expand=0 for smaller payloads and faster responses.

  5. Check canonical flag in VEP: VEP returns consequences for all overlapping transcripts; filter on tc.get("canonical") == 1 to 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 code
  • biopython-molecular-biology — Biopython's Entrez module for NCBI databases (alternative for RefSeq/GenBank queries)
  • kegg-database — Pathway/metabolic annotations for the same gene set
  • reactome-database — Pathway enrichment and hierarchy queries

References

通过NCBI E-utilities API查询基因信息,支持别名解析、ID获取、功能摘要及GO注释。适用于跨物种基因查询和下游数据关联。
需要解析基因符号或别名 查询基因官方ID 获取基因功能描述或GO注释
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_homolog retired with HomoloGene in 2019)
  • For expression profiles across conditions use geo-database; for variant annotations use clinvar-database or ensembl-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 email parameter)
  • 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 110000 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

  1. Always add alive[prop]: Discontinued gene records remain in the database. Without this filter, symbol searches may return outdated records.

  2. Use Gene IDs in pipelines: Downstream NCBI databases (ClinVar, dbSNP, GEO) accept Gene IDs; avoid re-searching by symbol in each call.

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

  4. 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_key parameter.

  5. 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 here
  • clinvar-database — Clinical variant data indexed by NCBI Gene IDs
  • ensembl-database — Complementary gene annotations with VEP and comparative genomics
  • biopython-molecular-biology — Biopython Entrez module wraps E-utilities with typed return values

References

通过GEOparse和E-utilities访问NCBI GEO数据库,支持按关键词搜索、下载GSE矩阵、解析GPL注释及提取GSM元数据。
需要搜索或下载基因表达数据集 处理GEO平台的微阵列或测序数据 进行基于GEO数据的元分析
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 110000 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

  1. Use silent=True in GEOparse: Suppresses verbose download progress; add your own print statement to confirm download.

  2. Cache downloads: GEOparse skips re-downloading if the .soft.gz file already exists in destdir. Set a shared destdir across sessions to avoid redundant downloads.

  3. Prefer Series Matrix for large datasets: For series with 100+ samples, download the _series_matrix.txt.gz directly from FTP rather than parsing individual GSM soft files—it's orders of magnitude faster.

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

  5. Check platform column names: GPL annotation table column names vary by platform (e.g., "Gene Symbol" vs "GENE_SYMBOL" vs "gene_id"). Always inspect gpl.table.columns before assuming field names.

  6. 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-seq
  • gene-database — NCBI Gene records with curated annotations for genes found in GEO studies
  • pubmed-database — Retrieve publications linked to GEO datasets via NCBI ELink
  • pydeseq2-differential-expression — Downstream differential expression analysis after loading GEO count data

References

gget提供统一CLI/Python接口访问20+基因组数据库。支持基因查询、序列获取、BLAST/BLAT搜索、AlphaFold结构预测、Enrichr富集分析、单细胞数据查询及癌症突变检索,适用于多组学数据分析场景。
需要跨物种查询基因信息或ID映射 执行BLAST或BLAT序列比对搜索 进行GO/KEGG基因集富集分析 查询单细胞转录组数据或疾病药物关联
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 biopython instead
  • For programmatic multi-database workflows with rate limiting, use bioservices instead

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 per gget.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

  1. Pin database versions for reproducibility: Use release=112 for Ensembl and census_version="2023-07-25" for CELLxGENE to ensure consistent results across analyses.

  2. Rate-limit batch queries: gget queries remote APIs. Add time.sleep(2) between BLAST/BLAT queries in loops. For gget.info(), limit to ~1000 IDs per call.

  3. Keep gget updated: Databases change their structure biweekly. Run pip install --upgrade gget regularly to avoid breakage from schema changes.

  4. Use Python interface for pipelines, CLI for exploration: Python functions return DataFrames suitable for chaining. CLI with -csv is better for quick one-off lookups.

  5. Check PDB before running AlphaFold: gget.pdb() is instant; AlphaFold prediction takes minutes to hours. Always check if the structure already exists in PDB.

  6. Use database shortcuts in enrichr: The shortcuts ('pathway', 'ontology', etc.) map to curated Enrichr libraries. For custom analyses, pass any Enrichr library name directly.

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

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

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

通过GraphQL API查询gnomAD v4数据,获取按祖先分层的等位基因频率、基因约束评分(pLI/LOEUF)及覆盖度。用于评估变异稀有性、进行负荷检验或过滤常见变异,辅助临床相关性的频率证据分析。
查询特定变异的群体等位基因频率 获取基因的pLI或LOEUF约束评分 评估候选变异是否足够稀有以具有临床意义 检查基因组区域的测序覆盖深度
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-G format, 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

  1. Use gnomad_r4 for GRCh38 analyses: gnomAD v4 is the most current dataset with 730K+ individuals. Use gnomad_r2_1 only when comparing to GRCh37-based variant calls.

  2. Use faf95.popmax for 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.

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

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

  5. 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 studies
  • ensembl-database — Ensembl VEP for variant consequence prediction and gene annotation
  • dbsnp-database — dbSNP for rsID lookup, variant classes, and cross-database ID mapping

References

提供NHGRI-EBI GWAS Catalog REST API访问,用于查询SNP与性状的关联、研究、变异及汇总统计。支持疾病关联搜索、多效性分析及PRS候选构建,无需认证,适用于遗传架构探索和功能注释前的数据获取。
查询特定性状或疾病的GWAS关联SNP 检索变异的多效性或效应大小 获取GWAS研究的元数据或摘要统计 构建多基因风险评分候选列表
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-database instead
  • 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 _embedded data and _links for pagination
  • Pagination: default 20 results per page; max 500 via size parameter

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_0001360 for 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

  1. Paginate large result sets: Default page size is 20; set size=500 and loop over pages for complete data retrieval. Check page.totalElements to know the full count before iterating.

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

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

  4. Be ancestry-aware: Effect sizes and allele frequencies vary across populations. Check the initialSampleSize and replicationSampleSize fields to understand the ancestry composition of each study.

  5. Add rate-limiting delays: Although no official limit exists, time.sleep(0.2) between requests prevents server overload and avoids temporary blocks.

  6. Cache frequently accessed data: Study and trait metadata rarely change. Cache results locally when running batch analyses to reduce redundant API calls.

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

通过REST API和pyJASPAR获取JASPAR转录因子结合谱。支持按名称、ID或物种查询PWM/PFM,扫描DNA序列寻找TFBS,浏览分类信息,适用于基序富集输入及调控序列分析。
查询特定转录因子的PWM或PFM矩阵 根据物种或结构类别检索结合谱 在DNA序列中扫描转录因子结合位点 构建基序库用于富集分析
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-analysis instead 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-database or regulomedb-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

  1. Pin matrix ID versions for reproducibility: Use MA0139.1 (not just CTCF) in scripts and manuscripts so results do not silently change across JASPAR releases.

  2. 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) or pseudocount = sqrt(sites) / 4.

  3. Use the CORE collection for standard analyses: UNVALIDATED profiles have not been manually curated and may contain lower-confidence motifs. Reserve UNVALIDATED for exploratory analyses.

  4. Respect pagination: JASPAR returns at most 100 results per page. Always follow the next URL in responses when building complete profile sets.

  5. 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 library
  • regulomedb-database — regulatory variant scoring using TF binding evidence overlapping JASPAR motifs
  • encode-database — download TF ChIP-seq peak files that can be cross-referenced with JASPAR profiles
  • remap-database — TF binding peak sets from ChIP-seq experiments for binding site validation
  • macs3-peak-calling — produce ChIP-seq peak BED files for downstream JASPAR motif enrichment

References

提供KEGG数据库的REST API查询能力,支持通路、基因、化合物等生物信息的检索与ID转换。适用于基因映射通路、代谢详情获取及药物相互作用检查,仅限学术用途,需遵守频率限制。
查询特定基因参与的生物通路 获取代谢通路的详细基因列表或结构信息 在不同数据库标识符(如NCBI、UniProt)间进行转换 检查药物之间的相互作用
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 bioservices instead
  • 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/ddi call (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:

  1. Metabolismmap001xx (Glycolysis, TCA cycle, amino acid metabolism)
  2. Genetic Information Processingmap030xx (Ribosome, Spliceosome, DNA repair)
  3. Environmental Information Processingmap040xx (MAPK signaling, ABC transporters)
  4. Cellular Processesmap041xx (Autophagy, Apoptosis, Cell cycle)
  5. Organismal Systemsmap046xx (Immune, Endocrine, Nervous)
  6. Human Diseasesmap052xx (Cancer, Neurodegenerative, Infectious)
  7. 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

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

  2. Anti-pattern — fetching all entries without filtering: Use kegg_list to enumerate IDs first, then kegg_get for specific entries. Avoid downloading entire databases when you need a subset.

  3. Parse tab-delimited output consistently: All KEGG responses use \t as field separator and \n as record separator. Always .strip() before splitting.

  4. Respect the 10-entry batch limit: kegg_get, kegg_list, kegg_conv, kegg_link, kegg_ddi accept max 10 entries (joined with +). Image/KGML/JSON formats accept only 1.

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

  6. 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.KEGG module 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
Monarch Initiative知识库图谱API,用于疾病-基因-表型关联查询、罕见病基因优先级排序及跨物种比较。支持基于MONDO ID和HP术语检索因果基因、表型谱及模型生物数据,无需认证,适用于诊断候选生成。
根据疾病名称或MONDO ID查找相关致病基因 获取疾病的表型特征(HP术语)以构建相似性模型 基于患者症状列表对候选基因进行表型相似度排名 查询跨物种(小鼠、斑马鱼等)的基因-表型关联数据
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-database instead when you need drug-target evidence scores or tractability data alongside disease associations
  • Use clinvar-database when 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 1500 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

  1. Resolve names to IDs first using /search: All association queries require CURIE IDs (e.g., MONDO:0007374), not free-text. Use search_entities() to resolve "Marfan syndrome" → MONDO:0007374 before querying associations.

  2. Use CausalGeneToDiseaseAssociation for gene lists, not GeneToDiseaseAssociation: The broader category includes susceptibility associations and ambiguous links. Causal associations have stronger evidence support.

  3. Paginate large result sets with offset: The default limit is 20 and max is 500. Check result["total"] and paginate with offset increments 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)
    
  4. 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.

  5. Cross-reference gene IDs with HGNC for human genes: Monarch may return HGNC:XXXX or NCBIGene:XXXX IDs. 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 traits
  • opentargets-database — drug-target evidence with tractability and safety scores
  • ensembl-database — gene/transcript annotation and cross-species orthology via Ensembl REST API
  • gseapy-gene-enrichment — gene set enrichment analysis using the Monarch-derived gene lists

References

调用EBI QuickGO REST API查询基因本体(GO)术语元数据、蛋白质注释及层级关系。支持按关键词搜索、祖先/后代遍历及多ID批量解析,为富集分析提供原始数据。
查询GO术语定义和名称 获取蛋白质GO注释信息 搜索GO关键词 遍历GO层级结构
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., 9606 for 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 1200 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

  1. Use batch_resolve_go_terms instead 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.

  2. 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. Set evidenceCode=EXP,IDA,IPI,IMP,IGI,IEP,TAS for curated-only results.

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

  4. Use descendants for ontology-aware queries: Searching only the exact goId misses proteins annotated to more specific child terms. Retrieve descendants first, then query each or combine into an evidenceCode-filtered batch.

  5. Check numberOfHits before assuming completeness: The default limit=25 often returns a fraction of total annotations. Always inspect numberOfHits and paginate when numberOfHits > 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 output
  • uniprot-protein-database — UniProt REST API for Swiss-Prot GO annotations integrated with protein sequence and feature data
  • ensembl-database — Ensembl REST API for variant-level GO annotations and cross-species gene lookups
  • kegg-database — KEGG pathways as an alternative functional annotation vocabulary

References

通过GET API查询RegulomeDB,对遗传变异进行调控功能评分(1a-7)并检索TF结合、组蛋白修饰等重叠证据。适用于GWAS位点优先级排序、非编码变异注释及顺式调控元件发现。
需要评估SNP或变异的调控活性强度 查询变异是否位于活跃染色质区域或QTL重叠区 识别与特定变异相关的转录因子结合位点 GWAS显著性位点的功能机制验证
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 @graph evidence 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-database instead when you need clinical pathogenicity classifications; RegulomeDB scores regulatory function, not germline disease association
  • Use gwas-database instead 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 11000 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

  1. Use GET, not POST. The POST /regulome-search/, POST /regulome-summary/, and GET /regulome-datasets/ JSON endpoints return a regulome-notfound stub or HTTP 500. Only GET /regulome-search/?regions=...&genome=...&format=json returns real data.

  2. Read regulome_score.ranking, not regulomedb_score. The field used to be named regulomedb_score in legacy docs; the live API exposes it as regulome_score.ranking (string like "1a", "7").

  3. Add time.sleep(0.3) between calls. RegulomeDB has no published rate limit, but polite spacing prevents intermittent 502s under load.

  4. 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-database for negative results.

  5. For tissue specificity, use tissue_specific_scores. The single ranking is an aggregate; the per-tissue probabilities reveal where the variant has the strongest regulatory signal.

  6. Watch the GRCh37 vs GRCh38 build. rsIDs are auto-resolved to the requested build, but coordinate-based regions=chrN:start-end queries are build-specific — pass the matching genome= 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 hits
  • clinvar-database — Clinical pathogenicity classifications; complements RegulomeDB's functional regulatory evidence
  • encode-database — Direct ENCODE REST API access for the TF ChIP-seq / ATAC-seq peak sets that underlie RegulomeDB scores
  • ensembl-database — Variant annotation and gene coordinate lookup; use to map rsIDs to genomic positions before region queries

References

通过REST API查询ReMap 2022转录因子结合位点数据库。支持按基因组区域重叠、基因邻近性、物种及调控元件类型筛选TF峰值,并提供BED文件下载,用于TF共占据分析和调控注释。
查找与特定基因组区域(如GWAS位点)重叠的转录因子峰值 获取基因启动子附近的TF结合峰以分析调控景观 查询特定细胞系中由启动子或增强子等调控元件定义的TF峰值 识别在特定基因座共同结合的转录因子 下载特定TF在所有细胞类型中的结合峰BED文件
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-database instead 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 files
  • remap2022_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/ chr1chrX, 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

  1. Parse the name field defensively: The TF:experiment:cell_type format may have fewer than three components for some records. Always guard with parts[n] if len(parts) > n else "".

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

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

  4. Use time.sleep(0.5) in batch loops: The ReMap API serves a research community; polite request pacing prevents throttling.

  5. 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 to start.

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 validation
  • encode-database — ENCODE regulatory tracks including TF ChIP-seq, DNase-seq, and ATAC-seq; partially overlaps with ReMap
  • homer-motif-analysis — de novo motif discovery in ChIP-seq peak sets from ReMap or MACS3
  • macs3-peak-calling — call peaks from raw ChIP-seq BAM files; ReMap provides pre-called peaks from the same approach
  • regulomedb-database — regulatory variant scoring that integrates TF binding evidence similar to ReMap

References

通过UCSC Genome Browser REST API查询100+基因组组装的DNA序列、基因结构、保守性评分及注释轨道。支持按区域检索序列、获取染色体大小,适用于基因组分析,无需认证。
查询特定基因组区域的DNA序列 获取RefSeq或GENCODE基因结构信息 查询PhyloP或PhastCons保守性分数 列出或查询UCSC注释轨道数据 获取基因组组装的染色体大小信息
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-database instead 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-intervals with 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 chr1chrY, 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

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

  2. 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)
    
  3. Discover track names before querying: Track names (e.g., refGene, cpgIslandExt) are not always obvious. Call list/tracks first to find the correct internal name, then query /getData/track.

  4. Handle missing track keys in response: The JSON key holding track records matches the track parameter name. Always use .get(track, []) to avoid KeyError when a track returns no data in a region.

  5. Download chromosome sizes once and cache: For pipelines that need sizes across many regions, call list/chromosomes once 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 workflows
  • encode-database — ENCODE portal for regulatory element datasets (ChIP-seq peaks, ATAC-seq) that feed into UCSC track hubs
  • bedtools-genomic-intervals — Perform intersection, coverage, and arithmetic on BED files downloaded from UCSC
  • regulomedb-database — RegulomeDB for regulatory variant scoring, which overlaps UCSC regulatory tracks

References

ETE3是Python系统发育树分析与可视化工具。支持解析Newick等格式,遍历注释节点,生成高质量图表,整合NCBI分类学,计算进化统计量及构建PhyloTree对象,适用于比较基因组学和进化生物学工作流。
需要解析或创建系统发育树 要求对进化树进行可视化渲染 涉及NCBI分类学数据映射 执行进化统计或拓扑结构分析
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-biology instead

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 TreeStyle rendering and interactive GUI; headless rendering requires xvfb

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v python first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run python rather than bare python.

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 09, 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 / PhyloTree instances — 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, .svg via t.render() — resolution controlled by w parameter and dpi
  • Taxonomy trees: NCBITaxa.get_topology() returns a Tree with taxid node names
  • RF distances: integer raw RF + integer max RF from robinson_foulds(); normalize as rf / 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

基于HOMER工具进行ChIP-seq/ATAC-seq峰序列的从头及已知转录因子基序富集分析,并注释峰与基因的距离、位置等基因组特征。
需要识别ChIP-seq峰值中结合的转录因子 对ATAC-seq开放染色质区域进行从头基序发现 比较不同条件下峰值集的基序景观差异 将基因组峰值注释到最近基因和TSS距离 验证ChIP-seq实验质量是否捕获目标TF
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-calling first to generate the peak BED files that serve as input to HOMER
  • Use jaspar-database to 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.pl after 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 / conda env). Run command -v findMotifsGenome.pl first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run findMotifsGenome.pl rather than bare findMotifsGenome.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

bedtools是基因组区间分析标准工具,用于BED/BAM/GFF/VCF格式的交集、合并、覆盖度计算及序列提取。适用于ChIP-seq注释、区域过滤和基因组算术运算,需坐标排序输入。
需要查找基因组特征间的重叠区域 合并相邻或重叠的基因组区间 计算测序数据在特定区域的覆盖深度 从参考基因组提取FASTA序列 寻找最近的基因或调控元件
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 tabix instead for fast indexed queries of a single genomic region
  • For normalized coverage bigWig tracks, use deeptools bamCoverage instead
  • Use mosdepth instead 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) for slop/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 / conda env). Run command -v bedtools first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run bedtools rather than bare bedtools.

# 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

  1. Always sort before bedtools: Most bedtools commands fail silently on unsorted input. Sort with sort -k1,1 -k2,2n input.bed before any bedtools operation.

  2. Use -sorted for large files: For pre-sorted files, -sorted reduces memory from O(N) to O(1). Required when intersecting multi-gigabyte BED files.

  3. Check chromosome naming consistency: The single most common failure — some tools use chr1, others use 1. Verify with cut -f1 file.bed | sort -u before running intersections.

  4. Apply blacklist early: Run bedtools subtract -b blacklist.bed -A before any peak analysis. ENCODE blacklists remove artifactual signal in repetitive/high-copy regions.

  5. Use -f 0.5 -r for peak reproducibility: When intersecting peaks across replicates, require 50% reciprocal overlap to avoid spurious short overlaps at interval boundaries.

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

deepTools用于NGS数据分析,将BAM转为标准化bigWig,进行相关性/PCA质控,生成TSS热图及ATAC-seq分析。需安装deeptools,输入文件须排序索引。
BAM转bigWig ChIP-seq热图绘制 RNA-seq覆盖度计算 ATAC-seq Tn5校正 样本相关性质控
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

  1. Always extend reads for ChIP-seq: Use --extendReads 200 (or actual fragment length) — ChIP fragments are longer than reads.

  2. Never extend reads for RNA-seq: --extendReads would span splice junctions, creating artifacts.

  3. Anti-pattern — comparing with different normalizations: Always use the same normalization method across all samples in a comparison.

  4. Use --region for parameter testing: Test on a single chromosome (--region chr1:1-10000000) before running on the full genome — saves hours.

  5. Always use --numberOfProcessors: Most tools parallelize well — use all available cores.

  6. Anti-pattern — using RPGC without --effectiveGenomeSize: Will silently produce wrong results. Always specify the correct genome size.

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

Geniml是用于基因组区间机器学习的Python库,支持从BED文件训练region2vec嵌入、使用BEDSpace进行相似性搜索、构建ML数据集及评估嵌入质量。适用于染色质可及性聚类、调控元件分类等场景。
需要学习基因组区域的密集向量表示 基于嵌入相似度聚类染色质开放位点或组蛋白修饰位点 在嵌入空间中搜索与查询区域相似的基因组区间 从BED格式构建PyTorch兼容的训练数据集 比较不同训练运行或数据集的嵌入质量
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 gtars or pysam-genomic-files instead

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 50512 Dimensionality of region embeddings; higher = more expressive but slower
window_size Region2Vec training 5 220 Context window for skip-gram co-occurrence; larger = broader co-occurrence
epochs Region2Vec training 10 5100 Training iterations; increase for large datasets
batch_size Region2Vec training 32 16512 Mini-batch size; larger batches improve stability
fraction UniverseBuilder 0.5 0.01.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

GTARS是基于Rust核心的高性能Python库,用于基因组区间操作。支持高效BED文件读写、集合运算(交集/合并等)、区域分词及共识宇宙构建。适用于大规模BED文件预处理和ML流水线,替代纯Python方案提升速度。
需要处理大量BED文件的读写 计算基因组区间的交集或并集 对基因组区域进行分词以准备ML输入 构建共识宇宙BED文件
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-files instead

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.01.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 GenomicInterval objects with .chr, .start, .end
  • BED files: written via .to_bed() — 3-column tab-separated (chr, start, end)
  • Token arrays: list[int] from tokenizer.tokenize() — convert with np.array(tokens)
  • Presence vectors: np.ndarray of 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

MACS3用于ChIP-seq/ATAC-seq的峰调用,识别转录因子或组蛋白结合区域。支持窄峰(TF)和宽峰(组蛋白),输出BED格式供可视化及差异分析,是ENCODE推荐标准工具。
ChIP-seq数据峰调用 ATAC-seq开放染色质区域识别 组蛋白修饰峰检测 需要生成narrowPeak或broadPeak文件
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 200 or 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 / conda env). Run command -v macs3 first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run macs3 rather than bare macs3.

# 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

提供BUSCO完整性状态解读指南,明确Duplicated计入完整,解析输出格式与计算逻辑,纠正排除重复基因的常见错误,适用于基因组QC、组装比较及报告。
运行BUSCO质控 比较不同组装的完整性 撰写包含基因组完整性的报告
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

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

  2. 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_odb10 against another with metazoa_odb10) produces incomparable results.

  3. 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 with mammalia_odb10 (9,226 markers).

  4. 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).

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

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

  7. Validate with BUSCO's built-in plotting: Use generate_plot.py to create the standard BUSCO stacked bar chart for visual comparison across assemblies. This standardized visualization is widely recognized by reviewers.


Common Pitfalls

  1. 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.
  2. Comparing results across different lineage datasets: BUSCO scores from eukaryota_odb10 (255 groups) and insecta_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.
  3. 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.
  4. Using a lineage dataset that does not encompass the organism: Running a fungal genome through insecta_odb10 will produce near-zero completeness, not because the assembly is poor but because the wrong orthologs are being searched.

    • How to avoid: Use --auto-lineage for unknown organisms, or verify phylogenetic placement before selecting a dataset. Check the OrthoDB taxonomy browser.
  5. 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%.
  6. 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.
  7. 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.

Workflow

  1. Select lineage dataset

    • Identify the organism's taxonomic placement
    • Choose the most specific available OrthoDB lineage dataset
    • If uncertain, run busco --auto-lineage first
  2. Run BUSCO

    • Execute BUSCO in the appropriate mode (genome, transcriptome, or protein)
    • Record the exact command, version, and dataset for reproducibility
  3. 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
  1. 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
  1. 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()
  1. 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
  1. 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 completeness
  • samtools-bam-processing -- BAM file processing; alignment quality metrics complement BUSCO completeness for assembly QC
  • multiqc-qc-reports -- Aggregated QC reporting; MultiQC can incorporate BUSCO results into unified quality reports across samples
fastp用于NGS数据的快速质控和适配器修剪。支持自动检测Illumina适配器、过滤低质量读段、校正双端重叠,并生成HTML/JSON报告。比传统工具快3-10倍,是STAR等比对前的标准预处理步骤。
需要清洗FASTQ文件 进行NGS数据质控 去除测序接头 生成QC报告
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 / conda env). Run command -v fastp first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run fastp rather than bare fastp.

# 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

MultiQC用于聚合150多种生物信息学工具的QC日志,生成包含交互式图表的综合HTML报告。适用于多样本NGS流程的质量控制、异常检测及结果共享,替代手动检查单个样本文件。
需要汇总多个生物信息学工具(如FastQC, STAR等)的质控结果 在差异表达或变异分析前进行最终质控检查 识别批次效应或失败的测序运行
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, STAR Log.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 / conda env). Run command -v multiqc first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run multiqc rather than bare multiqc.

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

基于R/Bioconductor的DESeq2工具,用于批量RNA-seq差异表达分析。支持负二项GLM、Wald/LRT检验、多因素设计及Salmon数据导入,提供apeglm收缩及可视化功能,适用于复杂实验设计与批次校正。
进行Bulk RNA-seq差异基因表达分析 需要处理多因素或交互作用的实验设计 使用Salmon伪比对结果进行转录本不确定性传播 执行时间序列或复杂假设检验
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), tximeta or tximport (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.sf files 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.010.10 FDR threshold used for independent filtering; does not change padj values
lfcThreshold results() 0 02 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 reduced model 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

用于从STAR对齐的BAM文件和GTF注释中统计RNA-seq reads,生成基因级计数矩阵。支持多样本批量处理、链特异性及配对末端读取,适用于DESeq2或edgeR差异表达分析前的数据准备步骤。
需要基于STAR BAM文件进行RNA-seq基因计数 使用featureCounts工具生成DESeq2/edgeR输入矩阵 询问如何安装或使用Subread包中的featureCounts
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 / conda env). Run command -v featureCounts first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run featureCounts rather than bare featureCounts.

# 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

用于RNA-seq和蛋白质组学的基因集富集分析(GSEA)和过表达分析(ORA)。支持Enrichr数据库查询及基于排序基因的GSEA算法,输出富集表格和图表,适用于DESeq2等差异表达结果的路径解读。
需要进行通路或GO术语水平的差异表达结果解释 对基因列表执行快速过表达分析(ORA) 基于log2倍数变化排序的基因列表进行GSEA预排名分析 识别scRNA-seq聚类标记基因的富集通路 生成出版级富集点图或GSEA运行得分图
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: enrich module 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 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

基于PyDESeq2的Bulk RNA-seq差异表达分析技能,涵盖数据加载、过滤、负二项模型拟合及Wald检验。支持双组或多因素设计、批次校正、LFC收缩及可视化,适用于纯Python环境下的基因表达差异识别与结果输出。
需要执行bulk RNA-seq差异表达分析 将R DESeq2流程迁移至Python环境 需要进行多因素实验设计的统计检验 生成火山图或MA图展示差异基因
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, seaborn for 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.010.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 1N Number of parallel threads for dispersion fitting
min_total_counts (user) 10 550 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 genes
  • deseq2_significant.csv — Filtered results (padj < 0.05 and |LFC| > 1)
  • deseq2_upregulated.csv — Significant upregulated genes sorted by padj
  • deseq2_downregulated.csv — Significant downregulated genes sorted by padj
  • volcano_plot.png — Volcano plot with significance and fold change thresholds
  • ma_plot.png — MA plot showing fold change vs mean expression
  • qc_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

基于准映射快速进行RNA-seq转录本/基因定量,无需BAM文件。支持GC及序列偏差校正,输出TPM和计数表,便于与DESeq2等工具整合用于差异表达分析。
RNA-seq定量 转录本丰度估计 快速基因表达分析 无需BAM文件的定量
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: pandas for parsing output; pydeseq2 for differential expression

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v salmon first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run salmon rather than bare salmon.

# 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

用于生物数据分析的Python库,支持序列操作、多序列比对、系统发育树构建、微生物组多样性计算(Alpha/Beta)、排序分析及PERMANOVA等统计检验。适用于生态学和微生物组研究。
计算微生物组Alpha或Beta多样性 执行PERMANOVA或ANOSIM统计检验 构建系统发育树(NJ/UPGMA) 读取或写入FASTA、BIOM等生物格式文件 进行DNA/RNA/蛋白质序列操作
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

  1. Read FASTQ sequences using generator (skbio.io.read with constructor=DNA)
  2. Filter by quality scores from positional_metadata['quality']
  3. Trim adapters using find_with_regex() and sequence slicing
  4. Find motifs with find_with_regex('ATG.{3}')
  5. Translate via dna.transcribe().translate()
  6. 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

  1. Use generators for large filesskbio.io.read() returns a generator; avoid list() on millions of sequences
  2. Use projected CRS for phylogenetic diversity — always provide matching tree and otu_ids; prune tree to feature set with tree.shear(feature_ids)
  3. Pair PERMANOVA with PERMDISP — PERMANOVA is sensitive to dispersion differences; run PERMDISP to check group homogeneity
  4. Use 999+ permutations for publication-quality p-values; 99 only for exploratory analysis
  5. Use HDF5 BIOM format over JSON for large feature tables (faster I/O, smaller files)
  6. Anti-pattern — relative abundance in diversity functions: diversity functions require integer counts, not proportions. Convert back if needed
  7. 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

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
用于单细胞基因组学的数据结构,存储矩阵及元数据。支持H5AD/Zarr读写、拼接及大文件处理,为Scanpy和scvi-tools提供数据准备。
读取或写入单细胞数据文件(.h5ad/.zarr) 构建或操作AnnData对象 单细胞数据拼接或子集筛选
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 .h5ad or .zarr files 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 scanpy instead
  • For probabilistic models, use scvi-tools instead

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.

  1. Open file in backed mode: adata = ad.read_h5ad("huge.h5ad", backed="r")
  2. Inspect metadata without loading data: check adata.obs, adata.var
  3. Filter on metadata conditions: mask = adata.obs["tissue"] == "brain"
  4. Load filtered subset into memory: subset = adata[mask].to_memory()
  5. Process the in-memory subset normally (normalize, filter genes)
  6. 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

  1. Use sparse matrices for count data: Single-cell count matrices are typically 90%+ zeros. Use scipy.sparse.csr_matrix to reduce memory by ~10x.

    from scipy.sparse import csr_matrix
    adata.X = csr_matrix(adata.X)
    
  2. Convert strings to categoricals before saving: Repeated string columns (cell_type, batch, sample) waste memory. Call adata.strings_to_categoricals() before .write_h5ad().

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

  4. Always copy views before modifying: Subsetting returns a view. Modifying triggers ImplicitModificationWarning. Use adata[mask].copy() before any modification.

  5. Store raw counts in layers before normalization: adata.layers["counts"] = adata.X.copy() before any transformation -- raw counts cannot be recovered from normalized data.

  6. Use gzip compression for long-term storage: adata.write_h5ad("f.h5ad", compression="gzip") reduces size 2-5x. Use lzf for speed-critical workflows.

  7. Align external data on index: Pandas index alignment silently inserts NaN. Always use external_series.reindex(adata.obs_names).values when 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:

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

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

基于预训练逻辑回归模型自动进行单细胞RNA测序细胞类型注释。支持45+种参考模型,提供单细胞及聚类级别标签、置信度评分及多数投票功能,适用于快速、无需手动检查标记基因的标准化注释流程。
需要自动化单细胞RNA-seq数据细胞类型注释 使用标准化参考模型对PBMC或免疫数据集进行快速初筛 通过多数投票平滑聚类级别的细胞类型预测结果 比较不同组织特异性模型的注释一致性
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.01.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.01.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:

  1. Each cell receives a per-cell label from the logistic regression output
  2. Within each cluster (e.g., Leiden cluster), the most frequent per-cell label becomes the cluster's consensus majority_voting label
  3. Cells whose per-cell label disagrees with the cluster majority are re-labeled to the cluster consensus unless min_prop is 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

提供CELLxGENE Census的编程接口,用于查询超6100万单细胞转录组数据。支持按细胞类型、组织等条件检索AnnData或PyTorch DataLoader,适用于大规模参考数据集构建、标记基因发现及机器学习模型训练。
查询单细胞表达数据 获取特定细胞类型的元数据 训练单细胞机器学习模型 探索可用单细胞数据集
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 == True to 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.

  1. Query metadata to identify available cell types in COVID-19 data (Core API module 2)
  2. Retrieve expression data for selected cell types and marker genes (Core API module 3)
  3. Compute mean expression per cell type per condition
  4. Visualize with scanpy dotplot or matrixplot

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

  1. Always filter is_primary_data == True: Without this filter, duplicate cells across datasets inflate counts and bias analyses.

  2. Estimate query size before loading: Call get_obs() with column_names=["soma_joinid"] to count cells before downloading expression data. Use out-of-core processing for >100k cells.

  3. Pin census_version for reproducibility: The default "latest stable" changes periodically. Always specify the version for published analyses.

  4. Select only needed metadata columns: Passing obs_column_names reduces data transfer and memory usage significantly for large queries.

  5. Use tissue_general for cross-tissue analyses: The tissue field has hundreds of specific values; tissue_general provides ~30 coarse groupings suitable for comparative analyses.

  6. Anti-pattern — querying all genes when you need a few: Specify var_value_filter to 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

Harmony批量校正技能,用于scRNA-seq等单细胞数据的批次效应去除。在PCA后、UMAP前运行,保留生物学差异并修正技术变异,支持百万级细胞规模,兼容Python和R环境。
需要整合来自不同样本或实验批次的单细胞数据 移除测序深度或平台等技术变异以保留生物学差异 对大规模数据集进行快速批次校正
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 010 Diversity penalty per batch variable. Higher values force more mixing; set to 0 to disable penalty. Increase to 46 for very uneven batch sizes.
sigma 0.1 0.010.5 Bandwidth of the soft-clustering Gaussian kernel. Smaller values → sharper cluster assignments; larger values → smoother. Rarely needs changing.
nclust None (auto) 5200 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 550 Maximum Harmony iterations. Increase to 2030 for datasets with strong or complex batch effects where convergence is slow.
tau 0 010 Overclustering protection. Positive values penalize very small clusters; use tau=5 when tiny populations are over-splitting.
n_neighbors (scanpy) 15 550 Number of nearest neighbors for the post-Harmony graph. Increase for larger datasets (>100k cells) to stabilize clusters.
block_size 0.05 0.010.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:

  1. Soft K-means assignment: Each cell is softly assigned to K clusters based on Euclidean distance in PC space, weighted by the batch-diversity penalty theta.
  2. Per-cluster linear regression: Within each cluster, a linear correction is estimated for each batch variable and applied to the cluster's cells.
  3. 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

popV通过集成10+种算法对单细胞数据进行多数投票,输出共识标签及一致性评分。适用于需高置信度标注、处理大规模数据或识别新颖细胞状态的场景,提供比单一方法更稳健的结果和不确定性评估。
需要基于参考图谱的细胞类型共识标注 使用多种算法进行集成学习以提高鲁棒性 检测低一致性以发现潜在的新颖或模糊细胞状态 对比不同算法结果以评估标注可靠性
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_agreement score)
  • 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 in obs, and an unlabeled query (adata_query). Both must be from the same species and have overlapping gene sets. Raw counts in adata.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 20500 scVI training epochs; increase for better embedding on large/complex datasets
n_epochs_semisupervised Process_Query 20 10100 scANVI fine-tuning epochs on top of scVI
hvg Process_Query 4000 20008000 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.01.0 Fraction of methods agreeing on consensus label; >=0.8 recommended for high confidence

Best Practices

  1. 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%}")
    
  2. Use raw counts as input: pass raw (un-normalized) counts in adata.X to Process_Query. popV internally applies its own normalization. Pre-normalized data can distort the scVI/SCANVI latent space.

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

  4. Exclude slow methods when speed matters: scanvi_popv and onclass are the slowest. For a quick first-pass, run only knn_harmony, knn_bbknn, rf, xgboost, and celltypist_popv.

    popv.annotation.annotate_data(adata, methods=["knn_harmony", "knn_bbknn", "rf", "xgboost", "celltypist_popv"])
    
  5. Save trained models for repeated queries: Process_Query stores scVI/SCANVI models in save_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_harmony method internally; understand it to tune popV's KNN-based methods

References

提供基于Scanpy的单细胞RNA测序标准分析工作流,涵盖数据加载、质控、标准化、降维、聚类及轨迹推断等步骤。
单细胞RNA-seq数据分析 scRNA-seq质控与过滤 单细胞聚类与标记基因识别 单细胞轨迹推断
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 layer
  • results/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

基于PyTorch的单细胞深度学习框架,提供scVI等模型进行概率批次校正、半监督注释、CITE-seq联合分析及差异表达。支持AnnData格式,统一API实现训练与特征提取,适用于大规模单细胞数据整合与去噪。
需要概率方法校正单细胞RNA测序批次效应 使用scANVI进行细胞类型半监督注释 分析CITE-seq RNA和蛋白质联合数据 执行带有不确定性估计的差异表达分析 利用预训练模型进行跨数据集迁移学习
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 in adata.layers["counts"] if adata.X has 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 1050 Latent space dimensionality; 20–30 typical for most datasets
n_layers All models 1 13 Depth of encoder/decoder networks
n_hidden All models 128 64256 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 501000 Training iterations (ELBO steps)
early_stopping train() False True/False Halt when validation ELBO plateaus
early_stopping_patience train() 45 5100 Epochs to wait before stopping
batch_size train() 128 64512 Mini-batch size; reduce if OOM
lr train() 1e-3 1e-41e-2 Initial learning rate
delta differential_expression() 0.25 0.11.0 Min
n_samples get_normalized_expression() 1 1100 Posterior samples; ≥25 for stable estimates

Best Practices

  1. Always store raw counts before normalization: Run adata.layers["counts"] = adata.X.copy() immediately after loading data, before any normalize_total or log1p call. Passing log-normalized data silently produces incorrect latent spaces.

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

  3. Register all known batch variables: If data has multiple technical sources (sequencing plate, donor, protocol), include them as batch_key or categorical_covariate_keys. Unregistered batch effects contaminate the latent space and downstream DE.

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

  5. Save trained models immediately: model.save("./model_dir/") persists the full model. Reloading with SCVI.load() is seconds vs. minutes of retraining. Models are typically 10–50 MB.

  6. Use n_latent=20–30 as 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).

  7. Filter low-confidence scANVI predictions: Cells where predict(soft=True).max(axis=1) < 0.7 are 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

提供scRNA-seq细胞类型注释的三层决策框架:手动标记、CellTypist自动分类及popV参考转移。指导何时使用各策略,强调最佳实践为自动化假设验证结合手动检查,确保高价值研究结果的准确性。
规划单细胞RNA测序数据注释流程 解决细胞类型注释中的歧义或错误 比较不同注释方法(手动、CellTypist、popV)的适用性
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:

  1. Canonical published markers: Genes with decades of protein-level validation, e.g., CD3 protein expression for T cells, CD19 for B cells.
  2. 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.
  3. Cross-validated markers: Genes that are canonical and also appear as top markers from rank_genes_groups in your dataset. This is the strongest evidence — it means the marker's biology is reflected in your actual data.
  4. 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

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

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

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

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

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

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

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

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

  1. 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.
  2. 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 with predicted_doublet == True from adata.obs before proceeding.
  3. 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.
  4. 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.
  5. 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.
  6. 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_cycle with 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.
  7. 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

  1. 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.
  2. 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 in adata.obs["predicted_doublet"].
    • Remove predicted doublets before clustering and annotation.
  3. 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.
  4. 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.
  5. 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"] and adata.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.
  6. Tier 3 — popV ensemble label transfer (if applicable):

    • Prepare a reference AnnData with column cell_type containing validated labels.
    • Ensure both query and reference contain raw count matrices in adata.X.
    • Run popv.process_query(query_adata, ref_adata, ...) and popv.annotate_data(query_adata).
    • Store consensus labels in adata.obs["popv_prediction"] and agreement scores in adata.obs["popv_score"].
    • Inspect the per-method label breakdown for cells with low agreement scores.
  7. 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.
  8. 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 .h5ad for downstream analysis.
    • Prepare a supplementary table listing each cell type, the markers used to validate it, the annotation method, and the number of cells.

Protocol Guidelines

  1. 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).

  2. CellTypist model selection: Match the model to the tissue. For immune datasets use Immune_All_High_Resolution or Immune_All_Low_Resolution. For lung use Human_Lung_Atlas. For gut use Human_Colon_Cell_Atlas or Cells_of_the_Human_Intestine. For fetal use Fetal_Human_Pancreas or the appropriate fetal model. Run celltypist.models.models_description() for the full list with tissue metadata.

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

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

  5. 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
  1. 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.

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

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

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 strategy
  • celltypist-cell-annotation — Tier 2 automated annotation tool using pre-trained logistic regression models for 100+ cell types across blood, lung, gut, brain, and other tissues
  • popv-cell-annotation — Tier 3 ensemble label transfer tool using 10+ methods with majority voting; use for robust annotation of novel or heterogeneous datasets
  • scvi-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 annotation
  • harmony-batch-correction — Batch correction for PCA embeddings; apply before annotation when integrating samples from multiple donors or processing batches
  • anndata-data-structure — Core data container for all scRNA-seq analysis; required for understanding how annotations are stored and propagated in AnnData objects
  • cellxgene-census — Source of large curated reference atlases (Human Cell Atlas, Tabula Sapiens) for use as label transfer references in popV workflows
  • muon-multiomics-singlecell — Multi-modal single-cell analysis; annotation of multi-omics data (RNA+ATAC, CITE-seq) requires considering the additional modalities as supplementary evidence
  • mofaplus-multi-omics — Multi-omics factor analysis; latent factors from MOFA+ can inform cell state annotation when transcriptional signatures are not sufficient
  • cellchat-cell-communication — Downstream analysis of annotated single-cell data; ligand-receptor interaction inference depends entirely on having accurate cell type labels from annotation
用于VCF/BCF变异数据处理的命令行工具,支持过滤、合并、注释、标准化及统计。适用于变异调用后的质控、多样本联合分析及基因型提取等流程。
处理VCF或BCF格式文件 对变异进行质量过滤或标准化 合并多个样本的变异调用结果 为变异添加rsID或基因注释 从VCF中提取特定字段如基因型 计算变异数据的QC统计信息
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 HaplotypeCaller instead when calling variants with local realignment in human samples
  • Use VCFtools instead for population genetics statistics (Fst, LD, Hardy-Weinberg)
  • Use bcftools in the HTSlib pipeline; use picard for 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: samtools for BAM processing; tabix for VCF indexing

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v bcftools first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run bcftools rather than bare bcftools.

# 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

  1. Index before region queries: bcftools view -r chr1:... requires a .tbi or .csi index. Always run bcftools index -t output.vcf.gz after creating any bgzipped VCF.

  2. Normalize before merging or annotation: Different callers represent the same indel differently. Run bcftools norm -m -any | bcftools norm -d any -f ref.fa before merging to prevent duplicate records.

  3. Use -O u for pipeline intermediates: Uncompressed BCF output (-O u) eliminates compression/decompression overhead in multi-step pipes — typically 2-3× faster than -O z.

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

  5. 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 before bcftools merge.

  6. Check chromosome naming consistency: bcftools fails silently if merging chr1-style with 1-style VCFs. Verify with bcftools 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

用于从WES/WGS/靶向BAM文件检测体细胞CNV。支持二分类、纯度倍性估计及可视化,提供CLI和Python API。
需要分析肿瘤样本的拷贝数变异 处理外显子组或靶向测序数据以识别扩增或缺失 估计肿瘤纯度和倍性
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 wgs mode
  • 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 / conda env). Run command -v cnvkit.py first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run cnvkit.py rather than bare cnvkit.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 16 Assumed baseline ploidy for absolute CN calling
--purity (call) 1.0 0.11.0 or "auto" Tumor cell fraction; corrects log2 ratios for admixed normal
--target-avg-size (target) 200 (WES) 50500 bp Desired mean target bin size after splitting
--antitarget-avg-size 150000 10000500000 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 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

基于GATK Best Practices的体细胞SNP/Indel变异检测流程,支持WGS/WES数据。涵盖BQSR、HaplotypeCaller单样本GVCF生成、GenomicsDBImport合并及联合基因分型。适用于群体遗传学或临床分析,需预处理BAM文件。
需要运行GATK进行全基因组或外显子组变异检测 执行多样本联合基因分型以构建队列数据集 对BAM数据进行碱基质量分数重校准(BQSR) 生成GVCF文件以便后续扩展样本而不重新处理
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 @RG read group headers (from BWA-MEM2)

Check before installing: The tool may already be available in the current environment (e.g., inside a pixi / conda env). Run command -v gatk first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run gatk rather than bare gatk.

# 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

PLINK2高性能GWAS工具,支持BED/VCF/BGEN格式处理。执行QC、IBD、PCA及线性/逻辑回归关联分析,输出曼哈顿图统计。适用于队列GWAS,超大规模数据建议用Regenie或SAIGE。
进行全基因组关联分析(GWAS) 执行群体遗传学分析 运行基因型数据质控(QC) 计算主成分分析(PCA)
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 / conda env). Always run command -v plink2 first and skip the install block if it returns a path. When executing tools inside a pixi project, prefer pixi 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

使用SnpEff和SnpSift对VCF变异进行功能注释与过滤。支持预测影响等级、基因转录本及HGVS,并整合ClinVar/dbSNP数据。适用于罕见病或癌症分析中的变异优先级排序、去新变异识别及结构化数据提取。
需要为VCF文件添加功能注释(如影响等级、基因信息) 根据临床意义或突变类型过滤VCF变异 从VCF中提取结构化字段用于统计分析 在 Trio 分析中识别候选去新变异
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, seaborn for 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 / conda env). Run command -v snpEff first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run snpEff rather than bare snpEff.

# 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) 4g32g 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.01.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
指导在计算Ts/Tv等统计指标前对原始VCF文件进行质量过滤。涵盖通过FILTER列和QUAL值识别原始数据、使用bcftools进行基于QUAL的过滤、Ts/Tv比率解读及不适宜过滤的场景,确保变异QC结果的可靠性。
需要对原始VCF文件进行质量控制或过滤 分析变异调用集的Ts/Tv比率或变异计数 检查VCF文件的FILTER状态和QUAL分数
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 unassessed
  • PASS -- 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

  1. 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 | head to get a quick overview.

  2. 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
    
  3. 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.

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

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

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

  1. 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.
  2. 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). Use bcftools query -f '%FILTER\n' | sort | uniq -c to inspect.
  3. 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 stats for Ts/Tv computation. It is well-tested, fast, and handles all VCF edge cases correctly.
  4. 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.
  5. 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.
  6. 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)."
  7. 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

  1. 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
    
  2. 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.
  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
    
  4. 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
    
  5. 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
    
  6. 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

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

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

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

  4. Use piped commands for efficiency. Chain bcftools view and bcftools stats in a pipe rather than writing intermediate files. This is faster, uses less disk, and reduces the chance of analyzing the wrong file.

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

Related Skills

  • bcftools-variant-manipulation -- Detailed bcftools usage for subsetting, merging, annotating, and manipulating VCF files beyond simple filtering
  • gatk-variant-calling -- Upstream variant calling with GATK HaplotypeCaller, including VQSR and hard filtering configuration
  • samtools-bam-processing -- BAM file quality control and processing steps that precede variant calling and affect downstream VCF quality
提供Benchling R&D平台Python SDK集成能力,支持DNA/RNA/蛋白质等注册实体、库存、ELN及工作流的CRUD操作与自动化。需API密钥认证,适用于生物序列管理及实验数据同步。
在Benchling中创建或查询生物序列(DNA、RNA、蛋白质) 自动化库存管理操作如容器转移和位置更新 程序化创建或检索电子实验记录本(ELN)条目 构建工作流自动化任务如批量状态更新
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

  1. List pending workflow tasks: benchling.workflow_tasks.list(workflow_id=..., status="pending")
  2. For each task, read associated entity via benchling.dna_sequences.get_by_id()
  3. Run automated validation checks (sequence length, GC content, restriction sites)
  4. Update task status to "complete" or "failed" via benchling.workflow_tasks.update()
  5. 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

  1. Always use environment variables for credentials: Never hardcode API keys. Use os.environ["BENCHLING_API_KEY"].

  2. Use the fields() helper for custom schema fields: Raw dicts will not work — the SDK requires typed Fields objects.

  3. Anti-pattern — loading all entities into memory: Use the paginated generator pattern. Never convert list() to a Python list for large datasets.

  4. Add rate limit delays for bulk operations: Insert time.sleep(0.5) between create/update calls when processing >50 entities.

  5. Use OAuth for production apps, API keys for scripts: API keys are user-scoped; OAuth allows app-level permissions and rotation.

  6. Anti-pattern — using both entity_registry_id and naming_strategy: These are mutually exclusive on create. Use one or the other.

  7. Handle BenchlingError explicitly: 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

用于为Opentrons OT-2/Flex机器人编写液体处理Python协议。支持移液器、模块及板卡控制,提供PCR、稀释等场景示例,并包含本地模拟与上传功能。
编写Opentrons自动化脚本 配置PCR或ELISA流程 控制磁珠清洗模块 进行序列稀释实验
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 PyLabRobot instead 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-integration to 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_simulate ships 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 11000 µL/s Aspirate speed; lower for viscous samples (glycerol, proteins > 5 mg/mL)
flow_rate.dispense pipette property varies by model 11000 µL/s Dispense speed; lower for foaming or delicate cell suspensions
height_from_base mag_mod.engage() 020 mm Height of magnet tips above plate base; depends on bead/plate geometry
repetitions tc_mod.execute_profile() 199 Number of PCR thermal cycles

Best Practices

  1. Always simulate before running on hardware: Use opentrons_simulate protocol.py to 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
    
  2. Prefer compound operations over manual pick-up/aspirate/dispense/drop sequences: transfer(), distribute(), and consolidate() handle tip management, air gaps, and blow-out automatically. Reserve low-level calls for operations not supported by compound methods.

  3. 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 to tip_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")
    
  4. Use define_liquid() and load_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.

  5. 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"). Set requirements = {"robotType": "Flex"} or "OT-2" to catch slot mismatches during simulation.

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

  7. Use protocol.pause() for manual steps, not protocol.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

通过REST API搜索和获取protocols.io上的湿实验、生信及临床协议,支持按关键词、DOI或分类检索步骤、试剂等详情。适用于实验室自动化工作流构建及协议版本验证,可结合Opentrons或Benchling使用。
需要查找特定关键词或技术的湿实验协议 需获取协议的详细步骤、试剂和材料列表 通过DOI检索完整协议内容 为实验室自动化工作流提取程序化协议数据
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-api or benchling-integration to 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 150 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

  1. Sort by views for quality: Use order_field=views when searching for well-validated protocols, as highly-viewed protocols have been tested by many groups.

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

  3. Check license before use: All public protocols.io protocols are CC-BY 4.0 by default. Commercial use requires checking individual protocol licenses.

  4. Extract materials list for reagent ordering: The materials API returns catalog numbers and supplier names, enabling direct procurement list generation.

  5. 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 skill
  • benchling-integration — Store retrieved protocols in Benchling ELN with reagent tracking
  • scientific-manuscript-writing — Reference protocols correctly in methods sections using protocols.io DOIs

References

PyLabRobot是跨平台液体处理Python库,支持Hamilton、Tecan、Opentrons及模拟器。提供统一API实现移液协议编写、板重排、稀释及自动化工作流,避免厂商锁定,支持异步接口与仿真测试。
编写跨品牌液体处理机器人脚本 在物理设备前使用仿真后端验证实验逻辑 执行孔板重排或挑孔操作 构建系统性的系列稀释曲线 将液体处理集成到Python数据分析或机器学习流水线中
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 opentrons Python 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 101000 µL/s Speed of liquid movement
blow_out_air_volume dispense 0 030 µ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

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

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

  3. 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()
    
  4. 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.

  5. 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:2121 shows 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

提供Western blot图像量化的标准化流程,涵盖条带检测、加载对照归一化(含两步法)、多重复实验的统计聚合及可视化最佳实践。
Western blot数据分析 蛋白条带定量 免疫印迹归一化 生物重复统计
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:

  1. 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)
  2. 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:

  1. For each experimental repetition, identify the control condition
  2. Calculate fold change for each condition:
Fold_Change = Target_value_condition / Target_value_control
  1. The control condition will have a fold change of 1.0 by definition
  2. 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:

  1. Collect normalized values (or fold changes) from all repetitions
  2. 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:

  1. X-axis: Experimental conditions (e.g., Control, P144, TGF-β1, Tβ1Ab)
  2. Y-axis: Normalized values or fold change (typically starting from 0)
  3. Bars: Mean values for each condition
  4. Error bars: Standard deviation or standard error
  5. 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:

  1. Quantification results: CSV or Excel file with:

    • Raw intensities
    • Normalized values
    • Fold changes
    • Statistical summaries (mean, SD, SE)
  2. Visualization: Bar graph image (e.g., psmad2_quantification.png)

    • High resolution (300 DPI minimum)
    • Clear labels and error bars
    • Publication-ready format
  3. 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:

  1. Detect bands: Identify all PSMAD2, SMAD2, and GAPDH bands across 12 lanes
  2. Measure intensities: Extract intensity values for each band
  3. Normalize:
    • Step A: SMAD2_norm = SMAD2 / GAPDH (for each sample)
    • Step B: Target_value = PSMAD2 / SMAD2_norm (for each sample)
  4. Calculate fold change: Target_value_condition / Target_value_control (within each repetition)
  5. Aggregate: Calculate mean ± SD across 3 repetitions for each condition
  6. Visualize: Create bar graph with error bars
  7. 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_threshold and upper_threshold per image; manually verify the grid overlay before extracting intensities, and preserve correctly detected ROIs when adjusting parameters.

Best Practices

  1. 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.
  2. 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.
  3. Verify detection: Always review the grid/verification image overlay before extracting intensities. Automatic ROI detection can split or merge bands and silently introduce errors.
  4. Document exclusions: Note any excluded samples and the reasons. Silent exclusion biases the reported mean and breaks reproducibility.
  5. 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.
  6. Save intermediate data: Keep raw intensities, ROI coordinates, and per-replicate normalized values for potential re-analysis or supplementary deposition.
  7. 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

Histolab用于数字病理全切片图像(WSI)处理,支持组织检测、多种策略的图块提取及H&E/IHC预处理流水线。适用于深度学习数据准备、批量提取和幻灯片质量控制,兼容OpenSlide格式。
数字病理学中的全切片图像处理 从WSI中提取图块以训练深度学习模型 组织区域检测与背景过滤 构建H&E或IHC染色切片的预处理流水线
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-python directly
  • For complex multiplexed imaging or spatial proteomics pipelines, use pathml instead

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

  1. Always preview before extracting: Call locate_tiles() and locate_mask() to validate settings before committing to full extraction. This saves hours on large slide collections.

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

  3. Choose the right tiler strategy:

    • RandomTiler for exploratory analysis and diverse sampling
    • GridTiler for complete coverage and spatial analysis
    • ScoreTiler for quality-driven dataset curation
  4. Use seeds for reproducibility: Always set seed in RandomTiler to ensure consistent tile selection across runs.

  5. Customize masks for specific stains: H&E and IHC stains have different color profiles. Adjust filter parameters or build custom Compose pipelines for non-standard stains.

  6. Anti-pattern -- Don't use TissueMask when BiggestTissueBoxMask suffices: TissueMask is more computationally expensive. Use it only when the slide has multiple tissue sections.

  7. Enable logging for batch processing: logging.basicConfig(level=logging.INFO) provides progress tracking during extraction.

  8. Generate CSV reports with ScoreTiler: Use report_path in extract() to create metadata manifests for downstream ML pipelines.

  9. 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, Compose chaining, 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):

  1. 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
  2. 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
  3. tile_extraction.md (421 lines) -- (a) Migrated to references/tile_extraction.md with condensation
  4. filters_preprocessing.md (514 lines) -- (a) Migrated to references/filters_preprocessing.md with condensation
  5. 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

基于nnU-Net的医学图像自动分割技能,支持CT、MRI等模态。通过自配置框架自动选择架构与预处理,实现从数据转换、训练到预测的全流程自动化,适用于有标注数据的器官或细胞分割任务。
需要进行医学图像(如CT、MRI)的深度学习分割 需要为新的分割挑战建立强基线模型 拥有20个以上带标注的训练案例
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_results must 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.30.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 2502000 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

通过omero-py客户端连接OMERO服务器,以编程方式检索显微图像为numpy数组、管理ROI及批量标注,集成至Python分析流水线,支持自动化QC与数据溯源。
需要从OMERO服务器下载图像进行分析 批量添加图像标签或元数据注解 读取和管理ROI区域用于量化 构建基于OMERO数据的自动化图像处理流程
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, or imageio directly.

Prerequisites

  • Python packages: omero-py, numpy, Pillow
  • System: Java 8+ (required by omero-py internals), 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-py has 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 102465535 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

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

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

  3. 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, use getTiles.

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

  5. 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.model objects 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

PathML是用于全切片图像的计算病理学Python工具包,提供从原始WSI加载、瓦片提取、染色归一化、核分割到特征提取及机器学习训练的端到端流水线。
需要处理全切片病理图像(WSI) 执行组织或细胞核分割任务 对H&E或免疫荧光图像进行染色归一化 构建基于WSI的机器学习训练数据集
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-image or cellpose directly 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 321 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 of Tile objects, 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 ImageFolder dataset 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

用于医疗影像处理,支持读取、写入和修改DICOM文件。可提取元数据与像素数据(NumPy数组),进行窗宽窗位调整、PHI去标识化及3D重建。适用于CT/MRI等格式分析。
读取DICOM文件元数据 将DICOM像素转为NumPy数组 对医疗影像进行去隐私化处理 应用窗宽窗位调整 从系列DICOM构建3D体积
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-processing instead
  • For NIfTI neuroimaging volumes (.nii/.nii.gz), use nibabel instead

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

  1. Use stop_before_pixels=True for 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)
    
  2. Always use getattr() with defaults for optional tags: DICOM files vary widely in which tags are present. Direct attribute access raises AttributeError on missing tags.

    # Good
    thickness = getattr(ds, 'SliceThickness', None)
    # Bad — will crash on files without SliceThickness
    thickness = ds.SliceThickness
    
  3. Apply Modality LUT before windowing for CT data: Raw pixel values are stored values; apply apply_modality_lut() first to convert to Hounsfield Units, then apply_voi_lut() for display.

  4. Install compression handlers before accessing compressed pixel data: Check ds.file_meta.TransferSyntaxUID and install the appropriate handler. Attempting pixel_array without the handler raises RuntimeError.

  5. Generate new UIDs for every modified file: Never reuse original SOPInstanceUID after modifications. Use pydicom.uid.generate_uid() to ensure global uniqueness.

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

  7. Sort series by ImagePositionPatient for 3D reconstruction: InstanceNumber is 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):

  1. SKILL.md (434 lines) -- Migrated: overview, workflows, best practices, common issues restructured into new SKILL.md format
  2. references/common_tags.md (229 lines) -- (b) Consolidated: essential 20-tag table into Key Concepts; full catalog into references/dicom_standards.md
  3. 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
  4. scripts/anonymize_dicom.py (138 lines) -- (c) Absorbed: PHI tag list and anonymize function into Core API Module 4; batch pipeline into Workflow 3
  5. 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
  6. 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

基于SimpleITK处理3D医学图像,支持MRI/CT等多模态图像的刚性/形变配准、阈值分割、滤波及格式转换。适用于纵向研究对齐、细胞分割及DICOM转NIfTI等任务。
医学图像配准 3D图像分割 DICOM格式转换
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 with regionprops, morphological operations, and watershed on non-volumetric fluorescence microscopy data

Prerequisites

  • Python packages: SimpleITK>=2.3, numpy, matplotlib
  • Optional: SimpleITK-SimpleElastix for 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.55.0 mm Gaussian smoothing width; larger values blur more and reduce noise
numberOfHistogramBins OtsuThreshold, MutualInfo 128 50256 Histogram resolution for threshold/metric calculation
numberOfIterations ImageRegistrationMethod 100 50500 Max optimizer steps; increase for complex/fine registration
learningRate GradientDescent optimizer 1.0 0.012.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 110000 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.54.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

  1. 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)
    
  2. 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
    
  3. Use sitkNearestNeighbor for 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.

  4. Run multi-resolution registration for speed and robustness: Set SetShrinkFactorsPerLevel([4,2,1]) and SetSmoothingSigmasPerLevel([2,1,0]) to prevent the optimizer from getting trapped in local minima on large 3D volumes.

  5. Validate registration visually with checkerboard comparison: Before trusting a registration result, verify alignment using sitk.CheckerBoard between 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

自动注释质粒序列,识别启动子、抗性基因等特征。支持循环拓扑,输出GenBank、HTML地图及CSV。适用于验证合成构建体、准备Addgene提交或批量注释克隆库。
质粒序列自动注释 验证合成构建体元件完整性 生成可分享的交互式质粒图 批量注释克隆库 准备GenBank或Addgene提交文件
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 0500 bp Minimum feature length in bp; increase to suppress short spurious matches
blast_identity_threshold 95 70100 % 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

提供sgRNA设计的三级指南,优先使用Addgene验证序列,其次选用CRISPick预计算数据集,最后采用从头设计规则。旨在优化CRISPR实验的靶向效率与特异性,降低实验失败风险。
需要设计sgRNA进行CRISPR基因编辑 查询已验证的sgRNA序列 获取CRISPR实验的引用文献
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):

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

  1. Always exhaust Tier 1 before moving to Tier 2. Even if addgene_grna_sequences.csv returns no rows, run the literature search step (Method 2). Many validated sgRNAs are published in supplementary materials but never deposited at Addgene.
  2. 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.
  3. Use Combined Rank as the default selector. It balances on-target efficiency and off-target specificity; reach for On-Target Rank or Off-Target Rank only when the experiment has a specific reason to prioritize one.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 (AsCas12a vs enAsCas12a) to the exact enzyme variant used at the bench.
  • 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.
  • 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 TTTT and homopolymer runs >4.
  • Pitfall: Failing to cite the original publication for a validated sgRNA.

    • How to avoid: Record the PubMed_ID from the Addgene CSV (or the DOI from the literature search) as part of the design record and include it in the methods section.

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 symbol
  • Target_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 number
  • Plasmid_URL: Direct link to plasmid page
  • PubMed_ID: Publication reference (cite this in your work)
  • PubMed_URL: Direct link to paper
  • Depositor: 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):

  1. Record the sgRNA sequence (20bp target sequence)
  2. Note the reference:
    • From database: Use PubMed_ID to cite the original paper
    • From web search: Record the publication DOI/PubMed ID
  3. 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:

  1. 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:

  1. Length:
    • 20 bp for SpCas9 and SaCas9
    • 23-25 bp for Cas12a variants (AsCas12a, enAsCas12a)
  2. 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
  3. GC content: 40-60% is optimal
  4. 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

基于ViennaRNA工具包预测RNA二级结构、MFE折叠及碱基配对概率。适用于siRNA/sgRNA靶向设计、核酶设计及RNA-RNA相互作用建模,支持Python绑定与CLI批量处理。
预测RNA最小自由能二级结构 评估siRNA或sgRNA的靶向效率与可及性 计算RNA-RNA相互作用或共折叠 分析RNA结构的碱基配对概率矩阵
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) or RNAstructure instead 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 0100 °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

提供ESM3和ESM C蛋白质语言模型,支持序列生成、结构预测、逆折叠及特征提取。适用于从头设计蛋白质、功能注释及相似性比较,支持本地GPU推理或云端Forge API。
需要生成新的蛋白质序列 从蛋白质序列提取嵌入向量 进行蛋白质逆折叠设计 预测蛋白质三维结构
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.

  1. Load target structure from PDB using ProteinChain.from_pdb() (Core API module 4)
  2. Create ESMProtein prompt with structure but no sequence
  3. Generate 10+ sequence designs with temperature=0.7 for diversity (Core API module 1)
  4. For each design, predict structure from designed sequence (Core API module 3)
  5. Compare predicted structure to target structure (RMSD calculation) to rank designs
  6. Select top designs for experimental validation

Key Parameters

Parameter Module/Function Default Range / Options Effect
num_steps GenerationConfig varies 164 Iterative refinement steps; more = higher quality, slower
temperature GenerationConfig 1.0 0.01.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

  1. 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).

  2. 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).

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

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

  5. Batch sequences to maximize GPU utilization: Processing one sequence at a time underutilizes the GPU. When embedding many sequences, batch them (limited by VRAM).

  6. 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
通过解析本地HMDB XML文件,提供代谢物查询、化学性质、生物背景及光谱数据等功能。适用于离线代谢组学分析,替代REST API限制。
查询代谢物详细信息 获取化学分子属性 分析疾病与生物标志物关联 提取质谱或核磁光谱数据 跨数据库ID映射
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-access instead
  • For live compound property queries without downloading use pubchem-compound-search instead

Prerequisites

  • HMDB XML download: Register at https://hmdb.ca/downloads — download hmdb_metabolites.xml.zip (~6 GB uncompressed)
  • Python packages: lxml (faster XPath) or standard xml.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): hmdbQuery on 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

  1. Build an in-memory index on startup: Parse once (60-120s), build dict by HMDB ID + lowercase name. Never re-parse inside a loop
  2. Always pass the namespace dict: Every find()/findall() needs NS = {'hmdb': 'http://www.hmdb.ca'}. Omitting it returns empty results
  3. Use iterparse for memory constraints: Full XML requires ~8-12 GB RAM. Use elem.clear() in iterparse to process incrementally
  4. Guard against None and empty text: Many optional fields are absent. Always check el is not None and el.text before accessing
  5. Use monoisotopic weight for MS matching: monisotopic_molecular_weight is correct for mass spec; average_molecular_weight for other calculations
  6. 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

用于查询InterPro REST API,获取蛋白质结构域架构、家族分类及成员数据库集成信息。支持按UniProt或InterPro ID检索条目、搜索特定结构域、查看物种分布及关联PDB结构,整合Pfam等13个数据源。
查询蛋白质的结构域组成和家族分类 根据关键词或ID搜索InterPro条目 查找包含特定结构域的蛋白质列表 查看结构域的物种分布信息 将结构域与PDB中的3D结构进行关联
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 Pfam directly; 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 1200 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

  1. Use reviewed proteins for curated domain lists: The unreviewed TrEMBL set is 5–10× larger and contains automated predictions. For benchmarking, family analysis, or training sets, restrict to reviewed (Swiss-Prot) entries to avoid noise from unreviewed predictions.

  2. 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=200 and the next cursor; store intermediate results to disk.

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

  4. 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 IPR000719 gives the union of all member DB matches in one query.

  5. Check type before interpreting entry_protein_locations: Only domain, repeat, and site entries carry meaningful position information. family and homologous_superfamily entries 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 mapping
  • esm-protein-language-model — Generate protein language model embeddings for sequences; useful after identifying a protein family with InterPro
  • pdb-database — Retrieve and download experimental 3D structures by PDB ID; cross-reference structure IDs discovered via InterPro structure queries

References

用于质谱光谱匹配与代谢物鉴定的Python库,支持多格式数据导入、峰值过滤标准化及多种相似度评分,构建可重复分析流程。
需要识别未知代谢物 计算光谱相似度 处理质谱数据格式 构建光谱处理流水线
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

  1. Load spectra from source format (Core API Module 1 — load_from_mzml)
  2. Apply default_filters for metadata harmonization (Core API Module 2)
  3. 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

  1. Always process queries and references identically — apply the same filtering pipeline to both sets to avoid systematic bias in similarity scores
  2. Save intermediate results in pickle — pickle format is fastest for re-loading; use MGF/MSP for sharing with other tools
  3. Pre-filter by precursor mass — for large libraries, use PrecursorMzMatch first to reduce the comparison space, then score with CosineGreedy
  4. Combine multiple metrics — use both CosineGreedy (peak matching) and FingerprintSimilarity (structure) for more robust identification
  5. Check for None after filtering — filters return None when a spectrum fails quality requirements. Always filter: [s for s in processed if s is not None]
  6. 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

基于MaxQuant和Python的蛋白质组学分析流程。支持LFQ/SILAC定量,通过脚本实现数据过滤、归一化、缺失值填充、差异分析及可视化,替代Perseus GUI,提供可重复的分析环境。
执行蛋白质组学LFQ或SILAC定量分析 处理MaxQuant输出的proteinGroups.txt进行下游统计 生成火山图或GO富集分析 需要可复现的Python脚本替代Perseus
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 .raw files 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 15 Minimum peptide pairs required for LFQ normalization; lower values increase coverage but reduce accuracy
maxMissedCleavages 2 04 Tryptic missed cleavages allowed; increase for samples with poor digestion
peptideFdr / proteinFdr 0.01 0.0010.05 FDR thresholds for peptide and protein identifications
MNAR downshift 1.8 1.52.5 Shifts imputation distribution below detection limit in units of column std; larger = more conservative imputation
MNAR width 0.3 0.10.5 Width of imputed distribution relative to column std
t-test alpha 0.05 0.010.1 FDR significance threshold for differential abundance
fc_threshold (volcano) 1.0 0.52.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 iBAQ column.
  • 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

查询Metabolomics Workbench REST API,支持代谢物ID检索、研究发现、RefMet标准化及m/z搜索。需处理特定输入限制(如禁用name)和TSV响应格式,适用于NIH资助的代谢组学数据分析。
查询代谢组学研究数据 代谢物名称标准化为RefMet 基于m/z值识别未知化合物 检索与代谢物关联的基因或蛋白注释
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 rejectedname is not an allowed input_item. Use pubchem_cid, kegg_id, inchi_key, hmdb_id, regno, lm_id, formula, smiles, or abbrev. For free-text input, go through refmet/match/{x} first.
  • refmet/name/{x}/all requires the exact RefMet name (e.g. Glucose, not D-glucose); use refmet/match/{x} for fuzzy normalisation first.
  • moverz/{REFMET|LIPIDS|MB}/{mz}/{ion}/{tol}/txt returns TSV text (no JSON variant).
  • The metstat/filter/... endpoint shown in older examples returns [] — replace with study/{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-database instead
  • For live 110M-compound property lookups use pubchem-compound-search instead

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/jsoninput_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=summary returns a dict when the input identifier is unique (e.g. study_id), a list when it isn't (e.g. last_name).
  • Appending /json to study/.../summary flips the response to TSV. Omit the format suffix — JSON is the default.
  • moverz only emits /txt (TSV); there is no JSON variant.

refmet/match vs refmet/name

  • refmet/match/{user_text} — fuzzy. Always returns a single dict with refmet_name, formula, exactmass, classification. Does not include pubchem_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

  1. Use refmet/match first for free-text input. compound/name/... is rejected by the server (name is not an allowed input_item).
  2. moverz is TSV-only. Parse with pd.read_csv(io.StringIO(r.text), sep="\t") — never call .json() on the response.
  3. Don't append /json to study/.../summary. The default is JSON; the suffix flips the response to TSV.
  4. refmet/name/{x}/all needs the canonical RefMet name. If you have user text, run it through refmet/match first.
  5. Compound results paged by formula come keyed '1','2',... — iterate dict.values() or pass to pd.DataFrame.from_dict(orient="index").
  6. 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 queries
  • pubchem-compound-search — General compound property lookups (110M+ compounds) via PubChemPy
  • kegg-database — Pathway and orthology data complementary to MW's study/metabolite hits
  • chembl-database-bioactivity — Bioactivity data for the same compounds

References

PyOpenMS用于LC-MS/MS蛋白质组学和代谢组学数据处理,支持多格式I/O、信号处理、特征检测及肽段鉴定。适用于原始数据预处理、跨样本特征关联及定量分析。
需要处理mzML或mzXML格式的质谱原始数据 执行色谱峰提取、平滑或质心化处理 进行非靶向代谢组学的特征检测与对齐 从MS/MS结果中进行肽段或蛋白质鉴定
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

  1. Load mzML files for all samples (Core API Module 1)
  2. Centroid each sample (Core API Module 2 — PeakPickerHiRes)
  3. Detect features per sample (Core API Module 3 — FeatureFinder)
  4. Align retention times (Core API Module 3 — MapAlignmentAlgorithmPoseClustering)
  5. Link features to consensus map (Core API Module 3 — FeatureGroupingAlgorithmQT)
  6. 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

  1. Always centroid before feature detection — FeatureFinder requires centroided data. Use PeakPickerHiRes first
  2. Use indexed loading for large filesOnDiscMSExperiment loads spectra on demand, avoiding memory issues for files >1 GB
  3. Preserve original data — Store raw experiment before processing: orig = ms.MSExperiment(exp). Processing is destructive
  4. Profile vs centroid awareness — Check data type: spec.getType() returns 1 (profile) or 2 (centroid). Some algorithms require specific types
  5. Parameter discovery — Print parameters before tuning: for k in params.keys(): print(k, params.getValue(k))
  6. Export to pandas early — Use features.get_df() or consensus.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

通过 UniProt REST API 进行蛋白质搜索、序列获取(FASTA)、ID映射及注释查询。支持按基因名、访问号等检索,适用于生物信息学分析中的蛋白数据提取与比对任务。
需要查询特定蛋白质的详细信息或序列 需要将不同数据库的ID(如Ensembl、PDB)映射到UniProt ID 需要批量获取蛋白质功能注释或GO术语
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

  1. Filter reviewed:true for curated data: Swiss-Prot entries are manually reviewed; TrEMBL entries are computationally predicted. Use Swiss-Prot for high-confidence annotations.

  2. Use TSV format with fields for tabular analysis: Requesting only needed fields as TSV is faster and easier to parse than full JSON entries.

  3. Use streaming for large downloads: The /stream endpoint returns all results without pagination, avoiding the need for multi-page iteration.

  4. Add time.sleep(0.5) between batch requests: Respect API resources, especially when making many sequential requests.

  5. Cache frequently accessed entries locally: UniProt updates monthly; cache results and re-fetch only when needed.

  6. Anti-pattern — querying without organism_id: Broad queries like gene:INS return 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

aeon是兼容scikit-learn的时间序列机器学习工具包,支持分类、回归、聚类等30+算法。处理面板及不等长数据,提供ROCKET等SOTA模型及基准测试功能,是sktime的社区维护分支。
时间序列分类任务 时间序列回归预测 时间序列聚类分析 时间序列分割或异常检测 使用ROCKET等算法处理传感器数据
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 via pip 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 (like n_samples in 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 classifiers
  • scikit-learn-machine-learning — sklearn-compatible downstream models for aeon features
  • matplotlib-scientific-plotting — visualization of time series and classification results

References

天文学核心Python库,提供单位计算、坐标变换、FITS读写、宇宙学及时间处理等功能。适用于天文数据分析和科学计算,一般表格用pandas,射电干涉用CASA。
进行天文单位换算或物理量计算 在天球坐标系之间转换位置 读取或写入FITS格式文件 执行宇宙学距离或红移计算 处理多种天文时间尺度
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

  1. Always attach units — Use Quantity objects (e.g., 10 * u.kpc) to prevent dimensional errors. Bare numbers silently produce wrong results.

  2. Use context managers for FITSwith fits.open(...) as hdul: ensures proper file closing and memory map cleanup.

  3. Process arrays, not loops — All astropy operations accept arrays. Process 10,000 coordinates at once instead of looping.

  4. Be explicit about time scalesTime('2023-01-15', scale='utc') prevents ambiguity. UTC↔TDB differences matter for precision timing.

  5. Use QTable for unit-aware columnsQTable preserves units through I/O; plain Table stores units as metadata only.

  6. Use ECSV for round-trip fidelityt.write('file.ecsv') preserves units, dtypes, and metadata. CSV/FITS lose some metadata.

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

  8. 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 original fits.md and tables.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 original coordinates.md, time.md, and cosmology.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 original wcs_and_other_modules.md and units.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

Dask用于处理超内存数据集的并行分布式计算。支持DataFrame、Array等组件,扩展Pandas/NumPy API至多核单机或HPC集群,适用于GB-TB级数据处理及复杂任务调度。
处理超出可用RAM的大型数据集(10GB-100TB) 在多个核心上并行化Pandas或NumPy操作 需要在HPC集群上分布工作负载
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

  1. Let Dask handle data loading — Never load data into pandas/numpy first then convert. Use dd.read_csv() / da.from_zarr() directly.

  2. Batch compute calls — Use dask.compute(a, b, c) instead of calling .compute() in loops. Allows sharing intermediates.

  3. Use map_partitions over applyddf.apply(func, axis=1) creates one task per row. ddf.map_partitions(func) creates one task per partition.

  4. Persist reused intermediates — Call .persist() on data accessed multiple times, then del when done.

  5. Use the dashboardclient.dashboard_link shows task progress, memory usage, worker states. Essential for diagnosing performance issues.

  6. Anti-pattern — Excessively large task graphs: If len(ddf.__dask_graph__()) returns millions, increase chunk sizes or use map_partitions/map_blocks to fuse operations.

  7. 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. Covers map_partitions patterns, meta parameter, chunking strategies, map_blocks, foldby, and collection conversion. Consolidated from original dataframes.md, arrays.md, and bags.md. Original best-practices.md content 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 original futures.md and schedulers.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

用于在统计分析前过滤无效输入(如常量特征、全NaN列、空文件等),防止产生无意义结果或崩溃。要求强制移除并详细报告被过滤项的数量及原因,确保下游分析的有效样本量透明。
用户询问如何处理含有常量值或NaN的统计数据 准备运行相关性检验或t检验前的数据清洗 处理生物信息学中的空FASTA文件或单序列比对
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.corrcoef returns nan for constant columns without warning.
  • scipy.stats.spearmanr returns (nan, nan) when one array is constant.
  • scipy.stats.ttest_ind returns (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:

  1. The count of items removed.
  2. The reason for removal.
  3. 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

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

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

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

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

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

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

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

  1. 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() >= 2 for every column before computing correlations. Use the reference implementation below.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Using df.var() > 0 without 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.
  7. 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.

  1. Step 1: Load and inspect

    • Load the data into a DataFrame or equivalent structure.
    • Print the shape: rows, columns, and dtype summary.
  2. Step 2: Remove all-NaN features

    • Identify columns (or rows, depending on orientation) where every value is NaN.
    • Remove them and print the count.
  3. Step 3: Remove constant-value features

    • Identify columns with nunique() <= 1 (after excluding NaN).
    • Remove them and print the count.
  4. 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.
  5. 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.
  6. 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

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.
提供科学数据文件的探索性数据分析(EDA)方法论,涵盖格式检测、类型分类及质量评估。支持表格、序列、图像等七类数据,辅助用户理解数据结构并选择后续分析路径。
收到科学数据文件需初步探索 需要确定文件格式或类别 在正式分析前评估数据质量
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

  1. Extension-based: Map file extension to category (primary method)
  2. Magic bytes: Check file header for binary format identification (HDF5: \x89HDF, GZIP: \x1f\x8b)
  3. Content sniffing: For ambiguous extensions (.txt, .dat, .csv), inspect first lines for delimiters, headers, or format markers
  4. Compound extensions: Handle .ome.tiff, .nii.gz, .tar.gz by 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

  1. Always check file integrity first — verify file is complete (not truncated) and readable before deep analysis. Check file size against expectations
  2. 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
  3. Use lazy/streaming readers when availablepl.scan_parquet(), h5py dataset slicing, pysam indexed access prevent memory overflows
  4. Validate metadata against data — cross-check stated dimensions vs actual data, verify column count matches header, confirm timestamps are monotonic
  5. 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
  6. Consider data provenance — note instrument type, software version, processing steps, and any preprocessing already applied. This context affects downstream analysis choices
  7. 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
  8. Preserve analysis reproducibility — include library versions, parameter choices, and random seeds in reports so EDA can be reproduced
  9. Check for batch effects early — in multi-sample datasets, compare distributions across batches, plates, or runs before combining
  10. 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

  1. 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 with chardet

  2. Ignoring missing value encoding — scientific data uses diverse null representations: NA, NaN, -999, empty string, #N/A, .. How to avoid: Specify na_values parameter; check for sentinel values in numeric columns

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

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

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

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

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

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

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

  10. 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 install command)
  • 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:

  1. Header: filename, timestamp, file size
  2. Format Information: type, description, typical use cases
  3. Data Structure: dimensions, dtypes, memory usage
  4. Quality Assessment: completeness scores, validity checks, issues found
  5. Statistical Summary: key metrics and distributions
  6. Key Findings: notable patterns, potential issues, anomalies
  7. 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
GeoPandas空间矢量分析技能,支持多格式读写、CRS转换、几何运算、空间连接与叠加分析、地图可视化及PostGIS交互。适用于地理数据处理与分析任务。
空间数据读取与写入 坐标系转换与处理 几何属性计算(面积、距离等) 空间连接与叠加分析 生成 choropleth 或交互式地图
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

  1. Always check CRS before spatial operations — mismatched CRS gives wrong or empty results
  2. Use projected CRS for area and distance calculations — geographic CRS (degrees) is meaningless for measurements
  3. Match CRS before joinsgdf2 = gdf2.to_crs(gdf1.crs) before gpd.sjoin(gdf1, gdf2)
  4. Validate geometries with .is_valid before complex operations — invalid geometries cause silent errors
  5. Use GeoPackage over Shapefile — no 10-char column name limit, supports multiple layers, better performance
  6. Filter during read — use bbox, columns, where to load only needed data for large files
  7. Set max_distance in sjoin_nearest — unbounded nearest-neighbor search is slow on large datasets
  8. 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

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()
基于LLM自动化生成和验证表格数据假设的工具。支持纯数据驱动、文献整合及混合模式,通过迭代优化提升假设可测试性,适用于欺骗检测等领域,加速科学发现与假设推理过程。
需要从标注数据中自动生成可测试的科学假设 结合文献与数据进行多假设竞争测试 加速特定领域(如欺骗检测)的假设构思
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

提供MATLAB/GNU Octave科学计算能力,涵盖矩阵运算、线性代数、微分方程求解、信号处理及可视化。适用于数值分析任务,兼容双平台语法,明确区分Python/statsmodels适用场景。
矩阵运算与线性代数求解 常微分方程数值解算 信号处理与频谱分析 科学数据可视化绘图 数值优化与曲线拟合
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

  1. List files with dir('data/*.csv')
  2. Loop through files, load each with readtable()
  3. Apply analysis function to each file
  4. Collect results into a summary table with vertcat()
  5. 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

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

  2. 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
    
  3. Use \ instead of inv() for linear systems: A\b is numerically more stable and faster than inv(A)*b.

  4. Anti-pattern — using == for floating-point comparison: Use abs(a - b) < tol instead of a == b due to floating-point precision.

  5. Save figures in vector format for publications: Use print('-dpdf', 'fig.pdf') or print('-dsvg', 'fig.svg') instead of PNG for scalable figures.

  6. Anti-pattern — mixing 0-indexed and 1-indexed logic: MATLAB arrays start at 1. When porting from Python/C, adjust all indices.

  7. Use fprintf over disp for formatted output: fprintf gives control over number formatting; disp only 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

提供含缺失值特征矩阵的NaN安全Spearman/Pearson相关性计算指南。强调逐对删除策略,规避批量矩阵方法的陷阱,处理退化输入并优化大规模数据性能。
计算基因或蛋白质等特征间的相关性 数据包含NaN缺失值 需要避免批量计算导致的精度损失
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

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

  2. Use scipy.stats.spearmanr per feature in a loop: This is the only method that guarantees correct pairwise NaN removal for each feature independently.

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

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

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

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

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

  1. Using bulk rank-then-correlate with NaN present: df.rank() followed by corrwith() silently mishandles NaN, producing incorrect correlations.

    • How to avoid: Always use scipy.stats.spearmanr per feature when NaN is present.
  2. 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_valid for every feature.
  3. 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.
  4. 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).
  5. 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.
  6. 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

  1. 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
  2. Step 2: Filter Degenerate Features

    • Remove constant features (nunique < 3)
    • Remove features with excessive NaN (< 50% non-NaN)
    • Report count of removed features
  3. 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)
  4. 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

Related Skills

  • statistical-analysis -- General statistical test selection and assumption checking
  • degenerate-input-filtering -- Broader guide on filtering uninformative data before any statistical test
  • scikit-learn-machine-learning -- Feature selection and preprocessing pipelines
NetworkX图分析工具,支持创建、操作和分析复杂网络。涵盖有向/无向/多重边图结构,提供中心性计算、最短路径、社区发现及图生成算法。支持GraphML等格式读写与可视化,适用于社交、生物及交通网络关系数据分析。
需要分析图或网络拓扑结构 计算节点中心性或寻找最短路径 检测社区或聚类 处理社交、生物或交通网络数据
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 igraph or graph-tool instead
  • For billion-edge graphs or GPU-accelerated analytics, use graph-tool with OpenMP or cuGraph
  • For graph neural networks and deep learning on graphs, use torch-geometric-graph-neural-networks

Prerequisites

  • Python packages: networkx, matplotlib, scipy, pandas, numpy
  • Optional: pydot or pygraphviz (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

  1. Always set random seeds for reproducible generators and layouts: seed=42 in both erdos_renyi_graph() and spring_layout().

  2. Use approximate algorithms for large graphs: nx.betweenness_centrality(G, k=500) samples k nodes instead of all pairs.

  3. Prefer from_pandas_edgelist over manual add_edge loops for bulk data loading -- handles attributes cleanly and is faster.

  4. Copy subgraphs before modification: G.subgraph(nodes) returns a read-only view; call .copy() for a mutable independent graph.

  5. Use GraphML or GML for persistent storage to preserve all node/edge attributes. Edge lists lose metadata unless explicitly handled.

  6. Convert graph types explicitly: D.to_undirected() (DiGraph -> Graph), nx.Graph(M) (MultiGraph -> Graph, collapses multi-edges).

  7. Use sparse matrices for large adjacency exports: to_scipy_sparse_array() is far more memory-efficient than to_numpy_array().

  8. Anti-pattern -- Don't use nx.info(): Deprecated; use G.number_of_nodes(), G.number_of_edges(), nx.density(G) directly.

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

NeuroKit2是用于生理信号(ECG/EEG/EMG等)处理的高阶Python工具包。提供统一的流程接口,自动完成去噪、峰值检测及特征提取,支持合成信号生成,适用于心率变异性分析、肌电检测等场景。
需要处理心电图(ECG)并提取HRV或R峰特征 需要分析脑电图(EEG)、肌电(EMG)或皮电(EDA)信号 需要生成合成的生理测试信号
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é plots
  • statsmodels-statistical-modeling — ANOVA and mixed-effects models on extracted HRV/EDA features
  • scikit-learn-machine-learning — stress/emotion classification using extracted features

References

Neuropixels脑电分析技能,基于SpikeInterface实现从原始数据加载、Kilosort spike排序、质量质控到PSTH及解码等后处理的全流程。支持NP1.0/2.0/Ultra格式,适用于啮齿类或灵长类实验的单单元活动提取与分析。
对Neuropixels记录进行SpikeSorting 执行自动质量质控和单元筛选 计算锁定事件的PSTH或群体解码 将排序数据转换为NWB格式
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 (.prb or 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 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

Nextflow是用于可扩展生物信息学流水线的科学工作流引擎。它基于数据流模型,支持容器化进程和隐式并行。可通过单一配置在本地、HPC或云平台无缝切换执行环境,兼容nf-core生态,适用于大规模基因组数据处理及可重复性分析。
构建跨平台(HPC/云/本地)的可移植生物信息学流水线 使用 nf-core 社区验证的现成分析流程 需要容器化隔离以确保结果可重复性的场景 处理数千个样本的大规模并行计算任务
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 / conda env). Run command -v nextflow first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run nextflow rather than bare nextflow.

# 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

基于Arrow/Rust的高性能DataFrame库,适用于100MB-100GB内存数据。支持表达式API、惰性求值及并行执行,提供快速ETL、聚合、连接等操作,是pandas的高效替代方案。
处理大型表格数据 需要高性能数据处理 替换pandas进行ETL 执行复杂聚合或连接
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/.iloc with labels
  • Strict typing: No silent type coercion; explicit .cast() required
  • Expressions, not methods: pl.col("x").mean() instead of df["x"].mean()
  • Parallel by default: All column operations run in parallel
  • Lazy evaluation: Available via LazyFrame for 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.

  1. Load time-series data with pl.scan_csv() or pl.scan_parquet()
  2. Parse dates: .with_columns(pl.col("date").str.to_date())
  3. Sort by entity and date: .sort("entity_id", "date")
  4. Add lag features: pl.col("value").shift(n).over("entity_id")
  5. Add rolling statistics: pl.col("value").rolling_mean(window_size=7).over("entity_id")
  6. Compute percent change: (pl.col("value") - pl.col("value").shift(1)) / pl.col("value").shift(1)
  7. 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

  1. Use lazy mode for large datasets: pl.scan_csv() not pl.read_csv(). Enables query optimization and streaming.

  2. Stay in the expression API: Avoid .map_elements() (runs Python, no parallelism). Prefer native Polars operations — string, datetime, list namespaces cover most needs.

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

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

  5. Prefer Parquet over CSV: Parquet preserves types, supports predicate pushdown, and is 5–10x smaller. Use compression="zstd" for best compression/speed balance.

  6. Anti-pattern — Python loops over rows: Never iterate rows with for row in df.iter_rows() for computation. Use expressions instead.

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

PyHealth是面向电子健康记录(EHR)的医疗机器学习Python库,支持MIMIC、eICU等数据加载与预处理。提供内置模型训练临床预测任务,涵盖死亡率、药物推荐及再入院预测等场景。
需要处理MIMIC-III/IV或eICU医疗数据 构建基于EHR的临床结局预测模型 进行药物推荐或相互作用预测 使用预训练临床模型如RETAIN或MedBERT 对OMOP-CDM格式数据进行机器学习分析
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 PatientVisit → 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

  1. 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's split_by_patient ensures strict patient-level separation.

  2. Use dev=True during development: MIMIC-III contains ~46,000 patients. Loading the full dataset takes minutes; dev mode loads ~460 patients in seconds. Switch to dev=False only for final training runs.

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

  4. 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 prediction
  • scikit-learn-machine-learning — traditional ML baselines (random forest, gradient boosting) on PyHealth features
  • clinical-decision-support-documents — translating clinical ML model outputs to decision support tools

References

pymoo是用于单目标和多目标优化的Python框架,支持NSGA-II等进化算法。适用于设计优化、超参数搜索及Pareto前沿分析,替代scipy处理无梯度或多目标场景。
需要求解多个冲突目标的优化问题 使用遗传算法或差分进化进行黑盒优化 机器学习模型的多目标超参数调优 计算投资组合或决策分析的Pareto前沿
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.optimize instead 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

  1. Profile your objective function first: pymoo calls the objective function pop_size × n_gen times. If one evaluation takes >1 ms, parallelize using pymoo.core.problem.StarmapParallelization or dask. Profile before optimizing.

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

  3. Set termination based on convergence, not fixed generations: DefaultMultiObjectiveTermination detects stagnation automatically. Fixed n_gen wastes compute if the Pareto front converges early, or terminates too soon if the problem is hard.

  4. Use vectorized Problem not ElementwiseProblem for speed: ElementwiseProblem evaluates one solution at a time; Problem evaluates the whole population in one NumPy call. For numpy-compatible functions this is 10–100× faster.

  5. 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 problems
  • pymatgen — materials property optimization using pymoo for multi-objective crystal structure search
  • scikit-learn-machine-learning — hyperparameter tuning (use pymoo for multi-objective HPO: accuracy vs. latency)

References

用于经典机器学习任务的Python库,支持分类、回归、聚类、降维及模型评估调优。提供与NumPy/pandas无缝集成的API,适用于表格数据处理,但不推荐用于深度学习或超大规模梯度提升场景。
需要进行分类或回归预测 执行数据预处理和特征工程 使用聚类算法分析无标签数据 进行PCA等降维操作 模型超参数调优
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 pytorch or transformers instead
  • For large-scale gradient boosting, use xgboost or lightgbm instead

Prerequisites

  • Python packages: scikit-learn, numpy, pandas
  • Optional: matplotlib, seaborn for 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

基于SHAP框架解释机器学习模型预测,支持树、深度学习及黑盒模型。涵盖解释器选择、特征归因及多种可视化(如蜂群图、瀑布图),用于调试、公平性审计和结果解释。
需要解释机器学习模型预测原因 分析特征重要性或进行模型调试 生成可解释的模型报告
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

  1. Choose specialized explainers firstTreeExplainer > LinearExplainer > DeepExplainer > KernelExplainer. Only use model-agnostic explainers when no specialized one exists
  2. Start global, then go local — begin with beeswarm/bar for overall importance, then waterfall/scatter for individual predictions and feature relationships
  3. Use multiple visualizations — different plots reveal different insights; combine global (beeswarm) + local (waterfall) + relationship (scatter)
  4. Select appropriate background data — 100-500 representative samples from training data; use shap.kmeans() for efficiency
  5. Validate with domain knowledge — unexpectedly high feature importance may indicate data leakage, not true predictive power
  6. Remember SHAP shows association, not causation — a feature's high SHAP importance means the model uses it, not that it causally affects the outcome
  7. 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
基于Python生成器的离散事件仿真框架,适用于队列系统、制造流程、网络及物流模拟。支持资源竞争、容量规划与实体交互建模。
离散事件仿真 排队论模型 生产流程模拟 资源容量规划 SimPy库使用
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):

  1. Create simpy.Store with bounded capacity (Module 2: Resources)
  2. Implement producer process that yield store.put(item) with production delay (Module 2)
  3. Implement consumer process that yield store.get() with processing delay (Module 2)
  4. Add event signaling for backpressure when store full (Module 3: Events)
  5. Collect throughput, queue length, and idle time statistics (Module 4: Monitoring)
  6. 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

  1. Always use context managers for resources: with resource.request() as req: yield req ensures automatic release even on exceptions
  2. Set random seeds for reproducibility: random.seed(42) before creating processes; use numpy.random for more distributions
  3. Collect statistics inline: Append to lists during simulation, compute aggregates after env.run() — don't query mid-simulation
  4. Use triangular distribution for process times: random.triangular(min, max, mode) is more realistic than uniform for service times
  5. Anti-pattern — forgetting yield: env.timeout(5) without yield creates the event but doesn't pause the process. Always yield env.timeout(5)
  6. 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

Snakemake是基于Python的工作流引擎,用于构建可复现、可扩展的生物信息学和数据处理流水线。通过定义文件依赖规则自动解析执行顺序与并行度,支持本地及HPC/云环境部署,并实现基于conda/Singularity的环境隔离。
构建多步骤生物信息学分析流程(如NGS比对、变异检测) 需要将工作流从本地扩展到SLURM集群或云平台 需要自动化管理依赖并仅重运行输入变更的步骤 处理具有分支和3个以上步骤的复杂数据管道
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 Nextflow instead 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 Prefect or Airflow instead 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 / conda env). Run command -v snakemake first and skip the install commands below if it returns a path. When running inside a pixi project, invoke the tool via pixi run snakemake rather than bare snakemake.

# 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 time
  • expand("results/{sample}.bam", sample=SAMPLES) = Python: generates a list of strings NOW (used in rule 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

  1. Always define rule all: Without it, only the first rule in the Snakefile runs. rule all collects all final outputs; Snakemake runs everything needed to produce them.

  2. Use temp() for large intermediates: BAM files before deduplication, unsorted BAMs, and intermediate assemblies can be marked temp() to auto-delete after consumption — saves significant disk.

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

  4. Test with snakemake -n first: 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.

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

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

统一Python框架,用于细胞外电生理数据处理。支持20+格式加载、预处理、运行10+分类器及质量评估。提供无格式依赖的多分类器工作流,适用于跨硬件实验对比与结果导出。
加载多种电生理记录格式 运行 spike sorting 算法 比较不同分类器输出 计算神经元信号质量指标
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-analysis instead 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 neurokit2 instead

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

  1. 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")
    
  2. 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.

  3. Use sparse=True when 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.

  4. Run containerized sorters to avoid dependency conflicts: Kilosort2/3 (MATLAB), IronClust, and other sorters have complex dependencies. Use docker_image=True in run_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)
    
  5. Compute metrics extensions in dependency order: Extensions depend on each other. The canonical order is: random_spikeswaveformstemplatesnoise_levelsspike_amplitudesprincipal_components. Skipping an earlier step causes a MissingExtensionError when a downstream step is requested.

  6. 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 a folder path so the analyzer can be reloaded:

    analyzer = si.load_sorting_analyzer("./analyzer_sc2")
    
  7. Do not compare sorters with mismatched preprocessing: When benchmarking sorters, run all of them on the same preprocessed recording_clean object. 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_spikeswaveformstemplatesnoise_levelsspike_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

SymPy用于Python符号数学,支持代数、微积分、方程求解及代码生成。适用于需要精确符号结果而非数值近似的场景。与NumPy/SciPy(数值计算)和statsmodels(统计建模)互补。
需要进行精确的符号代数运算 求解微分方程或积分 表达式简化与展开 将数学公式转换为C/Fortran代码
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) not 1.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)**21
expand() Distribute multiplication (x+1)**2x**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

  1. Define the ODE using Function and dsolve()
  2. Solve symbolically; apply initial conditions with ics={} parameter
  3. Convert solution to numerical function with lambdify()
  4. 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

  1. Always use Rational() or S() for fractions: 0.5 * x introduces floats that break exact computation. Use Rational(1, 2) * x or S(1)/2 * x.

  2. Add assumptions to symbols: symbols('x', positive=True) enables simplifications like sqrt(x**2) → x. Without assumptions, SymPy must handle the general complex case.

  3. Use lambdify for numerical evaluation, not subs().evalf(): subs/evalf in a loop is 100-1000x slower than a single lambdify call.

    # Slow: [expr.subs(x, v).evalf() for v in values]
    # Fast: f = lambdify(x, expr, 'numpy'); f(np.array(values))
    
  4. 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.

  5. Prefer solveset over solve for algebraic equations: solveset returns proper mathematical sets and handles edge cases better. solve is legacy but still useful for general cases.

  6. 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 for solve to fail.

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

基于PyTorch的图神经网络库,支持GCN、GAT等模型。适用于节点/图分类、链接预测、分子性质预测及3D点云处理。提供数据表示、小批量训练及异构图支持,专为图结构深度学习设计。
构建或训练图神经网络模型 进行分子属性预测或药物发现 执行社交网络或知识图谱的节点/链接预测 处理3D点云或网格数据的深度学习任务
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 + batch vector mapping nodes → graphs. No padding
  • Neighbor sampling: NeighborLoader samples 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

  1. Start with GCNConv: Use 2-layer GCN as baseline before trying complex architectures
  2. Use lazy initialization: Pass -1 as in_channels to infer dimensions automatically: GCNConv(-1, 64)
  3. Normalize features: Apply NormalizeFeatures() transform for citation/social networks
  4. Anti-pattern — too many layers: GNNs typically need only 2-3 layers. Deeper causes oversmoothing. Use JumpingKnowledge or PairNorm if you need depth
  5. GPU transfer: Move both model AND data to GPU: model.to(device), data.to(device)
  6. Anti-pattern — ignoring batch vector: In graph classification, always use global_mean_pool(x, batch) — forgetting batch pools 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 Recipes
  • references/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

基于HuggingFace Transformers的生物医学NLP技能,支持BioBERT等模型进行NER、关系抽取、QA及文本分类。涵盖模型加载、生物医学分词、推理管道构建及微调流程,适用于基因、疾病等实体提取与文献分析。
从生物医学摘要中提取基因、疾病或化学实体名称 对生物医学文献进行分类或问答 使用BioGPT生成假设或摘要 在自定义标注数据集上微调生物医学模型
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

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

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

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

  4. 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 pipelines
  • biorxiv-database — retrieve preprints for NLP analysis before peer review
  • scientific-critical-thinking — evaluate quality of NLP-extracted evidence before using for research conclusions

References

UMAP降维工具,用于数据可视化、聚类预处理及特征工程。支持标准、监督、参数化及密度保持等多种模式,兼容sklearn接口,需先标准化数据。
高维数据降维至2D/3D进行可视化 为HDBSCAN等密度聚类算法准备输入数据 机器学习流水线中的特征工程与嵌入变换
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):

  1. Fit standard UMAP on data (Core API: Standard UMAP)
  2. Create a grid of points spanning the embedding space
  3. Apply reducer.inverse_transform(grid_points) to reconstruct high-dimensional data
  4. 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

  1. Always standardize features: Use StandardScaler before UMAP — unscaled features with different ranges will dominate the embedding.

  2. Set random_state for reproducibility: UMAP uses stochastic optimization; results vary between runs without a fixed seed.

  3. Use different parameters for clustering vs visualization: Clustering needs n_neighbors=30, min_dist=0.0, n_components=5-10. Visualization needs n_neighbors=15, min_dist=0.1, n_components=2.

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

  5. Anti-pattern — using 2D embeddings for clustering: 2D projections lose density information. Use 5-10 components for HDBSCAN input.

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

  7. 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
通过PatentsView API和Google Patents BigQuery访问USPTO专利数据,支持按发明人、受让人、CPC或关键词搜索。适用于竞品监控、现有技术检索及生命科学领域的技术趋势分析。
现有技术检索 竞争对手IP格局分析 特定技术领域专利搜索 发明人网络分析 技术趋势追踪
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-bigquery for 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 110000 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

  1. Request only the fields you need: The "f" (fields) parameter controls what is returned. Requesting patent_abstract for thousands of patents significantly increases payload size and latency.

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

  3. 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())
    
  4. 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.

  5. Validate assignee names: Company names in patent records vary (e.g., "Genentech Inc.", "Genentech, Inc.", "GENENTECH INC"). Use _contains for 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.DataFrame with patent records (columns depend on requested "f" fields)
  • cpc_trend.png — bar chart of patent counts by year
  • assignee_comparison.png — horizontal bar chart comparing companies
  • total_patent_count in 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

用于处理超出内存的大规模表格数据(GB至TB级)的高性能Python库。通过惰性求值和内存映射实现快速聚合、过滤及可视化,支持HDF5/Arrow等格式及云存储,内置ML转换器,适合天文、金融等大数据场景。
数据量超过可用内存 需要大规模数据快速聚合或统计 无需采样直接生成大数据可视化图表 在大数据集上进行特征工程或ML预处理
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

  1. 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').

  2. Keep columns virtual until you must materialize. Virtual columns have zero memory cost. Materialize only when a complex expression is reused in 3+ aggregations.

  3. Batch aggregations with delay=True. Each separate aggregation call scans the entire dataset. Batching with vaex.execute([df.x.mean(delay=True), df.x.std(delay=True)]) reduces N passes to 1.

  4. Use selections instead of creating filtered DataFrames when computing statistics on multiple subsets. df.select(df.age > 30, name='senior') then df.salary.mean(selection='senior') is more efficient than creating df_senior = df[df.age > 30].

  5. Avoid .values and .to_pandas_df() on large data. These load data into RAM, defeating Vaex's purpose. Use only on small subsets or samples.

  6. Save pipeline state for reproducibility. df.state_write('state.json') captures virtual columns, selections, and ML transformers for deployment.

  7. 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 original io_operations.md (704 lines) and performance.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=True batching, 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 original machine_learning.md (729 lines) and visualization.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

用于存储大规模N维数组的Python库,支持分块、压缩及云存储。提供NumPy风格索引,适用于内存不足的大数据计算、并行读写及格式转换。
需要处理超出内存限制的大型多维数组 使用S3或GCS进行云原生数据存储 与Dask结合进行并行科学计算 在HDF5和Zarr格式间进行转换
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

  1. Target 1–10 MB per chunk: Smaller chunks = more metadata overhead; larger chunks = more memory per read. For float32: 512×512 ≈ 1 MB.

  2. Align chunks with access patterns: The most common query dimension should be contiguous within chunks. A 65× performance difference is possible with misaligned chunks.

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

  4. Consolidate metadata for cloud stores: Call zarr.consolidate_metadata(store) after creating all arrays. Then open with zarr.open_consolidated(). This reduces N metadata reads to 1 — critical for S3/GCS latency.

  5. Close ZipStore explicitly: Unlike other stores, ZipStore requires store.close() after writing. Forgetting this corrupts the ZIP file.

  6. Use sharding for millions of small chunks: When chunk count exceeds ~100k, filesystem overhead dominates. Sharding groups chunks into fewer storage objects.

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

通过REST API查询bioRxiv/medRxiv预印本,支持按DOI、类别或日期范围检索元数据(标题、摘要等)及PDF。无需认证,适用于追踪最新科研动态和文献筛选。
查询生物医学领域最新预印本 按特定类别或时间段获取论文列表 验证预印本的DOI及版本历史
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 use openalex-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: biorxiv or medrxiv
  • interval: either a DOI (for single record) or date_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

  1. Always take the last element for latest version: The collection array is sorted oldest-to-newest version. Use collection[-1] to get the most current version of a preprint.

  2. 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().

  3. Respect server resources: Add time.sleep(0.2) between individual DOI lookups; avoid bulk hammering the API.

  4. Cross-check with PubMed: The publisher endpoint reveals when a preprint is published; use pubmed-database to retrieve the full peer-reviewed article metadata.

  5. Handle missing abstracts: Some preprints have empty abstract fields. Always guard with art.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 preprints
  • openalex-database — Broader scholarly index including bioRxiv content after indexing lag
  • literature-review — Guide for incorporating preprints into systematic reviews
  • scientific-brainstorming — Using preprint alerts as input for hypothesis generation

References

提供Cancer Research (AACR)期刊图片准备指南,涵盖分辨率、格式、尺寸限制及独特的层级标签系统(Ai, Aii等),并包含Python代码校验分辨率。
查询癌症研究期刊图片规范 检查图片DPI是否符合要求 生成AACR风格面板标签
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:

  1. Level 1: Capital letters — A, B, C, D
  2. Level 2: Roman numerals — i, ii, iii, iv
  3. 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

  1. Plan display items early: With a 7-item limit for Research Articles, plan which results become figures vs. tables vs. supplementary material before drafting
  2. 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
  3. Include replicate counts in every legend: State both technical and biological replicate numbers for each panel. This is a mandatory requirement, not optional
  4. Avoid boxes and periods on labels: Cancer Research specifically prohibits decorative elements around panel labels. Use clean, unboxed capital letters
  5. 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
  6. Present figures in sequential order: Figures should appear adjacent to their legends in the order they are cited in the text

Common Pitfalls

  1. 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
  2. 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
  3. 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"
  4. 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
  5. 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

提供Cell Press期刊图片制作指南,涵盖分辨率、格式及尺寸规范。新增Python代码示例以验证DPI和设置图表宽度,辅助用户符合严格投稿要求。
准备Cell期刊投稿图片 检查图片分辨率是否达标 设置Matplotlib图表尺寸
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
PDF 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

  1. 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
  2. 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
  3. 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
  4. Embed all fonts in AI files: Adobe Illustrator files must have fonts fully embedded. Missing fonts cause rendering errors during production
  5. 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
  6. 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
  7. Keep original unprocessed data: Cell Press may request raw data during review or post-publication. Archive all originals alongside processed versions

Common Pitfalls

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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

指导科研人员选择参考文献管理软件(Zotero等),应用特定引文格式,管理DOI及跨平台集成。适用于搭建文献工作流或修正引用格式错误。
需要选择参考文献管理软件 配置引文格式如APA或Vancouver 解决Word/LaTeX中的引用同步问题 整理和导入参考文献元数据
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); use https://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

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

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

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

  4. 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 .tex files.

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

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

  7. Export a static archive at submission: Export the reference list as .bib, .ris, or .enl at 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).

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. LaTeX .bib file conflicts in multi-author teams: Multiple team members editing a shared .bib file 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 .bib file. All team members use the same repository; never edit the .bib file manually.
  7. 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

  1. 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.bib in project directory, commit .bib to git
  2. 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
  3. Writing integration

    • Insert citations using the word processor plugin — never type numbers manually
    • (LaTeX) Use \cite{key} with keys from the synchronized .bib file
    • Periodically refresh the bibliography to catch newly inserted references
  4. 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 any undefined citation warnings
    • Export static archive (.bib, .ris) and store alongside submission version
    • (NIH grants) Verify all funded publications have PMCIDs recorded

Protocol Guidelines

  1. 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.
  2. Systematic review deduplication protocol: Export all search results as .ris files, 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.
  3. 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

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 manage
  • latex-research-posters — LaTeX-specific BibTeX configuration and citation key workflow
用于生成临床决策支持文档,涵盖生物标志物分层队列分析和GRADE分级治疗报告。提供结构规范、统计标准(HR/生存率)及证据分级指南,适用于制药研究、临床指南和监管申报。
需要撰写基于生物标志物的患者队列分析报告 制定带有GRADE分级的临床治疗推荐指南 准备药物研发或监管提交的临床决策支持文件
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

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

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

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

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

  5. Use RECIST 1.1 for response assessment: For immunotherapy cohorts, note iRECIST criteria and pseudoprogression handling. Clearly state which criteria were used.

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

  7. Declare data completeness and follow-up: Report median follow-up time, data maturity (% events), and how missing data was handled (complete case, imputation method).

  8. De-identify per HIPAA Safe Harbor: Remove all 18 HIPAA identifiers before including any patient-level data. Add confidentiality headers for proprietary pharmaceutical data.

  9. Color-code consistently: Blue = data/information, green = biomarkers/positive, orange = clinical implications/caution, red = warnings/safety, gray = statistics/methods.

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

  1. 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)."

  2. 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."

  3. 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)."

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

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

  6. 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 \newpage before TOC.

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

  1. Define scope: Identify document type (cohort analysis vs treatment recommendations), disease state, target audience, and data sources
  2. Gather evidence: Collect trial data, biomarker results, published guidelines. For recommendations, perform systematic evidence review
  3. Design document structure: Select appropriate sections based on document type (see Decision Framework). Plan visual elements (survival curves, forest plots, decision algorithms)
  4. Draft executive summary first: Write the page-1 summary boxes before detailed sections. This forces clarity about key findings
  5. 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
  6. Create visual elements: Generate Kaplan-Meier curves, forest plots, waterfall plots, TikZ decision algorithms. Include number-at-risk tables below survival curves
  7. Apply GRADE ratings (recommendations only): Assess each recommendation against evidence quality criteria. Document rationale for each grade
  8. Format in LaTeX/PDF: Apply document template (0.5-inch margins, colored tcolorbox elements, professional tables). Ensure page 1 is executive summary only
  9. 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: tcolorbox package with color-coded environments
  • Tables: booktabs for professional formatting, longtable for multi-page tables
  • Figures: TikZ for decision algorithms, pgfplots for 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

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
提供eLife期刊图表制作指南,涵盖TIFF/EPS等格式、1800x900px冲击图要求、补充材料命名及严禁选择性增强的政策。内置Python代码用于校验分辨率和配置Matplotlib字体。
准备投稿eLife的图表 检查图片是否符合eLife分辨率标准 配置matplotlib以符合eLife排版规范
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
PDF 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

  1. Prepare striking images without any text: eLife strictly prohibits labels or text in striking images. The image must communicate visually without words
  2. 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
  3. Apply all adjustments uniformly to entire images: eLife's screening software detects selective enhancement. Any brightness/contrast change must cover the whole image
  4. Maintain original unprocessed data: Given eLife's screening policy, having originals available demonstrates integrity and speeds up any inquiries
  5. Submit each figure as an individual file: Do not combine multiple figures into a single file. Each figure gets its own upload
  6. 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
  7. Describe all processing in Methods: Document every image processing step (software used, adjustments applied) to preempt screening concerns

Common Pitfalls

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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

提供科学图表通用质量检查清单,涵盖重叠标签、截断文本、缺失坐标轴等可读性问题,以及分辨率、色彩无障碍和图像调整规范,辅助生成或手动组装的图表合规性评估。
用户请求检查生成的科学图表质量 询问图表导出格式或分辨率要求 涉及图表色彩无障碍或图像处理规范
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

  1. 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
  2. Use tight_layout() or constrained_layout=True: These prevent text clipping at figure boundaries, the most common layout failure in matplotlib
  3. Set DPI at figure creation time: Configure fig.set_dpi(300) or plt.savefig(..., dpi=300) to ensure publication-quality output from the start
  4. Export plots as vector formats: Use PDF, SVG, or EPS for graphs and diagrams. Reserve raster formats (PNG, TIFF) for photographs and micrographs
  5. 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)
  6. Add transparency for dense scatter plots: Use alpha=0.3-0.5 when plotting thousands of points to reveal density structure instead of a solid mass
  7. Include units on all axes: Every axis should show the measured parameter and units in parentheses (e.g., "Time (hours)", "Concentration (nM)")

Common Pitfalls

  1. 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
  2. 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 with constrained_layout=True
  3. 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
  4. Overcrowded scatter plots at large N: Scatter plots with >10,000 points become solid blobs at default settings
    • How to avoid: Use alpha transparency, hexbin plots, or kernel density estimation for large datasets
  5. Saving at screen resolution (72 DPI): Default screen DPI produces figures that are unprintable in journals
    • How to avoid: Always specify dpi=300 (minimum) in savefig() or set it on the figure object at creation

Protocol Guidelines

  1. Set up figure dimensions and DPI: Before plotting, configure target width (journal column width), aspect ratio, and DPI (300+ minimum) on the figure object
  2. Generate the figure using your plotting library with the pre-configured settings
  3. 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
  4. If any issue is found, regenerate with targeted fixes (adjust layout, font size, margins, alpha, or figure dimensions)
  5. Export in the correct format: vector (PDF/EPS/SVG) for plots, raster (TIFF/PNG at 300+ DPI) for photographs
  6. Verify the saved file: reopen the exported file to confirm it matches the on-screen rendering

Further Reading

Related Skills

  • nature-figure-guide -- Nature-specific figure requirements
  • cell-figure-guide -- Cell Press figure requirements
  • science-figure-guide -- Science (AAAS) figure requirements
  • pnas-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

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

  2. 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).

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

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

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

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

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

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

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

  3. 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]."

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

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

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

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

  1. Understand the phenomenon: Clarify the core observation, define scope and boundaries, note what is known vs uncertain, identify the relevant scientific domain(s)

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

  3. Synthesize existing evidence: Summarize current understanding, identify established mechanisms that may apply, note conflicting evidence, recognize knowledge gaps, find cross-domain analogies

  4. 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)

  5. Evaluate hypothesis quality: Score each hypothesis against the 7 quality criteria (testability, falsifiability, parsimony, explanatory power, scope, consistency, novelty). Note strengths and weaknesses explicitly

  6. Design experimental tests: For each viable hypothesis, propose specific experiments with: measurements, controls, methods, sample sizes, statistical approaches, and potential confounds

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

  8. 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
提供《柳叶刀》期刊插图准备规范,涵盖300 DPI分辨率、120%提交尺寸、PPT/SVG等可编辑格式偏好、单/双栏宽度及字体要求。旨在协助作者生成符合期刊出版标准的图表文件。
准备投稿柳叶刀期刊的插图 检查图片分辨率和尺寸是否符合柳叶刀标准 询问柳叶刀期刊的图片格式要求
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
PDF 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

  1. Submit editable formats whenever possible: PowerPoint is most preferred because Lancet illustrators redraw figures. Provide the editable source, not a rasterized export
  2. 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
  3. Use Times New Roman consistently: The Lancet's serif font convention differs from most journals. Set Times New Roman as default before creating figures
  4. Remove box outlines from graphs: Lancet style does not use borders around graph panels. Remove any automatic axis boxes
  5. Place titles in captions only: Do not embed titles inside figures or graphs. All descriptive text goes in the figure caption
  6. 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
  7. Number figures in citation order: Figures must be numbered sequentially in the order they are first cited in the text

Common Pitfalls

  1. 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
  2. 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
  3. 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
  4. 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.spines in matplotlib)
  5. 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

提供LaTeX科研海报制作指南,涵盖beamerposter、tikzposter等包选择、尺寸规范、排版字体规则及可视化建议,支持会议学术展示。
制作LaTeX科研海报 选择LaTeX海报模板 调整海报排版与字体
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

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

  2. MANDATORY: Verify poster dimensions match conference requirements exactly. Use pdfinfo poster.pdf | grep "Page size" after compilation. A0 should show ~2384 × 3370 points.

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

  4. Use white space intentionally. White space is not wasted space — it improves readability and visual hierarchy. Don't fill every gap with text.

  5. Keep text scannable. Use bullet points instead of paragraphs. Each section should be understandable in under 30 seconds.

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

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

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

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

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

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

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

  3. 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.pdf to verify.

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

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

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

  7. 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 with pdffonts.

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

  1. Confirm requirements: poster size, orientation, submission deadline, format requirements
  2. Draft content outline: identify 1–3 core messages, select 3–6 key figures
  3. Choose package: beamerposter (institutional), tikzposter (modern), or baposter (structured)
  4. 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

  1. Start from a template (see Companion Assets): customize color scheme, page size, orientation
  2. Configure typography: set font sizes per hierarchy level (title → headers → body → captions)
  3. Set margins: 10mm outer margins recommended; configure column spacing (15–20mm)
  4. Add institutional elements: logos (high-resolution), color codes (official RGB/CMYK values)

Stage 3: Content Integration

  1. Create header: title (10–15 words, concise), authors, affiliations, logos
  2. Populate sections: Introduction, Methods, Results, Conclusions — bullet points preferred
  3. 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
  4. Integrate figures: use \includegraphics with consistent sizing and clear captions
  5. Add references: 5–10 key citations in abbreviated style; consider QR code to full bibliography
  6. Add QR codes: link to paper DOI, GitHub repo, supplementary data

Stage 4: Refinement

  1. Review visual balance: ensure 40–50% visual content, no overcrowded sections
  2. Check typography: all fonts readable at intended viewing distances
  3. Verify color accessibility: test with color-blindness simulator (e.g., Coblis)
  4. Check contrast ratios: text-background ≥ 4.5:1 (WCAG AA), important elements ≥ 7:1 (WCAG AAA)
  5. Proofread: all text, author names, affiliations, numbers, statistics

Stage 5: Compilation and QC

  1. Compile: pdflatex poster.tex (or lualatex for better font support)
  2. Verify page size: pdfinfo poster.pdf | grep "Page size" — must match requirements exactly
  3. Verify font embedding: pdffonts poster.pdf — all "yes" in "emb" column
  4. Verify image resolution: pdfimages -list poster.pdf — all ≥300 DPI
  5. Reduced-scale print test: print at 25% on letter/A4; check readability from 2, 4, 6 feet
  6. Peer review: 30-second test (can they identify main message?), 5-minute review (do they understand conclusions?)
  7. Optimize for delivery: compress for email (< 10MB) if needed; keep original for printing
  8. 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

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.

指导开展系统、范围及叙述性文献综述,涵盖PRISMA协议、检索策略构建、数据库选择、筛选提取、证据合成及报告规范,适用于正式文献回顾的规划与执行。
需要撰写或规划系统综述 询问PRISMA或PROSPERO注册流程 制定PICO检索策略 比较不同综述类型
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

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

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

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

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

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

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. 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)
  2. 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
  3. 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)
  4. 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
  5. 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
  6. 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

Related Skills

  • citation-management — reference managers for collecting and organizing search results before and during review
  • statistical-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
提供Nature期刊插图制作规范,涵盖300+ DPI分辨率、AI/EPS/TIFF等格式要求及RGB色彩模式。包含Python代码用于校验图像DPI和文件大小,指导作者准备符合出版标准的高质量单文件多面板图表。
询问Nature期刊图片格式要求 检查图片分辨率是否达标 需要生成Nature风格图表
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

  1. 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
  2. Use colorblind-friendly palettes: Nature strongly recommends accessible color schemes. Avoid red-green combinations; use blue-orange or include pattern/shape differentiation
  3. 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
  4. Assemble multi-panel figures before upload: Combine all panels (a, b, c, etc.) into a single image file. Individual panel uploads will be rejected
  5. 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
  6. Apply adjustments uniformly: Any brightness, contrast, or color correction must be applied to the entire image, not selectively to specific regions
  7. Retain original unprocessed data: Editors or reviewers may request raw image files at any stage of review or post-publication

Common Pitfalls

  1. 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
  2. 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)
  3. 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
  4. 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
  5. 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
  6. 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

提供NEJM论文插图准备指南,涵盖黑白线稿1200 DPI、照片300 DPI等分辨率要求。强调数据可视化需使用AI/EPS/SVG等可编辑矢量格式,并严禁作者提交医学插图,须由期刊内部团队制作。
查询NEJM插图格式要求 检查图片分辨率是否符合NEJM标准 咨询NEJM医学插图投稿政策
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

  1. 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
  2. Do not submit finished medical illustrations: NEJM's in-house illustrators will create these. Submit reference sketches or descriptions instead
  3. Remove all patient-identifying information: Strip faces, names, record numbers, and any identifiable features from clinical images before submission
  4. Prepare high-resolution originals early: Although peer review accepts lower resolution, having 300+ DPI originals ready prevents delays during final production
  5. 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
  6. Respect word limits for Images in Clinical Medicine: Titles must be 8 words or fewer; legends must be 150 words or fewer
  7. Maintain original unprocessed files: NEJM may request raw data at any point during review or after publication

Common Pitfalls

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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

查询OpenAlex学术数据库,支持2.5亿+文献、作者及机构检索。提供关键词、DOI等搜索,支持引用网络分析、作者去重及OA全文获取。免费无需认证,适用于跨学科文献综述与计量分析。
学术文献检索 构建系统综述语料库 引用网络与计量分析 作者身份去重 开放获取全文链接查找
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 use biorxiv-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.com query 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 1200 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

  1. Always include mailto: Add mailto=your@email.com to all requests to join the polite pool and receive priority processing without rate throttling.

  2. Use select for 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.

  3. Use cursor pagination, not offset: OpenAlex does not support offset pagination beyond 10,000 results. Use cursor-based pagination (cursor parameter) for deep traversals.

  4. Reconstruct abstracts from inverted index: Not all works have abstracts; check abstract_inverted_index is not None before reconstructing to avoid KeyError.

  5. 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 sciences
  • biorxiv-database — Biomedical preprints not yet indexed in OpenAlex
  • scientific-brainstorming — Hypothesis generation workflows using literature as input
  • literature-review — Guide for designing systematic literature reviews using OpenAlex

References

提供结构化同行评审技能,涵盖论文与基金申请的7阶段评估流程。支持原创研究、综述等类型,集成CONSORT/PRISMA等报告标准,指导统计严谨性、可重复性及伦理审查,生成带行动建议的专业评审报告。
需要评审科学论文或基金申请书 检查研究是否符合CONSORT/STROBE等报告规范 评估实验设计的统计学严谨性或数据可重复性
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

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

  2. 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").

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

  4. 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").

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

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

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

  8. 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."

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

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

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

  2. 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?"

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

  4. 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).

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

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

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

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

  2. 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
  3. Statistical and methodological rigor: Check statistical assumptions, effect sizes, multiple testing correction, sample size justification, appropriate test selection, control adequacy, randomization, blinding

  4. Reproducibility and transparency: Verify data availability (repositories, accession numbers), code availability, reporting standards compliance (CONSORT/STROBE/PRISMA/ARRIVE), protocol detail

  5. Figure and data integrity: Check resolution, labeling, error bar definitions, color accessibility, signs of image manipulation, scale bars, data type appropriateness

  6. Ethical considerations: Verify IRB/IACUC approval, informed consent, 3Rs compliance, conflicts of interest disclosure, funding disclosure, data privacy

  7. 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
提供PNAS期刊图片制备指南,涵盖分辨率、格式(TIFF/EPS/PDF)、严格RGB色彩模式及尺寸规范。内置Python代码用于自动检查图片分辨率和生成符合排版要求的Matplotlib图表。
准备PNAS投稿图片 检查图片分辨率是否达标 生成符合PNAS尺寸的图表
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)
PDF 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

  1. Convert CMYK to RGB before submission: PNAS returns CMYK files without review. Always verify color mode before upload using image metadata tools
  2. Tag RGB images with ICC profiles: Embedding the originating ICC profile ensures accurate color conversion during PNAS's print production pipeline
  3. 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
  4. Use italicized uppercase panel labels: PNAS uses a distinctive convention — A, B, C (italic, uppercase) — that differs from most other journals
  5. Embed all fonts in EPS and PDF files: Missing fonts cause rendering failures. Use vector text rather than rasterized text for clean scaling
  6. Prepare alt text for accessibility: Since Volume 123, PNAS includes alt text for all figures. Drafting alt text during figure preparation saves revision time
  7. Name processing software in Methods: PNAS requires explicit mention of all image processing software used, not just a general description of adjustments

Common Pitfalls

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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

提供通过NCBI E-utilities REST API程序化访问PubMed数据库的能力。支持布尔/MeSH查询、字段标签搜索及ESearch等核心端点,适用于生物医学文献检索、自动化流水线构建及系统综述。
检索生物医学文献 构建自动化文献监控或提取流水线 进行系统综述或荟萃分析 通过PMID/DOI获取文章元数据或摘要 批量处理PubMed记录
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:

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:

  1. ESearch with usehistory=y → returns WebEnv + QueryKey
  2. EFetch in batches using WebEnv + QueryKey + retstart/retmax
  3. 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

  1. Always use an API key — register at NCBI for 10 req/sec instead of 3
  2. Use history server for >500 results — avoids URL length limits and enables batch fetching
  3. Include rate limitingtime.sleep(0.1) with API key, time.sleep(0.34) without
  4. Cache results locally — PubMed data changes slowly; cache responses to minimize API calls
  5. Use MeSH terms + free text — combine [mh] and [tiab] for comprehensive coverage: (diabetes mellitus[mh] OR diabetes[tiab])
  6. Document search strategies — for systematic reviews, record exact queries, dates, and result counts
  7. 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 filters
  • references/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

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
提供Science期刊图表制作规范,涵盖分辨率(150-300+ DPI)、格式(PDF/TIFF等)、尺寸、RGB色彩及字体要求。包含Python代码校验分辨率与创建适配图表,严禁上采样,强调非线性调整披露。
准备Science期刊投稿图片 检查图片DPI是否符合标准 生成符合Science尺寸的Matplotlib图表 确认图片格式和色彩模式
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 .docx or 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

  1. 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
  2. Disclose all nonlinear adjustments in captions: Gamma corrections and other nonlinear transforms must be explicitly noted in the figure caption, not just in Methods
  3. 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
  4. Keep captions under 200 words: Science enforces a ~200-word limit on individual figure captions. Move detailed methodology to the Methods section
  5. Place titles in captions, not figures: Figure titles belong at the beginning of the caption text, not rendered inside the figure itself
  6. Label axes with parameter, units, and scale: Science requires all axes to include the measured parameter, units in parentheses, and scale information
  7. 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

  1. 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
  2. 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")
  3. 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
  4. 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
  5. 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

提供SCAMPER、TRIZ等结构化科学头脑风暴方法,通过匹配挑战类型生成可测试的研究假设。强调协作、好奇心与领域约束,引导发散探索至综合评估,激发跨学科创新思路。
需要生成科学研究想法 探索跨学科连接 改进现有实验方法 解决科研中的思维僵局
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:

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

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

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

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

  5. 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:

  1. 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?"
  2. Biologize: Translate the function into biological terms. "What organisms need to separate particles from fluid?"
  3. Discover: Search for organisms that have solved this problem. Use resources like AskNature.org or consult biological literature.
  4. Abstract: Extract the design principle from the biological model. Not "copy the kidney" but "use countercurrent flow with selective permeability membranes."
  5. 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

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

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

  3. 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."

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

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

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

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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)

  1. 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.
  2. 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.
  3. 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)

  1. 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.
  2. 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.
  3. 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)

  1. 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.
  2. 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?
  3. 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)

  1. 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.
  2. 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.
  3. 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

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
评估科学证据与声明,涵盖研究设计层级、效应量(OR/RR/NNT等)、混杂因素、P值与临床意义区别、GRADE质量评估及各类偏倚。用于阅读论文或评估主张时的批判性思维分析。
评估科学研究论文的质量 判断健康或医学声明的可信度 分析临床试验数据结果 识别研究中的潜在偏倚
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

  1. 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?

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

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

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

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

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. 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
  2. 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
  3. 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?
  4. 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
  5. 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

Related Skills

  • literature-review — applying critical appraisal systematically across a body of evidence
  • biostatistics — quantitative tools for calculating and interpreting effect measures
  • peer-review-methodology — structured application of critical thinking to manuscript review
提供科学论文写作指南,涵盖IMRAD结构、APA/AMA等引用格式、图表设计及CONSORT/PRISMA等报告规范。强调清晰、简洁、准确原则,适用于生物医学及工程等领域,助力产出高质量学术稿件。
撰写学术论文 调整论文结构为IMRAD 选择引用格式 检查报告规范合规性
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:

  1. Self-explanatory: Each display item must stand alone with a complete caption defining all abbreviations, units, sample sizes, and statistical annotations
  2. No redundancy: Text highlights key findings from displays; it does not repeat all data
  3. Consistency: Uniform formatting, color schemes, and terminology across all display items
  4. Optimal quantity: Approximately one display item per 1,000 words of manuscript
  5. 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

  1. Write the abstract last: After completing all sections, synthesize the key message into 100-250 words. Include quantitative results with statistical measures.
  2. 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.
  3. 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.
  4. 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.
  5. 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...").
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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").
  7. 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.
  8. 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

  1. 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)
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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 manuscripts
  • seaborn-statistical-plots -- Statistical visualizations with automatic CI and aggregation
  • plotly-interactive-plots -- Interactive figures for supplementary materials
  • statsmodels-statistical-modeling -- Statistical models whose outputs you report in manuscripts
  • pymc-bayesian-modeling -- Bayesian analysis results for reporting
  • peer-review-methodology -- Structured peer review of manuscripts (complements this entry)
  • latex-research-posters -- Poster preparation using LaTeX
  • scientific-slides -- Conference presentation preparation
  • scientific-brainstorming -- Ideation methods for framing research questions

Companion Assets

  • assets/scientific_report.sty -- LaTeX style package for professional scientific reports
  • assets/scientific_report_template.tex -- Complete report template with all style features demonstrated
  • assets/REPORT_FORMATTING_GUIDE.md -- Quick reference card for box environments, color palette, and scientific notation commands
指导设计生物科学示意图,涵盖机制图、通路图及图形摘要等类型。提供BioRender等工具对比、构图原则、图标选择及无障碍标准,适用于非数据驱动的科研插图制作。
创建科学示意图 设计生物学通路图 制作期刊图形摘要 绘制实验流程图 选择绘图软件
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

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

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

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

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

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

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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 scalebar command or annotate in Inkscape after export.

Workflow

  1. Define the message — write one sentence stating what the schematic must communicate
  2. Sketch on paper — rough layout, element list, arrow types
  3. Choose tool — BioRender for icon-heavy biology; Inkscape for custom vectors; ChimeraX for structures
  4. Set canvas to print dimensions — match journal column width before placing any element
  5. Build in layers — background → structural elements (membranes, organelles) → protein/molecule icons → arrows → labels
  6. Apply visual vocabulary — consistent colors, arrow styles, fonts per lab style guide
  7. Run accessibility check — CVD simulation; test grayscale version
  8. Export from vector source — PDF or TIFF at target DPI (≥300 dpi raster, vector for line art)
  9. Archive source file — commit .svg/.afdesign/.brd to version control alongside manuscript

Protocol Guidelines

  1. 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.
  2. 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.
  3. ChimeraX protein structure render: open 6YYTgraphics silhouettes truelighting softsaveimage figure.png width 2400 height 2400 → annotate in Inkscape.
  4. 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

Related Skills

  • scientific-visualization — data-driven figures (graphs, heatmaps) as distinct from illustrative schematics
  • scientific-manuscript-writing — figure legend writing and integration of schematics into papers
  • latex-research-posters — assembling schematics into poster layouts
提供科研演示文稿的全流程设计指南,涵盖会议、答辩等场景的结构规划、视觉设计、数据可视化及演讲节奏控制,支持PowerPoint和LaTeX Beamer格式。
制作学术会议PPT 准备博士论文答辩幻灯片 优化科研海报或基金申请演示 调整LaTeX Beamer模板
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:

  1. Hook — grab attention (30–60 seconds)
  2. Context — establish importance (5–10% of talk)
  3. Problem/Gap — identify what's unknown (5–10%)
  4. Approach — explain your solution (15–25%)
  5. Results — present key findings (40–50%)
  6. Implications — discuss meaning (15–20%)
  7. 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

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

  2. 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).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. Define context: talk type (conference/seminar/defense), duration, audience (specialist/general/mixed), venue (room size, virtual/in-person)
  2. Develop content outline: identify 1–3 core messages, select 3–6 key figures, allocate time per section
  3. Search literature: find 8–15 relevant papers for citations (3–5 for introduction context, 3–5 for discussion comparison)
  4. Choose implementation tool: PowerPoint (editable, animations, company templates) vs. LaTeX Beamer (equations, version control, mathematical content)
  5. Plan slide-by-slide: title, key points, visual element for each slide

Stage 2: Design and Creation

  1. Start from template (see Companion Assets for Beamer; use master slides for PowerPoint)
  2. Select modern color palette: match your topic — 3–5 colors with high contrast
  3. Configure typography: sans-serif, 24–28 pt body, 36–44 pt titles
  4. Create varied layouts: mix full-figure slides, two-column (text + figure), visual overlays
  5. Add section dividers: visually distinctive slides between major sections

Stage 3: Content Development

  1. Visual backbone first: place all figures, charts, diagrams, images before adding text
  2. Generate figures: create presentation-appropriate plots using matplotlib, plotly, or seaborn — larger fonts (18–24 pt labels), fewer panels, direct labeling, high contrast
  3. Add minimal text: bullet points complement visuals, don't replace them
  4. Include citations: author-year format on relevant slides (small text, bottom or near data)
  5. Add transitions: control information flow with builds for complex slides
  6. Prepare presentation aids: for physical presentations, bring backup copies, adapters, handouts

Stage 4: Validation and Refinement

  1. Visual inspection: check each slide for text overflow, element overlap, font sizes, contrast
  2. Readability test: view at 50% zoom — everything should still be readable
  3. Content review: verify narrative flow, one idea per slide, consistent formatting
  4. Peer review: ask colleague for 30-second test (can they identify the main message?)
  5. Compilation check (Beamer): verify no LaTeX warnings, all figures render correctly

Stage 5: Practice and Delivery

  1. Practice 3–5 times with timer: run 1 (rough), run 2 (smooth transitions), run 3 (exact timing), run 4+ (polish)
  2. Set timing checkpoints: mark 3–4 points on notes (e.g., "should be here by 4 min")
  3. Practice transitions: connecting phrases between sections
  4. Prepare for Q&A: anticipate questions, prepare backup slides with additional data
  5. 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
用于访问AlphaFold DB获取2亿+预测蛋白结构。支持通过UniProt ID检索、下载PDB/mmCIF文件、分析pLDDT/PAE置信度及批量获取蛋白质组数据,适用于缺乏实验结构的建模与药物发现。
根据UniProt ID获取AI预测的蛋白质3D结构 下载PDB或mmCIF坐标文件进行结构分析或对接 分析预测置信度指标如pLDDT和PAE 批量下载整个蛋白质组的预测数据 为缺乏实验数据的蛋白质构建结构模型
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

  1. 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
  2. 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
  3. Anti-pattern — trusting all regions equally: AlphaFold predictions lack ligands, PTMs, cofactors, and multi-chain context. High pLDDT does not guarantee functional accuracy
  4. Cache downloaded files locally — avoid re-downloading the same structures; AlphaFold files are static per version
  5. 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
  6. Pin database version in reproducible analyses — include _v4 in 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

基于AutoDock Vina的分子对接技能,涵盖受体/配体预处理、网格框定义、对接执行及结合能分析。支持从PDB或SMILES生成输入,适用于预测结合模式、虚拟筛选及构效关系研究。
需要预测小分子与蛋白质的结合姿态 进行化合物库的虚拟筛选以识别潜在活性分子 计算和比较不同配体的相对结合亲和力 通过重新对接共结晶配体验证对接协议
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 (provides prepare_receptor for 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 header
  • results/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

通过ChEMBL公开REST API查询生物活性数据。支持按名称或ID检索化合物、获取IC50/Ki等活性值、查找靶点抑制剂及分析SAR关系,无需API密钥,依赖requests库。
查询特定化合物的生物活性数据 搜索针对某靶点的抑制剂或激动剂 进行药物结构-活性关系(SAR)分析 根据理化性质筛选符合Lipinski规则的分子
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_client package is convenient sugar over a public, no-auth REST/JSON API at https://www.ebi.ac.uk/chembl/api/data/. When the SDK is unavailable, every operation can be reproduced with plain requests and URL parameters. This SKILL.md uses the REST path throughout so the code runs in any environment with requests installed. 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-cheminformatics instead
  • For an alternative compound database (NIH, broader coverage) use pubchem-compound-search

Prerequisites

  • Python packages: requests (only requirement). Optional: pandas for 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} 0100 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_value for cross-study comparisons — it normalizes IC50/Ki/EC50 to a comparable -log10 scale.
  • Always check data_validity_comment before computing aggregates — flagged rows can skew distributions.
  • Pin standard_units="nM" in activity queries to avoid mixing nM with µM.
  • Follow page_meta.next for pagination instead of incrementing offset manually — the URL already carries the right cursor.
  • URL-encode SMILES in path-style endpoints (/similarity/{smiles}/..., /substructure/{smiles}) with urllib.parse.quote(smi, safe="").
  • Use a Session with 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__icontainsEGFR, BRAF, HER2 all return 0 hits. Use the spelled-out term or filter via target_components__accession=<UniProt> instead.

Related Skills

  • rdkit-cheminformatics — SMILES manipulation, fingerprints, descriptors
  • datamol-cheminformatics — molecular preprocessing & featurization
  • pubchem-compound-search — alternative compound database (NIH; broader coverage but less bioactivity depth)
  • pdb-database — 3D structures of ChEMBL targets via RCSB PDB REST API
  • opentargets-database — links ChEMBL drug-target evidence to disease associations

References

通过REST API查询FDA药物标签(DailyMed),支持按名称、NDC等搜索,获取适应症、剂量、警告等结构化数据。无认证需求,适用于官方药品信息查询与研究。
查询FDA批准的药品说明书 获取药物的适应症、剂量或警告信息 根据NDC或RxCUI查找药品包装细节
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-database instead; 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 1100 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

  1. Resolve set IDs before batch processing: Use the /spls search 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.

  2. 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
    
  3. 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.

  4. Pagination for comprehensive results: The default pagesize=20 may miss formulations. Use metadata["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)
    
  5. 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 actions
  • ddinter-database — drug-drug interaction severity and mechanisms from DDInter
  • drugbank-database-access — comprehensive drug information including targets, pathways, and chemical properties
  • clinicaltrials-database-search — ClinicalTrials.gov for clinical trial data on drugs

References

基于RDKit的Pythonic化学信息学工具包,提供SMILES解析、标准化、描述符计算、指纹生成、相似性搜索、聚类及3D构象生成等功能。支持云存储和并行处理,简化药物发现工作流。
分子结构解析与标准化 计算分子描述符或指纹 化合物相似性搜索或聚类 3D构象生成 分子可视化
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.Mol objects — fully compatible with RDKit functions
  • Fingerprints are numpy arrays (dense bit vectors)
  • DataFrames use pandas with a mol column 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

  1. Always standardize molecules from external sources — call dm.standardize_mol() with disconnect_metals=True, normalize=True, reionize=True before any analysis. Different SMILES representations of the same molecule will produce different fingerprints
  2. Check for None after parsingdm.to_mol() returns None for invalid SMILES. Filter these before batch operations to avoid crashes
  3. Use parallel processing for datasets — pass n_jobs=-1, progress=True to batch operations. Sequential processing of 10,000+ molecules is unnecessarily slow
  4. 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
  5. Mind clustering scale limits — Butina clustering (dm.cluster_mols) builds a full distance matrix. Use for ≤~1,000 molecules. For larger sets, use dm.pick_diverse() or hierarchical methods
  6. Use scaffold splitting for ML — random splits leak similar structures into train/test. Always use scaffold-based splitting for molecular property prediction models
  7. Leverage fsspec for cloud data — all I/O functions accept S3, GCS, and HTTP paths directly. Install s3fs or gcsfs for 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

通过REST API查询DDInter药物相互作用数据库,支持按药名或ID检索严重程度、机制及临床建议。适用于多重用药风险评估、自动化审查流水线及网络分析,无需认证。
查询两种药物的相互作用及严重程度 获取某药物的所有已知相互作用以评估多重用药风险 识别药物相互作用的机制(药代动力学或药效动力学) 构建自动化药物相互作用检查流水线
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

  1. 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 the ddinter_id, then pass it to /interaction/ or /between/.

  2. Handle pagination for complete interaction lists: The default page returns at most 10 results. Drugs like warfarin or amiodarone have hundreds of interactions — iterate next URLs until null:

    while url:
        data = requests.get(url, params=params).json()
        results.extend(data["results"])
        url = data.get("next")
        params = {}   # clear params after first request
    
  3. 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.

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

  5. 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"]
    
  6. 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-database and 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 data
  • drugbank-database-access — DrugBank local XML with structured DDI and target data
  • clinpgx-database — PharmGKB for drug-gene (pharmacogenomics) interaction data
  • pytdc-therapeutics-data-commons — TDC DDI benchmark datasets for ML model training

References

DeepChem是用于药物发现的深度学习框架,提供统一API处理分子机器学习。支持60+模型(如GCN、GAT)、50+特征化工具及MoleculeNet数据集,涵盖属性预测、虚拟筛选和超参优化等端到端工作流。
分子性质预测 训练图神经网络 微调化学语言模型 MoleculeNet基准测试 虚拟筛选
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-cheminformatics instead
  • For featurization only (no model training), use molfeat-molecular-featurization instead

Prerequisites

  • Python packages: deepchem (core), torch or tensorflow (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, or dc.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.

  1. Load pretrained ChemBERTa model (see Module 5 for code)
  2. Prepare downstream dataset with SmilesTokenizer featurizer
  3. Fine-tune with reduced learning rate (1e-5 to 5e-5) for 5-15 epochs
  4. Evaluate on held-out scaffold split — expect gains over fingerprint baselines when training data < 1000 samples
  5. Save fine-tuned model: model.save_checkpoint()
  6. See references/workflows_model_catalog.md Workflow 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

  1. Always use scaffold splitting for drug discovery: Random splits leak structural information and overestimate performance. Scaffold splits test generalization to novel chemotypes.

  2. Normalize regression targets: Apply NormalizationTransformer(transform_y=True) before training. Remember to untransform() predictions for interpretable values.

  3. 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)
    
  4. Match featurizer to model: GNN models require graph featurizers (ConvMolFeaturizer, MolGraphConvFeaturizer). Fingerprint models need CircularFingerprint. Mixing causes silent errors.

  5. Anti-pattern -- Do not use random split for drug discovery benchmarks: Results with RandomSplitter are not publishable for molecular property prediction. Reviewers expect scaffold or temporal splits.

  6. 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().

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

基于扩散模型的蛋白-配体对接工具,无需预定义结合位点即可预测全表面结合构象。适用于盲对接、传统方法失效的挑战性靶点及多结合模式探索,输出带置信度排名的多种姿态假设。
需要未知结合位点的盲对接 传统对接失败或针对变构位点 探索配体的多种结合模式 片段筛选的假设生成
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 1040 Number of diffusion reverse steps; more steps = slower but more accurate
--samples_per_complex 40 10100 Number of poses sampled; more = better coverage of binding modes
--batch_size 10 132 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 1inference_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.06.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 score
  • df_results DataFrame 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 40100 for better site coverage
Protein missing residues Incomplete crystal structure Use MODELLER or Swiss-Model to fill gaps before docking

References

通过解析本地DrugBank XML文件,提供药物信息查询、相互作用检查、靶点提取及化学性质分析等功能。支持按ID/名称/CAS搜索,计算分子相似度,并映射至外部数据库。适用于批量数据处理,替代REST API以规避速率限制。
查询药物详细信息(如适应症、机制) 检查药物-药物相互作用及严重程度 获取药物靶点、酶或转运蛋白信息 检索SMILES等化学属性进行构效分析 将DrugBank标识符映射到PubChem或ChEMBL
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-bioactivity instead
  • For compound property lookups without downloading a database use pubchem-compound-search instead

Prerequisites

  • DrugBank account: Register at https://go.drugbank.com/ (free academic license)
  • XML download: Download drugbank_all_full_database.xml.zip after 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

  1. Build an in-memory index on startup: Parse once (30-60s), build dict by ID + lowercase name. Never re-parse inside a loop
  2. Always pass the namespace dict: Every find()/findall() needs NS. Omitting it is the #1 source of empty results
  3. Use iterparse for memory constraints: With elem.clear(), avoids loading the full 1.5 GB tree
  4. Guard against None: Not all drugs have all fields. Always check el is not None before .text
  5. Prefer calculated over experimental properties: Calculated (SMILES, logP, MW) available for nearly all drugs
  6. 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 modules
  • data-access.md (243) → Core API 1 + Quick Start
  • drug-queries.md (387) → Core API 2
  • interactions.md (426) → references/interactions_targets.md + Core API 3
  • targets-pathways.md (519) → references/interactions_targets.md + Core API 4
  • chemical-analysis.md (591) → references/chemical_analysis.md + Core API 5
  • drugbank_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

用于查询EMDB冷冻电镜密度图及原子模型。支持通过EBI搜索进行关键词检索,获取条目元数据、下载链接、拟合PDB ID及引用信息。无需认证,适用于结构生物学研究中的实验性EM数据检索。
查询冷冻电镜密度图 搜索cryo-EM结构 获取EMDB条目元数据 查找拟合的原子模型
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, /publications do 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 bare https://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.gz density 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-database instead when you need experimentally determined atomic coordinates
  • Use alphafold-database-access for 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 for query=, size=, start=, and fields= selection.
  • /emdb/api/search/ is unreliable — it silently ignores q= and returns the latest released entries. Don't depend on it.
  • Entry detail/emdb/api/entry/{EMD-XXXXX}. There is no /map, /fitted, or /publications sub-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

  1. Always use EBI Search WS for keyword search. https://www.ebi.ac.uk/emdb/api/search/ ignores q= and just returns the latest releases — relying on it produces silently wrong cohorts.
  2. 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.
  3. Cast resolution.valueOf_ to float explicitly. The field is a string like "2.5"; numeric filters need an explicit cast (with try/except for entries that lack a value).
  4. EBI Search field values arrive as lists. Even single-valued fields like name come as {"name": ["..."]} — always index [0] or join.
  5. 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.
  6. Map download URLs follow https://ftp.ebi.ac.uk/pub/databases/emdb/structures/{EMDB_ID}/map/{file} — derive them from entry["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-references
  • alphafold-database-access — AI-predicted structures (complement to experimental EMDB maps)
  • uniprot-protein-database — Resolve organism / sequence context for an EMDB sample
  • cellxgene-census — Tissue/cell-type expression data complementary to structural surveys

References

调用openFDA REST API查询药物不良反应(FAERS)、标签、召回及执法信息。支持按药名、成分或NDC搜索,用于安全性信号监测与比例失调分析。
查询药物不良反应报告 获取药品官方标签信息 搜索药品召回记录 评估药物安全信号
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 use drugbank-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 11000 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

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

  2. Use .exact for drug name searches: patient.drug.medicinalproduct.exact (not .medicinalproduct) gives exact phrase matching, preventing partial matches that inflate counts.

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

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

  5. Paginate large result sets: Maximum limit is 1000; use skip to paginate through large result sets (total in meta.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 openFDA
  • drugbank-database-access — Drug structures, targets, and interactions to contextualize FDA data
  • chembl-database-bioactivity — Preclinical bioactivity data for drugs in the FAERS database
  • string-database-ppi — Protein interactions for drug targets found via adverse event analysis

References

查询GtoPdb受体-配体相互作用、亲和力及药物元数据。支持通过基因符号解析靶点,获取结合活性、批准状态、结构描述符及家族分类,无需认证,适用于药理学研究。
查询特定配体在靶点上的亲和力(pKi/pIC50) 查找受体的所有注释配体或批准药物 获取化学信息学所需的SMILES或InChI结构描述符 进行HGNC到UniProt的跨数据库映射
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-bioactivity for larger bioactivity datasets (2.4M+ compounds); GtoPdb is curated, smaller, with more annotation depth
  • Use dailymed-database for 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, ligandType are accepted but silently ignored — the endpoint returns ~280k rows for any filter. Use /targets/{id}/interactions or /ligands/{id}/interactions instead.
  • On /ligands: approved=true is silently ignored. Use type=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

  1. Use geneSymbol=/accession= for canonical lookups, never name= — fuzzy name= matches across all fields and silently returns the wrong target.
  2. 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.
  3. Filter interactions via /targets/{id}/interactions or /ligands/{id}/interactions — never /interactions?targetId=…, which silently ignores the filter.
  4. Use type=Approved server alias to fetch approved drugs; approved=true query param is ignored.
  5. Always cast affinity numerically with errors="coerce" — it's a string that can be ranges, qualified, or missing.
  6. 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 classes
  • pubchem-compound-search — Compound-centric chemoinformatics; cross-reference via PubChem CID from /ligands/{id}/databaseLinks
  • dailymed-database — FDA-approved label text for marketed drugs (regulatory complement)
  • uniprot-protein-database — Resolve the UniProt accession that GtoPdb cross-references

References

用于分析GROMACS等MD模拟轨迹的结构工具。支持读取拓扑与轨迹,计算RMSD、RMSF、回转半径、氢键及PCA等。适用于后处理结构分析,不用于运行模拟。
需要分析分子动力学模拟轨迹数据 计算蛋白质或配体的RMSD/RMSF指标 进行主成分分析(PCA)或氢键占有率统计 查询溶剂可及表面积(SASA)或回转半径
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

Medchem是用于药物发现的分子过滤与优先排序库,支持Lipinski等成药性规则、PAINS等结构警报及化学基团检测。适用于高通量化合物筛选、苗头化合物优化及ADMET预筛,依赖RDKit和datamol,支持并行计算。
需要评估化合物成药性或ADMET属性 进行药物发现中的化合物库筛选或去重 检测分子中的PAINS、NIBR等有害结构片段
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

  1. Start broad, then narrow: Apply permissive filters first (Ro5, PAINS), then progressively tighten (NIBR, Lilly, complexity) as the pipeline narrows the candidate set.

  2. Always use parallelization: For libraries >1000 molecules, set n_jobs=-1 to use all CPU cores.

  3. Combine rules with structural alerts: Rules check physicochemical properties; alerts check substructure patterns. Both are needed for robust triage.

  4. Anti-pattern — blind filtering: Do not blindly reject everything that fails Ro5. Consider the target class and modality before filtering.

  5. Anti-pattern — ignoring prodrugs: Prodrugs intentionally violate standard rules. Flag them as exceptions rather than filtering them out.

  6. Document filtering decisions: Track which molecules were removed and why for reproducibility and regulatory compliance.

  7. 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
Molfeat是分子特征化工具库,支持将SMILES转换为指纹、描述符及深度学习嵌入。兼容scikit-learn,提供并行计算与缓存功能,适用于QSAR建模、虚拟筛选、相似性搜索及化学空间分析等场景。
需要生成分子指纹或描述符 构建QSAR/QSPR模型 进行虚拟筛选或相似性搜索 使用预训练模型进行分子深度学习
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=-1 for 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=True for 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 FeatConcat to 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

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 3
  • references/api_reference.md (429 lines) → Migrated to new references/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 reference
  • references/available_featurizers.md (334 lines) → Migrated to new references/available_featurizers.md. Top-level summary → Key Concepts selection guide table. Full categorized catalog retained in reference
  • references/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%.

通过 Open Targets GraphQL API 查询靶点-疾病关联、药物链接及安全性数据。支持按基因或疾病ID检索,评估可药性及证据得分,整合遗传学、文献等多源信息。
查询特定基因相关的疾病及其置信度分数 获取针对某靶点的已批准或临床试验中药物列表 评估靶点的可药性(如小分子、抗体可能性) 分析靶点与疾病的关联证据(如 GWAS、FAERS 安全性数据)
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 use clinicaltrials-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 110000 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

  1. Use EFO IDs for diseases: Disease names vary; always use the search query to get the canonical EFO ID before running association queries to avoid name-matching issues.

  2. Paginate for full result sets: Default page size is 10; use page.size: 10000 for complete results, but be aware this can return large payloads.

  3. Filter by datatypeScores: For genetic target validation, filter on genetic_association subscore > 0.1; for drug repurposing, prioritize known_drug subscore.

  4. Use enableIndirect: true in evidence queries to include evidence for disease subtypes (child terms in EFO hierarchy).

  5. 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 targets
  • clinicaltrials-database-search — Detailed clinical trial information for drugs found via Open Targets
  • ensembl-database — Ensembl IDs and variant annotations needed as input to Open Targets queries
  • string-database-ppi — Protein-protein interaction networks to contextualize target biology

References

通过RCSB PDB的REST和GraphQL API查询生物大分子三维结构数据。支持按文本、属性、序列或3D相似性搜索,获取元数据及下载坐标文件。适用于结构生物学分析、药物靶点研究及机器学习数据集构建。
查询蛋白质或核酸的晶体/冷冻电镜/NMR结构 基于关键词或属性搜索PDB条目 查找与给定序列或3D几何形状相似的结构 获取实验元数据(分辨率、方法等) 下载PDB或mmCIF坐标文件用于分子动力学或对接
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-api Python 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 plain requests and a small JSON payload. This SKILL.md uses the REST path throughout so the code runs in any environment with requests installed.

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-access instead
  • For protein sequence/annotation queries without structures, use uniprot-protein-database instead

Prerequisites

  • Python packages: requests (only requirement). Optional: biopython for 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-1010 E-value threshold
identity_cutoff search sequence 0.9 0.01.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

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

  2. Use full_text vs text deliberately: free-text keyword search needs "service": "full_text". Structured attribute filters need "service": "text". They are not interchangeable.

  3. mmCIF over PDB format: PDB format is being phased out and has a 99,999 atom limit. Always download .cif for new code.

  4. Set realistic paginate.rows: rows: 100 is a good default for batch work; the API may slow down beyond ~10000. Loop with paginate.start for full traversal.

  5. Rate limit with time.sleep(0.2) in batch loops: No published hard cap, but the public infrastructure is shared. On HTTP 429, back off exponentially.

  6. Inspect the payload before posting: print(json.dumps(payload, indent=2)) is the cheapest way to debug HTTP 400 errors.

  7. entries(entry_ids: [...]) does not validate every ID: if one ID is wrong, the whole array returns null entries. 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

PyTDC提供AI就绪的药物发现数据集与基准,涵盖ADME、毒性及药物相互作用预测。支持标准化评估协议、骨架/冷拆分策略及分子生成评分,适用于治疗性机器学习模型训练与性质预测。
加载ADME或毒性数据集进行机器学习训练 使用标准化协议评估药物发现模型性能 预测药物-靶点或药物-药物相互作用 生成新分子并使用Oracle评分 获取基于骨架或时间序列的数据集划分
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-bioactivity instead
  • For molecular featurization beyond format conversion, use molfeat instead

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)

  1. Load DTI dataset — DTI(name='BindingDB_Kd') (Core API Module 2)
  2. Apply cold_drug split — data.get_split(method='cold_drug', seed=seed) (Core API Module 4)
  3. Verify zero drug overlap between train and test sets (Module 4 overlap check)
  4. Train model on train set, predict on test set
  5. Evaluate with Spearman correlation — Evaluator(name='Spearman') (Module 4)
  6. 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

  1. Always use scaffold splits for molecular property prediction — random splits leak structural information and inflate performance metrics
  2. Report 5-seed evaluations with mean ± std — single-seed results are unreliable for method comparison
  3. Use cold splits for interaction predictioncold_drug tests generalization to unseen drugs, cold_target to unseen targets
  4. Filter molecules early with MolFilter (PAINS, drug-likeness) before training to remove problematic compounds
  5. Normalize oracles appropriately — QED returns 0-1, SA returns 1-10 (lower is better), binding scores vary. Check oracle documentation before combining
  6. 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 around get_split() and Evaluator().
  • 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

提供基于RDKit的化合物库分析与虚拟筛选工作流,涵盖分子解析、物性计算、指纹相似性筛选、SMARTS子结构过滤及药物相似性评估。
计算分子量LogPTPSA等分子性质 基于指纹进行虚拟筛选 使用SMARTS进行子结构过滤 评估Lipinski药物相似性 生成2D/3D构象或反应枚举
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-cheminformatics instead for a higher-level RDKit wrapper with batching and error handling; use openbabel instead for multi-format conversion (MOL2, XYZ, PDB)

Prerequisites

  • Python packages: rdkit-pypi (or rdkit via 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 properties
  • results/similarity_results.csv — Tanimoto similarity rankings against reference compound
  • top_hits_grid.png — 2D grid image of top similar compounds
  • substructure_highlight.png — Molecule image with highlighted functional group
  • conformers.sdf — 3D conformer ensemble with MMFF energies
  • annotated_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 ranges
  • references/smarts_patterns.md — Common SMARTS patterns for functional group detection, organized by chemical class

References

基于RDKit的药物发现SAR分析指南,通过最大公共子结构识别骨架、R基团分解及分子对齐,生成包含活性热图的HTML报告与叙事分析,辅助推导设计假设。
进行药物构效关系分析 需要识别化合物系列的共同骨架和可变取代基 执行活性悬崖检测或R基团对活性影响评估
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

  1. 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.
  2. Add explicit hydrogens before MCS. Chem.AddHs lets MCS reason correctly about heavy-atom valence and ring closures; without it, otherwise-identical scaffolds can be missed.
  3. 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.
  4. 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.
  5. 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).
  6. 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.
  7. 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.
  8. 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 uses Smiles or Standard Value.

    • How to avoid: Inspect df.columns and df.head() and detect the SMILES/activity/ID columns by content (valid SMILES parse rate, numeric values, unique strings).
  • Pitfall: Skipping Chem.AddHs before 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 calling rdFMCS.FindMCS.
  • Pitfall: Setting threshold=1.0 on 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.
  • Pitfall: Drawing each fragment with Compute2DCoords independently. 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. GetSubstructMatch returns 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) with makeDummiesQueries=True before retrying the match (Strategy 2).
  • 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) or pIC50 = -log10(IC50_in_M); this gives uniform color separation across orders of magnitude.
  • 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:

  1. 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').

  2. Core Identification (MCS):

    • Use rdFMCS.FindMCS to find a significant common scaffold.
    • Pre-processing: Apply Chem.AddHs to 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)
      
  3. 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.
  4. 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)
      
  5. 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-width of 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).
  6. 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:

      1. Identify the Scaffold and Substituents:

        • Determine the common core structure and label the variable positions as R1, R2, etc. Use these labels consistently.
      2. 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. 🚨
      3. Infer Mechanisms:

        • Propose plausible reasons for activity changes, considering steric, electronic, and potential intermolecular interactions (e.g., H-bonding, hydrophobic).
      4. 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.

      5. 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.html file.
  • Print the Analysis Text in the chat.

References

TorchDrug是基于PyTorch的药物发现机器学习框架,提供分子图表示、GNN架构及预训练模型。适用于ADMET预测、药物-靶点相互作用、逆合成分析及分子生成等任务,支持在大型数据集上进行迁移学习和基准测试。
需要预测分子ADMET属性或生物活性 进行药物与靶点的结合亲和力预测 规划分子的逆合成路线 使用GNN进行分子生成或设计
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 14 Depth of MLP prediction head after GNN
criterion PropertyPrediction "mse" "mse", "bce", "ce" Loss function: regression, binary/multi-label classification
batch_size Engine 32 8512 Training batch size

Best Practices

  1. Use concat_hidden=True for 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).

  2. Apply batch_norm=True for training stability: Batch normalization reduces sensitivity to learning rate and initialization, especially with deep GNNs (3+ layers).

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

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

  5. Handle missing labels in multi-task datasets: Many MoleculeNet datasets (Tox21, SIDER) have missing assay values. TorchDrug's PropertyPrediction task 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 TorchDrug
  • diffdock — structure-based docking complementary to TorchDrug's ligand-based prediction

References

通过UniChem API将化合物ID在20多个数据库间交叉引用,支持InChIKey解析、ID转换及结构相关化合物查找。
跨数据库化合物ID翻译 InChIKey解析与来源验证 构建药物发现项目的交叉引用表
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

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

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

  3. 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).

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

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

  6. 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", [])}
  1. 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

用于查询ZINC15/22虚拟化合物库,支持按MW、logP等性质筛选或SMILES相似度搜索可购买化合物,适用于虚拟筛选、对接及片段药物发现。
需要下载可购买的类药或先导化合物库进行虚拟筛选或对接 根据Lipinski规则筛选化合物以构建聚焦筛选集 通过SMILES相似度搜索商业可用的类似物 获取可购买的片段化合物用于基于片段的药物发现
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 use drugbank-database-access; for RDKit property calculation use rdkit-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 11000 Max compounds returned per request
similarity Similarity 0.01.0 Tanimoto similarity threshold

Best Practices

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

  2. Apply reactivity filters: ZINC marks reactive compounds with "reactivity" flags. Exclude compounds with reactive groups (reactivity: "clean" filter) for cell-based assays.

  3. Deduplicate by SMILES: API results may contain duplicates across supplier catalog entries. Canonical SMILES deduplication with RDKit (Chem.MolToSmiles(Chem.MolFromSmiles(smi))) before docking.

  4. Combine with RDKit filtering: After downloading, apply additional filters (PAINS, Brenk alerts) using rdkit-cheminformatics or medchem before investing compute in docking.

  5. 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 compounds
  • autodock-vina-docking — Use downloaded ZINC SMILES/SDF files for molecular docking campaigns
  • chembl-database-bioactivity — Bioactivity data for compounds identified in ZINC virtual screens
  • medchem — Apply medicinal chemistry filters (Lipinski, PAINS, NIBR) on ZINC libraries

References

用于查询BRENDA酶数据库的SOAP/REST接口,获取Km、Vmax等动力学参数、EC分类及底物特异性。适用于代谢建模和酶学研究,需注册账号并使用zeep库访问。
查询特定酶的动力学参数(如Km, kcat) 查找酶的抑制剂或辅因子信息 构建代谢模型所需的米氏常数数据 比较不同生物体中同一酶的动力学差异
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 use hmdb-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

  1. Hash your password correctly: BRENDA requires SHA256 hash of the plain-text password, not the password itself. Use hashlib.sha256("your_password".encode()).hexdigest().

  2. Store credentials in environment variables: Never hard-code credentials. Use os.environ["BRENDA_EMAIL"] and os.environ["BRENDA_PASSWORD"] patterns.

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

  4. 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).

  5. 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 constraints
  • hmdb-database — Metabolite structure and biological context for BRENDA substrates
  • kegg-database — Pathway context for BRENDA enzymes via EC number cross-references
  • uniprot-protein-database — Protein sequence and structure data for enzymes found in BRENDA

References

基于R包CellChat,从单细胞转录组数据推断并可视化细胞间通讯网络。支持配体-受体互作识别、通路信号分析及发送者/接收者角色量化,适用于健康与疾病状态下的通讯网络对比及关键靶点发现。
分析单细胞RNA测序数据中的细胞间通讯 比较不同条件下(如疾病vs健康)的细胞信号网络变化 识别组织图谱中主要的信号发送或接收细胞类型 查找介导特定细胞群相互作用的关键配体-受体对
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 00.25 Fraction trimmed from each tail; only applies when type="truncatedMean"
nboot computeCommunProb 100 501000 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 550 Minimum number of cells required per sender or receiver group to retain an interaction
k identifyCommunicationPatterns required 26 (choose via selectK) Number of latent communication patterns; use selectK elbow to select
thresh netAnalysis_computeCentrality 0.05 0.010.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

用于基于约束的基因组规模代谢模型分析,支持FBA、FVA、基因敲除筛选、通量采样及培养基优化等。适用于菌株设计、关键基因识别和通量分析,不用于动力学建模或可视化。
进行代谢通量平衡分析 预测微生物在不同营养条件下的生长速率 通过敲除筛选鉴定必需基因或反应 计算生产包络线以指导代谢工程 对不完整模型进行Gapfilling修复
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.

  1. Load model and compute wild-type production envelope for target metabolite
  2. Screen single gene knockouts for overproducers (filter where target secretion increases)
  3. Combine top knockouts and validate with FBA under production conditions
  4. Gapfill if knockouts create infeasibilities
  5. 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

  1. 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
    
  2. Validate with slim_optimize() before analysis: Quick feasibility check before expensive operations (FVA, sampling).

  3. Check solution.status after optimization: Always verify "optimal" before interpreting fluxes.

  4. Use loopless FVA when thermodynamic feasibility matters: Standard FVA can include infeasible internal cycles that inflate flux ranges.

  5. Parallelize expensive operations: Sampling and double deletions support processes parameter.

  6. Prefer SBML for model exchange: Community standard supported by all COBRA tools.

  7. Use slim_optimize() in loops: Skips full flux vector construction, significantly faster for screening.

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

指导差异表达基因进行KEGG通路富集分析。涵盖ORA与GSEA方法选择、强制按方向拆分基因、物种代码处理、API失败离线回退及跨条件对比,提供clusterProfiler和gseapy的使用规范与错误规避策略。
需要运行KEGG通路富集分析 使用clusterProfiler或gseapy进行富集计算 处理差异表达基因的ORAGSEA分析
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

  1. Always split ORA by gene direction. Run enrichKEGG() or gp.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.

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

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

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

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

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

  7. Use gseapy as a fallback when clusterProfiler fails. When enrichKEGG() fails due to KEGG API issues, gseapy's enrichr() function with bundled offline gene sets (e.g., KEGG_2021_Human) provides equivalent ORA results without any network dependency.

Common Pitfalls

  1. 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.
  2. 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., pae for PAO1 vs pau for PA14).

    • How to avoid: Confirm the organism code from the KEGG organism list before running enrichment. For bacteria, verify the specific strain code.
  3. 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.
  4. 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.
  5. 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.
  6. 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) to enrichKEGG().
  7. 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.

Workflow

  1. 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")
    
  2. 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)
    
  3. 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)
    
  4. 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)
    
  5. 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")
    
  6. 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

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 workflows
  • deseq2-differential-expression / pydeseq2-differential-expression -- Upstream differential expression analysis that produces the DEG lists used as input to KEGG pathway enrichment
LaminDB是用于生物数据的开源FAIR框架,支持数据集版本管理、计算谱系追踪、基于本体的验证及统一API查询。适用于多组学数据湖构建与可重复性保障,集成Nextflow等工具。
需要管理生物学数据集的版本和元数据 要求追踪数据处理流程和确保结果可复现 需要对基因、细胞类型等数据进行本体一致性验证
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

  1. In each Nextflow process, import lamindb and call ln.track()
  2. Load input artifacts with ln.Artifact.get(key=...); cache to local path
  3. Run analysis; save output as new artifact with ln.Artifact(...).save()
  4. 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

  1. Always wrap analysis with ln.track() / ln.finish(): This captures lineage automatically. Without it, artifacts have no provenance.

  2. Use hierarchical keys: Structure as project/experiment/datatype/file.ext (e.g., immunology/exp42/scrna/counts.h5ad). This enables prefix-based queries.

  3. Anti-pattern — duplicating data instead of versioning: Use the revises= parameter to create new versions, not new keys for the same dataset.

  4. Validate early: Run schema validation before analysis. Catching bad metadata early saves debugging time downstream.

  5. Use ontologies for standardization: Map free-text labels to ontology terms (e.g., "T helper cell" → CL:0000912). This enables cross-dataset queries.

  6. Anti-pattern — loading large files without checking size: Use .filter().df() to inspect metadata first, then .load() or .open() (backed mode) for large files.

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

利用libSBML Python API构建、读取、验证和修改SBML生物网络模型。支持ODE及基于约束的代谢模型,兼容FBC扩展与COBRApy等工具,适用于生化通路建模、参数调整及数据提取。
构建新的SBML格式的ODE或代谢模型 读取并修改现有的BioModels数据库模型 验证SBML文件符合规范 将SBML模型在不同Level间转换 通过FBC扩展构建基因组规模代谢模型
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-modeling instead 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 tellurium directly 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 12 (L3); 14 (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

  1. Always check parseL3Formula return value: the function returns None on malformed input without raising an exception. Assigning None to kl.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)
    
  2. Set constant=False on assignment rule targets: any parameter or species that is the target of an AssignmentRule or RateRule must have constant set to False. A True value creates a constraint violation that fails consistency checking.

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

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

  5. Use readSBMLFromFile (top-level function) for quick loading: the convenience function libsbml.readSBMLFromFile(path) is equivalent to creating a SBMLReader instance and calling readSBMLFromFile on it. Both return an SBMLDocument; choose whichever is less verbose.

  6. Validate before saving and after converting: run doc.checkConsistency() immediately before any writeSBMLToFile call 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 formulaToL3String for analytical steady-state derivation

References

基于mofapy2的多组学因子分析工具,联合分解scRNA、ATAC等多层数据为潜在因子。支持多组设计、方差解释计算、与元数据关联及可视化,用于降维、聚类及特征驱动因素发现。
多组学数据整合 寻找共享变异来源 多组学降维 潜在因子与元数据关联 MOFA+分析
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 MOFA2 Bioconductor 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 550 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 2005000 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.0010.05 Drop factors explaining less than this fraction of variance; None keeps all
startELBO 1 1100 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

用于多模态单细胞数据分析,支持RNA+ATAC、CITE-seq等联合分析。核心结构MuData整合多组学数据,提供WNN嵌入、TF-IDF/CLR预处理及MOFA因子分析,适用于10x Multiome等场景。
处理10x Genomics Multiome (RNA+ATAC) 数据 分析CITE-seq实验 (RNA+蛋白) 构建跨模态联合UMAP嵌入 对ATAC-seq进行TF-IDF或LSI预处理 执行MOFA+多组学因子分析
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 mofapy2 for 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 10100 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 10100 Number of reduced dimensions; 30–50 typical
resolution sc.tl.leiden() 1.0 0.12.0 Clustering granularity; lower = fewer, larger clusters
scale_factor mu.atac.pp.tfidf() 1e4 1e31e5 TF scaling constant before log transform
n_factors mu.tl.mofa() 10 550 Number of MOFA latent factors; use elbow on variance explained
target_sum sc.pp.normalize_total() 1e4 1e31e6 Library size normalization target per cell (RNA)
n_top_genes sc.pp.highly_variable_genes() varies 10005000 Number of highly variable genes to retain for PCA

Best Practices

  1. 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}")
    
  2. 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 in X_lsi, so verify that component 1 correlates with log_total_counts before using X_lsi in WNN; if so, restrict to X_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:]
    
  3. Use key_added="wnn" consistently: Always name the joint graph "wnn" and pass neighbors_key="wnn" to all downstream sc.tl.umap(), sc.tl.leiden(), and sc.tl.paga() calls. Mixing neighbors_key values silently uses the wrong graph.

  4. 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 apply sc.pp.normalize_total() + sc.pp.log1p() to the protein modality.

  5. Store raw RNA counts before normalization: Before any normalization, store the raw RNA counts in mdata["rna"].layers["counts"] and set mdata["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"]
    
  6. 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

提供转录组学和蛋白质组学数据分析的三级指南。优先使用经过验证的管道,其次为标准工作流,最后为自定义方法。涵盖缺失值机制(MCAR/MAR/MNAR)处理及规范引用要求,确保分析的可重复性与灵活性平衡。
需要进行转录组或蛋白质组数据分析 询问缺失值处理方法 寻求生物信息学分析最佳实践
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:

  1. Record the pipeline/method name and version
  2. Note the reference: Record the publication DOI/PubMed ID
  3. Record validation details: Benchmark results, recommended parameters, any limitations
  4. 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):

  1. Quality control
  2. Normalization and filtering (if count data, use DESeq2/edgeR normalization)
  3. Statistical analysis (differential expression)
  4. Multiple testing correction
  5. Functional enrichment (optional)

Proteomics (Pre-quantified):

  1. Quality control
  2. Missing value assessment and imputation
  3. Normalization
  4. Batch correction (if needed)
  5. Statistical analysis (differential abundance)
  6. 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:

  1. 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
  2. Variance homogeneity test:

    • Use Levene's test to check equal variances
    • If >=70% of features have equal variances, assume equal variance
  3. 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
  4. 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:

  1. 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
  2. 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:

  1. Document all steps and parameters used
  2. Save intermediate results for reproducibility
  3. Validate results using independent methods when possible
  4. 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:

  1. Data Quality: Ensure high-quality data before custom analysis

    • Perform all QC steps from Option 2
    • Remove outliers and batch effects
    • Validate technical replicates
  2. Statistical Rigor:

    • Always check test assumptions before analysis
    • Use appropriate statistical tests for your data distribution
    • Apply multiple testing correction (FDR)
    • Validate assumptions
  3. Reproducibility:

    • Document all steps and parameters
    • Use version control for code
    • Save intermediate results
    • Provide seed values for random processes
  4. 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

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

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

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

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

  5. Always apply FDR correction (Benjamini-Hochberg) for genome-wide tests. Report p_adj (or q-value), not raw p. Rationale: with 20,000 genes tested at α=0.05, ~1,000 false positives are expected without correction — the result set is meaningless.

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

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

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

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

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

  4. 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 (or q < 0.05) as the significance threshold.

  5. 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| > 1 AND p_adj < 0.05. Report effect sizes alongside p-values.

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

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

Consortium Best Practices

Statistical Methods

Data Repositories for Validation


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.

用于查询Reactome生物通路、实体信息、关键词搜索及基因列表富集分析。支持通过REST API获取通路层级、跨库ID映射及物种特异性数据,适用于多组学整合工作流。
查询特定通路的详细信息 基于关键词搜索生物通路或反应 执行基因列表的富集分析 获取通路层级结构 进行跨数据库标识符映射
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-database instead
  • For protein-protein interaction networks, use string-database-ppi instead
  • 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

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

  2. Save and reuse analysis tokens: Tokens remain valid for hours. Store the token to re-filter results by species or resource without re-submitting.

  3. Prefer stable IDs over database IDs: Reactome stable IDs (R-HSA-69620) are permanent. Internal database IDs can change between releases.

  4. Use sortBy=ENTITIES_FDR for enrichment results: FDR-corrected p-values are more reliable than raw p-values for pathway-level significance.

  5. Check identifiersNotFound in 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

通过REST API查询STRING数据库的蛋白质相互作用网络,支持获取互作、功能富集分析(GO/KEGG)、伙伴发现及可视化。需先映射基因ID为STRING ID,注意API调用间隔。
查询蛋白质相互作用网络 进行功能富集分析 寻找蛋白质互作伙伴
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 networks
  • kegg-database — Pathway-centric queries complementary to STRING enrichment
  • bioservices-multi-database — Alternative access to STRING via the PSICQUIC interface

References

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 inline
  • references/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 to references/api_advanced.md
  • scripts/string_api.py (370 lines) → Helper function pattern absorbed into Quick Start (string_query reusable 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-based string_query helper

Retention: ~460 lines (SKILL.md) + ~180 lines (reference) = ~640 / 990 original = ~65%.

用于自动化创建新的 SciAgent 技能条目。它通过收集主题、类型、分类等关键信息,调用脚本生成标准化的 SKILL.md 骨架并更新 registry.yaml,同时执行名称唯一性和格式校验,确保规范录入。
add a SciAgent skill create new skill scaffold a skill 신규 skill 추가
.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-creator directly
  • The agent is about to hand-edit registry.yaml and create a skills/<cat>/<name>/SKILL.md from 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.yaml only (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:

  1. Topic — concrete tool/library/concept name. Reject vague topics ("ML stuff") with a clarifying question.
  2. Sub-typepipeline | toolkit | database | guide. Use the decision rule from CLAUDE.md Step 1b. If unsure, ask the user.
  3. Category — primary category directory. List the table from CLAUDE.md Step 2 if the user is unsure.
  4. Entry name — kebab-case slug. Convention: {tool-name}-{purpose} (e.g., pydeseq2-differential-expression). Confirm with the user.
  5. License — underlying tool's license. Default to CC-BY-4.0 for original prose-only content.
  6. 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".
  7. 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:

  1. Validates name (kebab-case, not already in registry.yaml, not in legacy/)
  2. Validates category exists as a directory under skills/
  3. Validates description with validate_description.py (length + first-120-char keyword lead)
  4. Validates tags (kebab-case if provided)
  5. Creates skills/{category}/{name}/SKILL.md from the matching template, substituting frontmatter fields
  6. Appends a new entry to registry.yaml with date_added = today (UTC)
  7. Runs pixi run validate to confirm the registry is still well-formed
  8. 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:

  1. Fill Overview, When to Use, Prerequisites, Workflow / Core API / Key Concepts, Common Recipes, Troubleshooting, References
  2. Match the section structure required by the sub-type (see CLAUDE.md Step 4 format rules)
  3. Run pixi run test — full suite, not just validate — 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), no LinearSegmentedColormap.from_list(...), no ListedColormap([...]), no prescribed cmap= 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 reportfigsize=(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 trajectory above traj = 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 by scaffold.py and 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 validate fails 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)
查询ClinPGx和CPIC API,获取药物基因组学临床注释、剂量指南、基因-药物关联及FDA/EMA标签。支持通过基因型查找推荐用药,无需认证。
查询药物基因组学临床注释 获取特定基因型的给药建议 查找CPIC或DPWG剂量指南 查询基因、药物或变体的标识符
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, JSON cs.{...} 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-database or opentargets-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 filters
  • view=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

  1. Resolve PA identifiers once. Never hand-construct ClinPGx PA IDs. Call data/{type}?{symbol|name}=... (or site/search) once and cache the returned id for reuse — gene/PA128 for CYP2D6, drug/PA449088 for clopidogrel, variant/PA166154579 for rs4149056.

  2. Pick the right host for the question. Use ClinPGx for what is annotated and CPIC for what to prescribe. Trying to derive genotype-specific recommendations from ClinPGx alone misses the structured recommendation.phenotypes rows.

  3. Filter by evidence level upfront when building clinical workflows. levelOfEvidence.term=1A returns 312 actionable annotations across all of ClinPGx; Level 3/4 records are exploratory and shouldn't drive prescribing.

  4. Don't filter clinicalAnnotation by gene — filter by guidelineAnnotation with relatedGenes.symbol. The clinicalAnnotation endpoint has no working gene property and returns HTTP 400 for any attempt.

  5. Use view=base for bulk filters, view=max for single-record drill-downs. A list query with view=max can time out or hit 429; the difference is roughly 5–10× payload size.

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

  7. URL-encode cs.{...} jsonb filters when phenotype values contain spaces or special characters. requests.get(..., params={"phenotypes": 'cs.{"CYP2C19":"Poor Metabolizer"}'}) works because requests does the encoding; a manual URL string needs urllib.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 targets
  • chembl-database-bioactivity — bioactivity and binding data for the drugs annotated in ClinPGx
  • cosmic-database — somatic cancer mutations and tumor-specific PGx (orthogonal to germline PGx covered here)

References

通过 Jackson Laboratory MPD REST API 检索小鼠表型数据,支持查询项目元数据、品系均值及个体值。适用于 QTL 分析、跨品系比较、模型筛选及本体驱动发现,无需认证。
需要获取特定小鼠品系的生理或行为表型数据 进行数量性状位点 (QTL) 映射所需的原始或调整后的表型数据提取 根据 MP/VT 本体术语查找相关的小鼠表型研究项目 验证小鼠品系命名规范或基因坐标以准备实验
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-database instead for disease-gene-phenotype knowledge graphs (HPO ↔ MP ↔ disease)
  • Use ensembl-database instead 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:

  1. Finding the project: GET /projects?panelsym=... or GET /projects?investigator=...
  2. Listing its measures: GET /pheno/measureinfo/{projsym}
  3. Picking the right varname / descrip / units triple 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

  • strainmeans are unadjusted: simple per-strain × per-sex arithmetic means of the raw animal values.
  • lsmeans are 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

  1. 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".

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

  3. Prefer the project-level dataset CSV for bulk analysis. When you want every animal × every measure for a project, GET /projects/{projsym}/dataset returns a single CSV in one request — much faster than looping animalvals per measnum.

  4. Use lsmeans instead of strainmeans when 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 of ls_measures).

  5. Validate strain names with /straininfo before 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.

  6. 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 links
  • ensembl-database — mouse genome annotation (GRCm39 coordinates, transcripts, VEP) — pairs with MPD /geneinfo for fine-grained gene-model details
  • gwas-database — human GWAS Catalog SNP-trait associations; conceptual analogue of MPD's QTL projects for human populations
  • clinvar-database — clinical variant interpretation; relevant when mapping a mouse QTL to a human disease gene

References

提供NCI影像数据commons的查询与下载能力。基于本地DuckDB索引实现零认证SQL检索,支持从公有云S3/GCS流式下载DICOM数据及生成查看器链接,适用于癌症影像数据集搜索、ML队列构建及批量数据处理。
查询NCI IDC癌症影像元数据 下载公开DICOM影像数据 构建医学影像机器学习训练集 获取OHIF/Slim影像查看链接
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 s5cmd executable 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

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

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

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

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

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

用于搜索PRIDE Archive蛋白质组学数据集,支持关键词及多维筛选、元数据获取、RAW/RESULT文件下载及相似项目查找。注意v3 API不提供肽段/PSM级端点,需下载RESULT文件本地解析。
查询蛋白质组学数据集 下载质谱原始或结果文件 查找提及特定UniProt accession的项目 寻找相似研究项目
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}/files and parse them with pyteomics or 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

  1. Use /search/projects for searching, not /projects. Plain /projects is a paginated listing endpoint and silently ignores keyword / organism / disease filters. Filtering only works through /search/projects with the filter=field_facet==value syntax.

  2. Discover filter values via /facet/projects before 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.

  3. 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.).

  4. Prefer FTP URLs for bulk file downloads. Each file record carries both FTP Protocol and Aspera Protocol URLs. FTP is more universally supported; pair it with aria2c -x 8 -j 4 for parallel chunks. Use Aspera only if you have an Aspera client and need >100 Mbit transfer speeds.

  5. Watch the field name fileSizeBytes. The current v3 field is fileSizeBytes, not fileSize (old v2 docs may say fileSize). Sizes are in bytes — divide by 1e6 for MB, 1e9 for GB.

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

  7. Pagination has no metadata — walk until empty. Unlike old PRIDE v2, the v3 API doesn't return totalElements/totalPages. Iterate page=0, 1, 2, ... and stop when a page returns an empty array, or when its length is less than pageSize.

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 information
  • interpro-database — Protein domain architecture (Pfam, SMART, PANTHER) for proteins reported in PRIDE
  • pdb-database — Resolved 3D structures for proteins with PRIDE evidence
  • pyteomics (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

用于分子动力学轨迹分析的轻量级Python库。支持多种格式读写,计算RMSD、回旋半径、RMSF、接触频率、二面角及二级结构等几何与结构指标,适用于快速后处理分析。
分子动力学轨迹分析 计算RMSD或RMSF 蛋白质二级结构识别 Ramachandran图绘制 轨迹文件加载与切片
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 radiansnp.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

  1. Convert units before plotting — nm → Å (×10), ps → ns (÷1000), rad → deg (np.degrees). #1 cause of "my RMSD is 10× too small".
  2. Superpose before RMSFmd.rmsd superposes internally; RMSF doesn't. Without explicit traj.superpose(...), RMSF picks up rigid-body motion.
  3. md.iterload for big trajectories — 1 µs at 1 ps/frame is millions of frames. Streaming avoids OOM.
  4. simplified=False for fine-grained DSSP — 3-state collapses 3_10 / pi-helix / bend transitions.
  5. Pair indices, not resSeqmd.compute_contacts returns 0-based contiguous indices. Map to resSeq only for labeling.
  6. Strip waters earlytraj.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

smina分子对接工具,支持SDF/MOL2直接输入、自定义评分函数、局部优化及逐原子能量分解。适用于虚拟筛选、结合能重算及配体预处理流程。
需要进行分子对接计算 使用自定义评分函数 对现有构象进行局部能量最小化 需要逐原子能量贡献分析
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 system openbabel), 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

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-22 04:59
浙ICP备14020137号-1 $访客地图$