Skill: fmaxy/bubble-plot (version 1.0.0)

--- name: bubble-plot description: "在需要基于基因表达数据画气泡图（dot plot）时使用。适用于单细胞 RNA-seq、空间转录组等场景，按细胞类型 × 癌种展示基因表达比例和均值。" metadata: { "openclaw": { "emoji": "🫧", "requires": { "bins": ["python3"] } } } --- # Skill: 气泡图生成器 (bubble-plot) 基于基因表达矩阵数据，生成按**细胞类型 × 癌种**分组的气泡图（dot plot）。 ## 何时使用 - 用户上传 TSV/CSV 表达数据文件，并要求画气泡图、dot plot、泡泡图 - 用户说"画气泡图"、"bubble plot"、"dot plot"并提供了数据文件 - 用户提供数据文件和基因列表，要求可视化表达模式 ## 常见触发词 - 画气泡图 - bubble plot - dot plot - 泡泡图 - 气泡图可视化 - 表达气泡图 ## 执行清单 ### 1. 检查数据与环境 - 确认用户提供了数据文件（TSV/CSV） - 确认文件已下载到工作目录 - 确认 Python 3 + pandas + matplotlib 已安装（缺失时 `pip3 install --break-system-packages pandas matplotlib`） ### 2. 了解数据结构 读取文件前几行，识别以下关键列： | 角色 | 识别关键词 | 示例列名 | |------|-----------|---------| | 细胞类型 | `majorCluster`, `cell_type`, `cluster` | `majorCluster` | | 组织类型 | `tissue`, `class`, `condition`, `group` | `tissue` | | 癌种 | `cancerType`, `cancer_type`, `disease` | `cancerType` | | 基因表达 | 所有数值型非元数据列 | `FOLR1`, `TACSTD2`, `MET` ... | - 如果用户指定了基因列表，只画指定的 - 如果用户未指定，自动检测所有数值型非元数据列 - 将 `Adjacent` 等正常组织名称统一映射为 `Normal` ### 3. 运行脚本 ```bash # 基本用法 — 自动检测所有基因 python3 skills/bubble-plot/bubble_plot.py <data.tsv> # 只画指定基因 python3 skills/bubble-plot/bubble_plot.py <data.tsv> --genes FOLR1 TACSTD2 MET # 自定义列名（如果自动检测失败） python3 skills/bubble-plot/bubble_plot.py <data.tsv> --genes FOLR1 MET --cell-col CellType --cancer-col Disease # 不按组织类型拆分 python3 skills/bubble-plot/bubble_plot.py <data.tsv> --no-split-tissue # 自定义输出目录和分辨率 python3 skills/bubble-plot/bubble_plot.py <data.tsv> -o my_plots --dpi 600 ``` ### 4. 输出文件 - 默认输出到 `bubble_plots/` 目录 - 每个基因 × 组织类型 = 1 个 PNG（300dpi）+ 1 个 PDF - 文件命名：`{基因}_dotplot_{Normal|Tumor}.{png|pdf}` ### 5. 向用户报告结果 - 列出生成的文件清单和大小 - 如果有基因在数据中不存在，明确告知并跳过 - 展示 1-2 张关键图的预览 ## 参数说明 | 参数 | 默认值 | 说明 | |------|--------|------| | `input` | 必填 | 输入 TSV 或 CSV 文件路径 | | `--genes / -g` | 自动检测 | 要画的基因名列表 | | `--cell-col` | 自动检测 | 细胞类型列名 | | `--cancer-col` | 自动检测 | 癌种/疾病列名 | | `--tissue-col` | 自动检测 | 组织类型列名 | | `--split-tissue` | True | 按组织类型拆分画图 | | `--no-split-tissue` | - | 不拆分，所有数据画一张 | | `--outdir / -o` | `bubble_plots` | 输出目录 | | `--dpi` | 300 | PNG 分辨率 | ## 图例说明 - **气泡颜色**：标准化平均表达量（0-1，红=高，蓝=低，RdBu_r 色谱） - **气泡大小**：阳性细胞百分比（该细胞类型中表达量 > 0 的比例） ## 自检 - 数据文件是否已确认下载到本地 - 基因列名是否与数据中一致（区分大小写） - `tissue` 列中 `Adjacent` 是否已映射为 `Normal` - 输出是否包含 PNG 和 PDF 两种格式 - 缺失的基因是否已告知用户

path: bubble_plot.py
content: #!/usr/bin/env python3
"""
Bubble Plot Generator for Gene Expression Data
Reads a TSV/CSV expression matrix and generates bubble (dot) plots.
Each plot: x = cancerType, y = cellType, color = scaled avg expression, size = % positive cells.
"""

