|
| 1 | +"""Generate the manuscript figures from the DiffBio Gate-2 benchmark results. |
| 2 | +
|
| 3 | +All numbers are the summary statistics (mean +/- std over 3 seeds unless noted) of the |
| 4 | +runs described in the paper; each block cites its source run. Figures are written as |
| 5 | +vector PDFs into ``paper/figures/`` for inclusion in the LaTeX manuscript. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import matplotlib as mpl |
| 13 | +import matplotlib.pyplot as plt |
| 14 | +import numpy as np |
| 15 | + |
| 16 | +mpl.rcParams.update( |
| 17 | + { |
| 18 | + "figure.dpi": 150, |
| 19 | + "savefig.bbox": "tight", |
| 20 | + "font.family": "serif", |
| 21 | + "font.size": 10, |
| 22 | + "axes.spines.top": False, |
| 23 | + "axes.spines.right": False, |
| 24 | + "axes.grid": True, |
| 25 | + "grid.alpha": 0.25, |
| 26 | + "grid.linewidth": 0.5, |
| 27 | + } |
| 28 | +) |
| 29 | + |
| 30 | +FIG_DIR = Path(__file__).resolve().parent / "figures" |
| 31 | +FIG_DIR.mkdir(parents=True, exist_ok=True) |
| 32 | + |
| 33 | +FROZEN = "#4C72B0" |
| 34 | +JOINT = "#C44E52" |
| 35 | +ACCENT = "#55A868" |
| 36 | + |
| 37 | +# --- Main Gate-2 result: macro-F1 and rare-type F1, frozen vs joint -------------- |
| 38 | +# Source runs: gate2_joint_pipeline.json (TS120k dispersion); gate2_ts120k_supervised, |
| 39 | +# gate2_tsall_dispersion, gate2_tsall_supervised (gate2_strengthen.py). |
| 40 | +CONDITIONS = [ |
| 41 | + # label, frozen_macro, frozen_std, joint_macro, joint_std, frozen_rare, joint_rare |
| 42 | + ("TS-120k\n59 types\ndispersion", 0.7304, 0.0054, 0.7826, 0.0034, 0.563, 0.625), |
| 43 | + ("TS-120k\n59 types\nsupervised", 0.7398, 0.0134, 0.7989, 0.0000, 0.595, 0.648), |
| 44 | + ("TS-193k\n87 types\ndispersion", 0.6497, 0.0060, 0.7518, 0.0000, 0.403, 0.534), |
| 45 | +] |
| 46 | + |
| 47 | + |
| 48 | +def fig_main_result() -> None: |
| 49 | + """Grouped bars: frozen vs joint, overall macro-F1 and rare-type F1.""" |
| 50 | + labels = [c[0] for c in CONDITIONS] |
| 51 | + x = np.arange(len(labels)) |
| 52 | + width = 0.38 |
| 53 | + fig, axes = plt.subplots(1, 2, figsize=(9.0, 3.6)) |
| 54 | + |
| 55 | + frozen = [c[1] for c in CONDITIONS] |
| 56 | + frozen_err = [c[2] for c in CONDITIONS] |
| 57 | + joint = [c[3] for c in CONDITIONS] |
| 58 | + joint_err = [c[4] for c in CONDITIONS] |
| 59 | + axes[0].bar( |
| 60 | + x - width / 2, frozen, width, yerr=frozen_err, capsize=3, label="Frozen PCA", color=FROZEN |
| 61 | + ) |
| 62 | + axes[0].bar( |
| 63 | + x + width / 2, |
| 64 | + joint, |
| 65 | + width, |
| 66 | + yerr=joint_err, |
| 67 | + capsize=3, |
| 68 | + label="Joint projection", |
| 69 | + color=JOINT, |
| 70 | + ) |
| 71 | + for xi, (f, j) in enumerate(zip(frozen, joint, strict=True)): |
| 72 | + axes[0].annotate( |
| 73 | + f"+{100 * (j - f):.1f}", (xi, max(f, j) + 0.02), ha="center", fontsize=8, color=JOINT |
| 74 | + ) |
| 75 | + axes[0].set_ylabel("Held-out macro-F1") |
| 76 | + axes[0].set_title("(a) Cell-type annotation accuracy") |
| 77 | + axes[0].set_ylim(0.55, 0.87) |
| 78 | + |
| 79 | + frozen_rare = [c[5] for c in CONDITIONS] |
| 80 | + joint_rare = [c[6] for c in CONDITIONS] |
| 81 | + axes[1].bar(x - width / 2, frozen_rare, width, label="Frozen PCA", color=FROZEN) |
| 82 | + axes[1].bar(x + width / 2, joint_rare, width, label="Joint projection", color=JOINT) |
| 83 | + for xi, (f, j) in enumerate(zip(frozen_rare, joint_rare, strict=True)): |
| 84 | + axes[1].annotate( |
| 85 | + f"+{100 * (j - f):.1f}", (xi, max(f, j) + 0.015), ha="center", fontsize=8, color=JOINT |
| 86 | + ) |
| 87 | + axes[1].set_ylabel("Rare cell-type F1") |
| 88 | + axes[1].set_title("(b) Rare cell types") |
| 89 | + axes[1].set_ylim(0.35, 0.72) |
| 90 | + |
| 91 | + for ax in axes: |
| 92 | + ax.set_xticks(x) |
| 93 | + ax.set_xticklabels(labels, fontsize=7.5) |
| 94 | + ax.legend(frameon=False, fontsize=8, loc="upper right") |
| 95 | + fig.tight_layout() |
| 96 | + fig.savefig(FIG_DIR / "fig_main_result.pdf") |
| 97 | + plt.close(fig) |
| 98 | + |
| 99 | + |
| 100 | +def fig_gain_vs_k() -> None: |
| 101 | + """The central regime figure: frozen and joint macro-F1 as a function of k.""" |
| 102 | + # Matched frozen/joint macro-F1 across k (TS-120k, dispersion HVG, 3 seeds). |
| 103 | + k_values = [5, 10, 20, 50] |
| 104 | + frozen = {5: (0.5645, 0.0108), 10: (0.7304, 0.0054), 20: (0.7941, 0.0070), 50: (0.8243, 0.0111)} |
| 105 | + joint = {5: (0.7488, 0.0077), 10: (0.7826, 0.0034), 20: (0.8211, 0.0064), 50: (0.8406, 0.0035)} |
| 106 | + soft = (0.8304, 40.3) |
| 107 | + |
| 108 | + fig, ax = plt.subplots(figsize=(5.4, 4.0)) |
| 109 | + ks = np.array(k_values) |
| 110 | + fm = np.array([frozen[k][0] for k in k_values]) |
| 111 | + fs = np.array([frozen[k][1] for k in k_values]) |
| 112 | + ax.plot(ks, fm, "-o", color=FROZEN, label="Frozen PCA") |
| 113 | + ax.fill_between(ks, fm - fs, fm + fs, color=FROZEN, alpha=0.15) |
| 114 | + |
| 115 | + jk = sorted(joint) |
| 116 | + jm = np.array([joint[k][0] for k in jk]) |
| 117 | + js = np.array([joint[k][1] for k in jk]) |
| 118 | + ax.plot(jk, jm, "-s", color=JOINT, label="Joint projection") |
| 119 | + ax.fill_between(jk, jm - js, jm + js, color=JOINT, alpha=0.15) |
| 120 | + |
| 121 | + ax.plot( |
| 122 | + [soft[1]], |
| 123 | + [soft[0]], |
| 124 | + "*", |
| 125 | + color=ACCENT, |
| 126 | + markersize=14, |
| 127 | + label=f"Soft-k (learned dim {soft[1]:.0f})", |
| 128 | + ) |
| 129 | + for k in jk: |
| 130 | + gain = 100 * (joint[k][0] - frozen[k][0]) |
| 131 | + ax.annotate( |
| 132 | + f"+{gain:.1f}pp", (k, joint[k][0] + 0.006), ha="center", fontsize=8, color=JOINT |
| 133 | + ) |
| 134 | + ax.set_xscale("log") |
| 135 | + ax.set_xticks(k_values) |
| 136 | + ax.get_xaxis().set_major_formatter(mpl.ticker.ScalarFormatter()) |
| 137 | + ax.set_xlabel("Number of components $k$") |
| 138 | + ax.set_ylabel("Held-out macro-F1") |
| 139 | + ax.set_title("Joint gain concentrates at aggressive reduction") |
| 140 | + ax.legend(frameon=False, fontsize=8, loc="lower right") |
| 141 | + fig.tight_layout() |
| 142 | + fig.savefig(FIG_DIR / "fig_gain_vs_k.pdf") |
| 143 | + plt.close(fig) |
| 144 | + |
| 145 | + |
| 146 | +def fig_scaling_and_ablation() -> None: |
| 147 | + """Gain vs dataset size (crossover) and the knob-attribution ablation.""" |
| 148 | + fig, axes = plt.subplots(1, 2, figsize=(9.0, 3.6)) |
| 149 | + |
| 150 | + # (a) gain vs n_cells; source: joint-preprocessing scaling curve + Gate-2 runs. |
| 151 | + n_cells = np.array([2000, 10000, 33000, 120000, 193000]) |
| 152 | + gain = np.array([-0.086, -0.027, -0.007, 0.052, 0.102]) |
| 153 | + axes[0].axhline(0.0, color="grey", lw=0.8, ls="--") |
| 154 | + axes[0].plot(n_cells, gain, "-o", color=JOINT) |
| 155 | + axes[0].fill_between(n_cells, gain, 0, where=(gain > 0), color=JOINT, alpha=0.12) |
| 156 | + axes[0].fill_between(n_cells, gain, 0, where=(gain <= 0), color=FROZEN, alpha=0.12) |
| 157 | + axes[0].set_xscale("log") |
| 158 | + axes[0].set_xlabel("Atlas size (cells)") |
| 159 | + axes[0].set_ylabel("Joint $-$ frozen macro-F1") |
| 160 | + axes[0].set_title("(a) Gain grows with scale (crossover)") |
| 161 | + axes[0].annotate("frozen wins\n(small data)", (2600, -0.07), fontsize=7.5, color=FROZEN) |
| 162 | + axes[0].annotate("joint wins\n(atlas scale)", (60000, 0.07), fontsize=7.5, color=JOINT) |
| 163 | + |
| 164 | + # (b) ablation: which learnable knob carries the gain (TS-120k, k=10, 2 seeds). |
| 165 | + variants = ["frozen", "norm", "hvg", "proj", "all"] |
| 166 | + scores = [0.736, 0.734, 0.735, 0.765, 0.765] |
| 167 | + colors = [FROZEN, FROZEN, FROZEN, JOINT, JOINT] |
| 168 | + axes[1].bar(variants, scores, color=colors) |
| 169 | + axes[1].set_ylim(0.72, 0.775) |
| 170 | + axes[1].axhline(scores[0], color="grey", lw=0.8, ls="--") |
| 171 | + axes[1].set_ylabel("Held-out macro-F1") |
| 172 | + axes[1].set_title("(b) The projection carries the gain") |
| 173 | + axes[1].annotate("learnable\nprojection", (3, 0.767), ha="center", fontsize=7.5, color=JOINT) |
| 174 | + fig.tight_layout() |
| 175 | + fig.savefig(FIG_DIR / "fig_scaling_ablation.pdf") |
| 176 | + plt.close(fig) |
| 177 | + |
| 178 | + |
| 179 | +def fig_cross_modality() -> None: |
| 180 | + """The joint gain at aggressive reduction across pipelines, modalities and machineries.""" |
| 181 | + # (label, gain_pp @ k=5 unless noted, machinery). Sources: gate_sequence/gate_vcc/ |
| 182 | + # gate_atac/gate_variant_pool JSON summaries (3 seeds each) and the scRNA gain-vs-k run. |
| 183 | + entries = [ |
| 184 | + ("scRNA\nannotation", 18.4, "linear"), |
| 185 | + ("DNA regulatory\nelements", 7.5, "linear"), |
| 186 | + ("Perturb-seq\nidentity", 8.3, "linear"), |
| 187 | + ("scATAC\nannotation", 12.2, "linear"), |
| 188 | + ("Variant calling\n(read pooling)", 16.9, "pool"), |
| 189 | + ("Variant calling\n(no bottleneck)", -1.1, "null"), |
| 190 | + ] |
| 191 | + color = {"linear": JOINT, "pool": ACCENT, "null": "#9AA0A6"} |
| 192 | + |
| 193 | + fig, ax = plt.subplots(figsize=(7.4, 3.6)) |
| 194 | + positions = np.arange(len(entries)) |
| 195 | + gains = [entry[1] for entry in entries] |
| 196 | + colors = [color[entry[2]] for entry in entries] |
| 197 | + ax.bar(positions, gains, color=colors, width=0.66) |
| 198 | + ax.axhline(0.0, color="grey", lw=0.8) |
| 199 | + for position, gain in zip(positions, gains, strict=True): |
| 200 | + offset = 0.5 if gain >= 0 else -0.9 |
| 201 | + ax.annotate(f"{gain:+.1f}", (float(position), gain + offset), ha="center", fontsize=8) |
| 202 | + ax.set_xticks(positions) |
| 203 | + ax.set_xticklabels([entry[0] for entry in entries], fontsize=7.5) |
| 204 | + ax.set_ylabel("Joint $-$ frozen macro-F1 (pp)") |
| 205 | + ax.set_title("The joint gain generalizes across modalities and machineries") |
| 206 | + handles = [ |
| 207 | + mpl.patches.Patch(color=JOINT, label="Linear projection"), |
| 208 | + mpl.patches.Patch(color=ACCENT, label="Set pooling (reads)"), |
| 209 | + mpl.patches.Patch(color="#9AA0A6", label="No reduction bottleneck"), |
| 210 | + ] |
| 211 | + ax.legend( |
| 212 | + handles=handles, frameon=False, fontsize=8, loc="upper center", |
| 213 | + ncol=3, bbox_to_anchor=(0.5, 1.0), columnspacing=1.4, handletextpad=0.5, |
| 214 | + ) |
| 215 | + ax.set_ylim(-4, 25) |
| 216 | + fig.tight_layout() |
| 217 | + fig.savefig(FIG_DIR / "fig_cross_modality.pdf") |
| 218 | + plt.close(fig) |
| 219 | + |
| 220 | + |
| 221 | +def main() -> None: |
| 222 | + """Generate all manuscript figures.""" |
| 223 | + fig_main_result() |
| 224 | + fig_gain_vs_k() |
| 225 | + fig_scaling_and_ablation() |
| 226 | + fig_cross_modality() |
| 227 | + print(f"figures written to {FIG_DIR}") |
| 228 | + |
| 229 | + |
| 230 | +if __name__ == "__main__": |
| 231 | + main() |
0 commit comments