c09_scatter_marginal 双变量关联 (Bivariate) IEEE Cell Nature

Scatter Plot with Marginal Histograms / KDE

双变量散点图,顶部与右侧分别附带直方图与核密度估计边缘子图,并绘制线性回归拟合线与 Pearson r。

🔬 矢量预览 (Vector Preview) 600 DPI Ready
Scatter Plot with Marginal Histograms / KDE

📊 统计学特性 (Statistical Features)

  • 普通最小二乘法 (OLS) 线性回归
  • Pearson r 相关系数与 p 值
  • 边缘一维核密度估计 (KDE)
  • 边缘频数直方图

🛡️ QC 质检要点 (QC Highlights)

  • 边缘子图与主散点图坐标轴精准对齐
  • 边缘图刻度隐藏保持版面清爽
  • 散点透明度避免过密重叠
  • 双栏宽度适配
🎨 顶刊色彩提取器 (Palette Extractor)Nature

Crisp sans-serif typography, clean borderless spines, high-contrast palette with soft muted secondary accents.

🛡️ 无障碍评分:AAA (Deuteranopia & Protanopia Compliant)

📋 Data Contract 数据契约规范 Strict Schema

输入数据必须完全符合下列列名与数据类型规范,方可通过自动化数据前检 (Pre-flight Validation)。

📥 下载规范示例 CSV 数据
字段名称 (Column) 数据类型 (Type) 约束性 语义说明与值域约束
x_val continuous 必需 (Required) Independent or predictor continuous variable
y_val continuous 必需 (Required) Dependent or response continuous variable
group categorical 可选 (Optional) Subgroup or experimental condition label
sample_id string 可选 (Optional) Sample identifier
组内最小样本量: n ≥ 5
最大允许缺失率: 5%

🐍 独立可复现 Python 绘图源码 Stand-alone Script

完全可复现的 Python 脚本,支持 CLI 参数 `--data`, `--style`, `--output-dir` 与模块化 `render()` 调用。

#!/usr/bin/env python3
"""FigureCraft Chart Engine: Scatter Plot with Marginal Histograms / KDE (c09_scatter_marginal)."""

import os
import sys
import argparse
from pathlib import Path
from typing import List, Dict, Optional
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats

try:
    from engines.styles import apply_style, get_palette
except ImportError:
    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
    from engines.styles import apply_style, get_palette

CHART_ID = "c09_scatter_marginal"