import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import sys
import os
import argparse


# ── Column auto-detection ──────────────────────────────────────────────

METADATA_KEYWORDS = {
    'cell_type': ['majorcluster', 'cell_type', 'celltype', 'cell type', 'cluster',
                  'cell_annot', 'cellannot', 'cell_annotation', 'celllabel', 'cell_label'],
    'tissue':    ['tissue', 'class', 'condition', 'sample_type', 'group', 'source'],
    'cancer_type': ['cancertype', 'cancer_type', 'cancer type', 'cancer', 'disease',
                    'tumor_type', 'tumortype', 'project', 'dataset'],
}

TISSUE_NORMALIZE = {
    'adjacent': 'normal',
    'normal': 'normal',
    'healthy': 'normal',
    'control': 'normal',
    'para': 'normal',
    'tumor': 'tumor',
    'cancer': 'tumor',
}


def detect_columns(df):
    """Auto-detect cell_type, tissue, cancer_type columns. Returns dict."""
    cols_lower = {c.lower().strip(): c for c in df.columns}
    result = {}
    for role, keywords in METADATA_KEYWORDS.items():
        for kw in keywords:
            if kw in cols_lower:
                result[role] = cols_lower[kw]
                break
    return result


def detect_gene_columns(df, meta_cols):
    """All numeric columns that are NOT metadata = gene columns."""
    meta_set = set(meta_cols.values())
    genes = []
    for col in df.columns:
        if col in meta_set:
            continue
        if pd.api.types.is_numeric_dtype(df[col]):
            genes.append(col)
    return genes


# ── Plotting ───────────────────────────────────────────────────────────

def plot_bubble(data, gene_col, gene_name, cell_col, cancer_col, tissue_label=""):
    """Create one bubble plot."""
    grouped = data.groupby([cancer_col, cell_col])[gene_col].agg(
        Total='count',
        Positive=lambda x: (x > 0).sum(),
        AvgExpr='mean'
    ).reset_index()

    grouped['PctExpr'] = grouped['Positive'] / grouped['Total'] * 100

    max_expr = grouped['AvgExpr'].max()
    if max_expr == 0:
        max_expr = 1
    grouped['AvgExpr_Scaled'] = grouped['AvgExpr'] / max_expr

    cancer_types = sorted(grouped[cancer_col].unique())
    cell_types = sorted(grouped[cell_col].unique())

    x_map = {ct: i for i, ct in enumerate(cancer_types)}
    y_map = {ct: i for i, ct in enumerate(cell_types)}

    grouped['x'] = grouped[cancer_col].map(x_map)
    grouped['y'] = grouped[cell_col].map(y_map)

    fig, ax = plt.subplots(figsize=(max(10, len(cancer_types) * 0.5),
                                    max(5, len(cell_types) * 0.45)))

    scatter = ax.scatter(
        grouped['x'], grouped['y'],
        c=grouped['AvgExpr_Scaled'],
        s=grouped['PctExpr'] * 8,
        cmap=plt.cm.RdBu_r,
        vmin=0, vmax=1,
        edgecolors='gray', linewidths=0.3, alpha=0.9
    )

    cbar = plt.colorbar(scatter, ax=ax, shrink=0.8, pad=0.02)
    cbar.set_label(f'Scaled avg {gene_name} expression', fontsize=9)

    # Size legend
    pct_vals = [10, 25, 50, 75]
    handles = [plt.scatter([], [], s=pv * 8, c='gray', alpha=0.6,
                           edgecolors='gray', linewidths=0.3) for pv in pct_vals]
    ax.legend(handles, [f'{pv}%' for pv in pct_vals],
              title=f'{gene_name}+ cell fraction',
              loc='upper left', bbox_to_anchor=(1.15, 0.5),
              frameon=True, fontsize=8, title_fontsize=9, labelspacing=1.2)

    ax.set_xticks(range(len(cancer_types)))
    ax.set_xticklabels(cancer_types, rotation=45, ha='right', fontsize=8)
    ax.set_yticks(range(len(cell_types)))
    ax.set_yticklabels(cell_types, fontsize=9)
    ax.set_xlabel(cancer_col, fontsize=10, fontweight='bold')
    ax.set_ylabel(cell_col, fontsize=10, fontweight='bold')

    title = f'{gene_name}'
    if tissue_label:
        title += f' - {tissue_label}'
    ax.set_title(title, fontsize=12, fontweight='bold')

    for spine in ax.spines.values():
        spine.set_visible(True)
        spine.set_linewidth(0.5)

    ax.grid(False)
    fig.tight_layout()
    return fig


