積み重ねドットプロット

パーマーペンギン(palmerpenguins)のスウォームプロットは、どうしても不自然な鎖が伸びてしまう。Sinaplot(シーナプロット)は良いのだが、ジッターが重なってしまうことがある。何とかこの2つの良い点を合わせられないか。

ChatGPT 5.2 Pro と話していて、次のようなプロットが良いかもしれないと思った。ChatGPTはこれを「積み重ねドットプロット」(stacked dotplot)と呼んだが、一般に言うstacked dotplotとは少し違うようである。

積み重ねドットプロット

実装は以下の通り。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def stacked_dotplot(
    df, x, y, ax=None,
    s=20, width=0.35, pad=1.05, seed=0,
    y_pad=0.05,  # 上下余白(割合)
    **scatter_kws
):
    if ax is None:
        ax = plt.gca()

    # ---- 1) 欠損/非有限を同じ行で除外 ----
    d = df[[x, y]].copy()
    d = d.dropna(subset=[x, y])
    d = d[np.isfinite(d[y].to_numpy())]

    if d.empty:
        return ax

    # ---- 2) カテゴリ→コード(フィルタ後のデータだけで作る)----
    cat = pd.Categorical(d[x])
    codes = cat.codes.astype(int)          # 長さ = len(d)
    yv = d[y].to_numpy(dtype=float)        # 長さ = len(d)
    ncat = len(cat.categories)

    # ---- 3) 軸範囲(先に固定して変換を安定させる)----
    ax.set_xlim(-0.5, ncat - 0.5)

    y_min, y_max = np.min(yv), np.max(yv)
    span = (y_max - y_min) if (y_max > y_min) else 1.0
    extra = span * y_pad
    ax.set_ylim(y_min - extra, y_max + extra)

    dpi = ax.figure.dpi

    # マーカー直径(pixel)
    if s <= 0:
        raise ValueError("s must be > 0")
    diam_pix = np.sqrt(s) * dpi / 72.0 * pad

    # x方向の pixel/data 換算
    y_ref = (y_min + y_max) / 2
    px0 = ax.transData.transform((0, y_ref))[0]
    px1 = ax.transData.transform((1, y_ref))[0]
    pix_per_data_x = abs(px1 - px0)
    if pix_per_data_x == 0:
        pix_per_data_x = 1.0
    step_x = diam_pix / pix_per_data_x  # 直径ぶんのx(data)ステップ

    # ---- 4) y を pixel 空間でビン分け ----
    y_pix = ax.transData.transform(np.c_[np.zeros_like(yv), yv])[:, 1]
    # y_pix はフィルタ済みなので NaN/inf にならない想定
    bin_id = np.floor(y_pix / diam_pix).astype(int)

    rng = np.random.default_rng(seed)
    xs = np.empty_like(yv, dtype=float)

    for c in range(ncat):
        mask_c = (codes == c)                 # 長さ len(d)
        idx_c = np.where(mask_c)[0]
        if idx_c.size == 0:
            continue

        bins_c = bin_id[mask_c]               # 長さ一致で安全
        for b in np.unique(bins_c):
            idx = idx_c[bins_c == b]

            # 等間隔のまま並びだけランダム(見た目の規則性を軽減)
            idx = idx[rng.permutation(idx.size)]

            n = idx.size
            if n == 1:
                offsets = np.array([0.0])
            else:
                offsets = (np.arange(n) - (n - 1) / 2) * step_x

            # 幅超過時は圧縮(このとき非重なり保証は弱くなるので width か s を調整)
            max_abs = np.max(np.abs(offsets))
            if max_abs > width:
                offsets = offsets * (width / max_abs)

            xs[idx] = c + offsets

    ax.scatter(xs, yv, s=s, **scatter_kws)
    ax.set_xticks(range(ncat))
    ax.set_xticklabels(cat.categories)
    ax.set_xlabel(x)
    ax.set_ylabel(y)
    return ax

使い方の例:

import seaborn as sns

penguins = sns.load_dataset("penguins")
fig, ax = plt.subplots(figsize=(4.0, 4.8))
stacked_dotplot(penguins, x="species", y="body_mass_g", ax=ax, s=25, alpha=0.7)