def render(
    data_path: Optional[str] = None,
    style: str = "nature",
    output_dir: str = "output",
    formats: Optional[List[str]] = None,
    dpi: int = 600,
) -> Dict[str, str]:
    if formats is None:
        formats = ["svg", "pdf", "png", "tiff"]
    if data_path is None:
        data_path = str(Path(__file__).resolve().parents[1] / "data" / f"{CHART_ID}.csv")

    apply_style(style)
    palette = get_palette(style)
    df = pd.read_csv(data_path)
    df = df.dropna(subset=["x_val", "y_val"])

    has_group = "group" in df.columns
    groups = list(df["group"].unique()) if has_group else ["All"]

    fig = plt.figure(figsize=(6.5, 6.0))
    gs = fig.add_gridspec(
        nrows=2,
        ncols=2,
        width_ratios=[4.5, 1.2],
        height_ratios=[1.2, 4.5],
        hspace=0.08,
        wspace=0.08,
    )

    ax_marg_x = fig.add_subplot(gs[0, 0])
    ax_main = fig.add_subplot(gs[1, 0], sharex=ax_marg_x)
    ax_marg_y = fig.add_subplot(gs[1, 1], sharey=ax_main)

    # Main scatter & group regressions
    for i, grp in enumerate(groups):
        color = palette[i % len(palette)]
        sub_df = df[df["group"] == grp] if has_group else df
        x = sub_df["x_val"].values
        y = sub_df["y_val"].values

        # Scatter
        ax_main.scatter(
            x,
            y,
            c=color,
            label=grp.replace("_", " "),
            alpha=0.75,
            s=32,
            edgecolors="#FFFFFF",
            linewidth=0.5,
            zorder=3,
        )

        # Marginal X (Top KDE)
        if len(x) >= 3:
            kde_x = stats.gaussian_kde(x)
            x_eval = np.linspace(df["x_val"].min(), df["x_val"].max(), 100)
            ax_marg_x.plot(x_eval, kde_x(x_eval), color=color, lw=1.2)
            ax_marg_x.fill_between(x_eval, 0, kde_x(x_eval), color=color, alpha=0.3)

        # Marginal Y (Right KDE)
        if len(y) >= 3:
            kde_y = stats.gaussian_kde(y)
            y_eval = np.linspace(df["y_val"].min(), df["y_val"].max(), 100)
            ax_marg_y.plot(kde_y(y_eval), y_eval, color=color, lw=1.2)
            ax_marg_y.fill_betweenx(y_eval, 0, kde_y(y_eval), color=color, alpha=0.3)

    # Overall linear regression fit across all data
    x_all = df["x_val"].values
    y_all = df["y_val"].values
    if len(x_all) >= 3:
        slope, intercept, r_val, p_val, std_err = stats.linregress(x_all, y_all)
        x_line = np.linspace(x_all.min(), x_all.max(), 100)
        y_line = slope * x_line + intercept
        ax_main.plot(x_line, y_line, color="#1F2937", linestyle="--", linewidth=1.4, zorder=2)

        # 95% Confidence band for regression
        n = len(x_all)
        t_crit = stats.t.ppf(0.975, df=n - 2)
        s_err = np.sqrt(np.sum((y_all - (slope * x_all + intercept)) ** 2) / (n - 2))
        x_mean = np.mean(x_all)
        ci = t_crit * s_err * np.sqrt(1 / n + (x_line - x_mean) ** 2 / np.sum((x_all - x_mean) ** 2))
        ax_main.fill_between(x_line, y_line - ci, y_line + ci, color="#9CA3AF", alpha=0.25, zorder=1)

        stat_text = f"Linear Fit: $r = {r_val:.2f}$\n$p = {p_val:.3e}$" if p_val < 0.001 else f"Linear Fit: $r = {r_val:.2f}$\n$p = {p_val:.3f}$"
        ax_main.text(
            0.05,
            0.92,
            stat_text,
            transform=ax_main.transAxes,
            fontsize=7.5,
            va="top",
            bbox=dict(boxstyle="round,pad=0.3", facecolor="#F3F4F6", edgecolor="#D1D5DB", alpha=0.85),
        )

    ax_main.set_xlabel("Independent Predictor ($X$)")
    ax_main.set_ylabel("Dependent Response ($Y$)")
    ax_main.legend(loc="lower right", frameon=False, fontsize=7.5)

    # Clean marginal axes
    ax_marg_x.axis("off")
    ax_marg_y.axis("off")

    fig.suptitle("Bivariate Association with Marginal Kernel Densities", fontsize=9.5, fontweight="bold", y=0.98)

    os.makedirs(output_dir, exist_ok=True)
    generated_files = {}
    for fmt in formats:
        out_path = os.path.join(output_dir, f"{CHART_ID}_{style}.{fmt}")
        fig.savefig(out_path, format=fmt, dpi=dpi, bbox_inches="tight")
        generated_files[fmt] = str(Path(out_path).resolve())

    plt.close(fig)
    return generated_files


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Render Scatter with Marginals")
    parser.add_argument("--data", type=str, default=None, help="Path to input CSV dataset")
    parser.add_argument("--style", type=str, default="nature", choices=["nature", "cell", "lancet", "ieee", "economist"], help="Journal style pack")
    parser.add_argument("--output-dir", type=str, default="output", help="Output directory")
    parser.add_argument("--formats", type=str, default="svg,pdf,png,tiff", help="Comma-separated export formats")
    parser.add_argument("--dpi", type=int, default=600, help="Raster DPI")
    args = parser.parse_args()

    fmt_list = [f.strip() for f in args.formats.split(",") if f.strip()]
    results = render(args.data, style=args.style, output_dir=args.output_dir, formats=fmt_list, dpi=args.dpi)
    for fmt, path in results.items():
        print(f"Generated {fmt.upper()}: {path}")