# ── Main ───────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description='Bubble plot generator for gene expression data')
    parser.add_argument('input', help='Input TSV or CSV file')
    parser.add_argument('--genes', '-g', nargs='+', default=None,
                        help='Gene names to plot (default: auto-detect all numeric non-metadata columns)')
    parser.add_argument('--tissue-col', default=None, help='Override tissue column name')
    parser.add_argument('--cell-col', default=None, help='Override cell type column name')
    parser.add_argument('--cancer-col', default=None, help='Override cancer type column name')
    parser.add_argument('--split-tissue', action='store_true', default=True,
                        help='Split plots by tissue type (default: True)')
    parser.add_argument('--no-split-tissue', dest='split_tissue', action='store_false')
    parser.add_argument('--outdir', '-o', default='bubble_plots', help='Output directory')
    parser.add_argument('--dpi', type=int, default=300, help='PNG DPI')
    parser.add_argument('--width', type=float, default=10, help='Figure width in inches')
    parser.add_argument('--height', type=float, default=7, help='Figure height in inches')
    args = parser.parse_args()

    # Read data
    print(f"Reading {args.input} ...")
    sep = '\t' if args.input.endswith('.tsv') else ','
    df = pd.read_csv(args.input, sep=sep)
    # If first column looks like row names (unnamed), drop it
    if df.columns[0].startswith('Unnamed') or df.columns[0] == '':
        df = pd.read_csv(args.input, sep=sep, index_col=0)
    print(f"  {df.shape[0]:,} rows, {df.shape[1]} columns")

    # Detect columns
    meta = detect_columns(df)
    print(f"  Detected metadata: {meta}")

    # Apply overrides
    if args.cell_col:
        meta['cell_type'] = args.cell_col
    if args.tissue_col:
        meta['tissue'] = args.tissue_col
    if args.cancer_col:
        meta['cancer_type'] = args.cancer_col

    # Validate required columns
    if 'cell_type' not in meta:
        print("ERROR: Cannot detect cell type column. Use --cell-col to specify.")
        print(f"  Available columns: {list(df.columns)}")
        sys.exit(1)
    if 'cancer_type' not in meta:
        print("ERROR: Cannot detect cancer type column. Use --cancer-col to specify.")
        print(f"  Available columns: {list(df.columns)}")
        sys.exit(1)

    # Detect gene columns
    if args.genes:
        gene_cols = args.genes
        missing = [g for g in gene_cols if g not in df.columns]
        if missing:
            print(f"ERROR: Gene columns not found: {missing}")
            print(f"  Available: {[c for c in df.columns if c not in meta.values()]}")
            sys.exit(1)
    else:
        gene_cols = detect_gene_columns(df, meta)
        print(f"  Auto-detected {len(gene_cols)} gene columns")

    if not gene_cols:
        print("ERROR: No gene columns found.")
        sys.exit(1)

    # Normalize tissue values
    if 'tissue' in meta:
        tcol = meta['tissue']
        df[tcol] = df[tcol].astype(str).str.lower().map(
            lambda v: TISSUE_NORMALIZE.get(v, v)
        )
        tissue_values = sorted(df[tcol].unique())
    else:
        tissue_values = ['all']
        df['_all'] = 'all'
        meta['tissue'] = '_all'

    tcol = meta['tissue']
    cell_col = meta['cell_type']
    cancer_col = meta['cancer_type']

    # Output
    os.makedirs(args.outdir, exist_ok=True)
    files_written = []

    for gene in gene_cols:
        for tissue_val in tissue_values:
            label = tissue_val if tissue_val != 'all' else ''
            print(f"  Plotting {gene}{' - ' + label if label else ''} ...")

            data_sub = df[df[tcol] == tissue_val].copy()
            if len(data_sub) == 0:
                print(f"    No data, skipping")
                continue

            fig = plot_bubble(data_sub, gene, gene, cell_col, cancer_col, label)

            base = f"{gene}_dotplot"
            if label:
                base += f"_{label}"

            png_path = os.path.join(args.outdir, f"{base}.png")
            pdf_path = os.path.join(args.outdir, f"{base}.pdf")

            fig.savefig(png_path, dpi=args.dpi, bbox_inches='tight', facecolor='white')
            fig.savefig(pdf_path, bbox_inches='tight', facecolor='white')
            plt.close(fig)
            files_written.extend([png_path, pdf_path])

    # Summary
    print(f"\nDone! {len(files_written)} files in {args.outdir}/")
    for f in sorted(files_written):
        print(f"  {f} ({os.path.getsize(f) / 1024:.1f} KB)")


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)