Skip to content

Commit 40ec399

Browse files
vitklclaude
andcommitted
Add distributional diagnostics to model comparison notebook
Add overlaid ECDF, overlaid density histograms, quantile table (p1-p99), extreme-value fractions (genes with theta below thresholds), and distribution spread metrics (IQR, 90/98% range) to reveal tail behavior across models. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d55c85 commit 40ec399

1 file changed

Lines changed: 59 additions & 17 deletions

File tree

docs/notebooks/model_comparison_theta.ipynb

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
"- **regularizedvi_nb** (tutorial): NB mode, prior on $\\sqrt{\\theta}$ (pushes $\\theta$ small)\n",
1717
"\n",
1818
"The Exponential(rate=3) prior has:\n",
19-
"- GammaPoisson direction: $E[1/\\sqrt{\\theta}] = 1/3$ $\\theta$ centered ~9\n",
20-
"- NB direction: $E[\\sqrt{\\theta}] = 1/3$ $\\theta$ centered ~1/9\n",
19+
"- GammaPoisson direction: $E[1/\\sqrt{\\theta}] = 1/3$ \u2192 $\\theta$ centered ~9\n",
20+
"- NB direction: $E[\\sqrt{\\theta}] = 1/3$ \u2192 $\\theta$ centered ~1/9\n",
2121
"\n",
2222
"Key question: do the unconstrained scVI models learn theta values that are very different from both 1/9 and 9?"
2323
]
@@ -64,14 +64,14 @@
6464
" (\"scvi_custom\", \"scvi_custom\", \"NB, 512/128, custom train, no prior\"),\n",
6565
" (\"scvi_custom_default_train\", \"scvi_custom_default_train\", \"NB, 512/128, default train, no prior\"),\n",
6666
" (\"scvi_custom_zinb\", \"scvi_custom_zinb\", \"ZINB, 512/128, custom train, no prior\"),\n",
67-
" (\"regularizedvi_gamma_poisson\", \"regularizedvi_gamma_poisson\", \"GammaPoisson, 512/128, prior→Poisson\"),\n",
68-
" (\"regularizedvi_nb\", \"regularizedvi_nb\", \"NB, 512/128, prior→overdispersed\"),\n",
69-
" (\"regularizedvi_gp_small\", \"regularizedvi_gamma_poisson_small\", \"GammaPoisson, 128/10, prior→Poisson\"),\n",
70-
" (\"regularizedvi_gp_medium\", \"regularizedvi_gamma_poisson_medium\", \"GammaPoisson, 256/30, prior→Poisson\"),\n",
67+
" (\"regularizedvi_gamma_poisson\", \"regularizedvi_gamma_poisson\", \"GammaPoisson, 512/128, prior\u2192Poisson\"),\n",
68+
" (\"regularizedvi_nb\", \"regularizedvi_nb\", \"NB, 512/128, prior\u2192overdispersed\"),\n",
69+
" (\"regularizedvi_gp_small\", \"regularizedvi_gamma_poisson_small\", \"GammaPoisson, 128/10, prior\u2192Poisson\"),\n",
70+
" (\"regularizedvi_gp_medium\", \"regularizedvi_gamma_poisson_medium\", \"GammaPoisson, 256/30, prior\u2192Poisson\"),\n",
7171
" (\n",
7272
" \"regularizedvi_gp_small_default\",\n",
7373
" \"regularizedvi_gamma_poisson_small_default_train\",\n",
74-
" \"GammaPoisson, 128/10, default train, prior→Poisson\",\n",
74+
" \"GammaPoisson, 128/10, default train, prior\u2192Poisson\",\n",
7575
" ),\n",
7676
"]\n",
7777
"\n",
@@ -112,7 +112,49 @@
112112
"id": "hist-theta",
113113
"metadata": {},
114114
"outputs": [],
115-
"source": "fig, axes = plt.subplots(1, len(models), figsize=(4 * len(models), 4), sharey=True)\nif len(models) == 1:\n axes = [axes]\n\nbins = np.linspace(-3, 3, 80) # log10 scale: 0.001 to 1000\n\nfor ax, (name, data) in zip(axes, models.items(), strict=False):\n log_theta = np.log10(data[\"theta\"])\n ax.hist(log_theta, bins=bins, alpha=0.7, edgecolor=\"black\", linewidth=0.3)\n ax.axvline(np.log10(1 / 9), color=\"red\", linestyle=\"--\", alpha=0.7, label=\"θ=1/9 (NB prior)\")\n ax.axvline(np.log10(9), color=\"blue\", linestyle=\"--\", alpha=0.7, label=\"θ=9 (GP prior)\")\n ax.axvline(\n np.log10(np.median(data[\"theta\"])),\n color=\"green\",\n linestyle=\"-\",\n alpha=0.9,\n label=f\"median={np.median(data['theta']):.1f}\",\n )\n ax.set_xlabel(\"log₁₀(θ)\")\n ax.set_title(name, fontsize=9)\n ax.legend(fontsize=7)\n\naxes[0].set_ylabel(\"Gene count\")\nfig.suptitle(\"Per-gene inverse dispersion θ = exp(px_r)\", fontsize=12)\nplt.tight_layout()\nplt.show()"
115+
"source": "fig, axes = plt.subplots(1, len(models), figsize=(4 * len(models), 4), sharey=True)\nif len(models) == 1:\n axes = [axes]\n\nbins = np.linspace(-3, 3, 80) # log10 scale: 0.001 to 1000\n\nfor ax, (name, data) in zip(axes, models.items(), strict=False):\n log_theta = np.log10(data[\"theta\"])\n ax.hist(log_theta, bins=bins, alpha=0.7, edgecolor=\"black\", linewidth=0.3)\n ax.axvline(np.log10(1 / 9), color=\"red\", linestyle=\"--\", alpha=0.7, label=\"\u03b8=1/9 (NB prior)\")\n ax.axvline(np.log10(9), color=\"blue\", linestyle=\"--\", alpha=0.7, label=\"\u03b8=9 (GP prior)\")\n ax.axvline(\n np.log10(np.median(data[\"theta\"])),\n color=\"green\",\n linestyle=\"-\",\n alpha=0.9,\n label=f\"median={np.median(data['theta']):.1f}\",\n )\n ax.set_xlabel(\"log\u2081\u2080(\u03b8)\")\n ax.set_title(name, fontsize=9)\n ax.legend(fontsize=7)\n\naxes[0].set_ylabel(\"Gene count\")\nfig.suptitle(\"Per-gene inverse dispersion \u03b8 = exp(px_r)\", fontsize=12)\nplt.tight_layout()\nplt.show()"
116+
},
117+
{
118+
"cell_type": "markdown",
119+
"id": "3d6kr3mrx8p",
120+
"source": "## 2b. Overlaid ECDF \u2014 all models on same axes\n\nThe empirical CDF of $\\log_{10}(\\theta)$ reveals the full shape of each distribution and makes tail differences immediately visible.",
121+
"metadata": {}
122+
},
123+
{
124+
"cell_type": "code",
125+
"id": "bbavnahmdh7",
126+
"source": "fig, ax = plt.subplots(figsize=(10, 6))\n\nfor name, data in models.items():\n log_theta = np.sort(np.log10(data[\"theta\"]))\n ecdf = np.arange(1, len(log_theta) + 1) / len(log_theta)\n ax.plot(log_theta, ecdf, label=f\"{name} ({data['desc']})\", linewidth=1.5)\n\nax.axvline(np.log10(1 / 9), color=\"red\", linestyle=\"--\", alpha=0.5, label=\"\u03b8=1/9\")\nax.axvline(np.log10(9), color=\"blue\", linestyle=\"--\", alpha=0.5, label=\"\u03b8=9\")\nax.set_xlabel(\"log\u2081\u2080(\u03b8)\")\nax.set_ylabel(\"Cumulative fraction of genes\")\nax.set_title(\"ECDF of per-gene \u03b8 across all models\")\nax.legend(fontsize=7, loc=\"upper left\")\nax.set_xlim(-3, 3)\nax.grid(True, alpha=0.3)\nplt.tight_layout()\nplt.show()",
127+
"metadata": {},
128+
"execution_count": null,
129+
"outputs": []
130+
},
131+
{
132+
"cell_type": "markdown",
133+
"id": "68q53gvczvm",
134+
"source": "## 2c. Quantile table and extreme-value fractions\n\nPercentiles reveal how spread the distribution is. Extreme-value fractions show what fraction of genes have very low \u03b8 (extreme overdispersion) or very high \u03b8 (near Poisson).",
135+
"metadata": {}
136+
},
137+
{
138+
"cell_type": "code",
139+
"id": "f8yks65q64w",
140+
"source": "quantiles = [1, 5, 25, 50, 75, 95, 99]\nheader = f\"{'Model':<35}\" + \"\".join(f\"{'p' + str(q):>8}\" for q in quantiles)\nprint(\"=== Theta quantiles ===\")\nprint(header)\nprint(\"-\" * len(header))\nfor name, data in models.items():\n vals = \"\".join(f\"{np.percentile(data['theta'], q):>8.2f}\" for q in quantiles)\n print(f\"{name:<35}{vals}\")\n\nprint()\n\n# Extreme-value fractions\nthresholds = [0.01, 0.1, 1, 10, 100, 1000]\nheader2 = f\"{'Model':<35}\" + \"\".join(f\"{'<' + str(t):>8}\" for t in thresholds)\nprint(\"=== Fraction of genes with \u03b8 below threshold ===\")\nprint(header2)\nprint(\"-\" * len(header2))\nfor name, data in models.items():\n theta = data[\"theta\"]\n fracs = \"\".join(f\"{(theta < t).mean():>8.3f}\" for t in thresholds)\n print(f\"{name:<35}{fracs}\")\n\nprint()\n\n# IQR and range as spread measure\nprint(\"=== Distribution spread (log10 scale) ===\")\nprint(f\"{'Model':<35}{'IQR':>8}{'90% range':>12}{'98% range':>12}{'full range':>12}\")\nprint(\"-\" * 79)\nfor name, data in models.items():\n lt = np.log10(data[\"theta\"])\n iqr = np.percentile(lt, 75) - np.percentile(lt, 25)\n r90 = np.percentile(lt, 95) - np.percentile(lt, 5)\n r98 = np.percentile(lt, 99) - np.percentile(lt, 1)\n full = lt.max() - lt.min()\n print(f\"{name:<35}{iqr:>8.2f}{r90:>12.2f}{r98:>12.2f}{full:>12.2f}\")",
141+
"metadata": {},
142+
"execution_count": null,
143+
"outputs": []
144+
},
145+
{
146+
"cell_type": "markdown",
147+
"id": "vzxfo0slei",
148+
"source": "## 2d. Overlaid histograms (density)\n\nSame data as Section 2 but overlaid on a single plot for direct visual comparison of distribution shapes.",
149+
"metadata": {}
150+
},
151+
{
152+
"cell_type": "code",
153+
"id": "e2rwc55svjq",
154+
"source": "fig, ax = plt.subplots(figsize=(10, 5))\nbins = np.linspace(-3, 3, 80)\n\nfor name, data in models.items():\n log_theta = np.log10(data[\"theta\"])\n ax.hist(log_theta, bins=bins, alpha=0.3, density=True, label=name)\n\nax.axvline(np.log10(1 / 9), color=\"red\", linestyle=\"--\", alpha=0.7, label=\"\u03b8=1/9\")\nax.axvline(np.log10(9), color=\"blue\", linestyle=\"--\", alpha=0.7, label=\"\u03b8=9\")\nax.set_xlabel(\"log\u2081\u2080(\u03b8)\")\nax.set_ylabel(\"Density\")\nax.set_title(\"Overlaid density of per-gene \u03b8\")\nax.legend(fontsize=7)\nplt.tight_layout()\nplt.show()",
155+
"metadata": {},
156+
"execution_count": null,
157+
"outputs": []
116158
},
117159
{
118160
"cell_type": "markdown",
@@ -130,7 +172,7 @@
130172
"id": "scatter-theta",
131173
"metadata": {},
132174
"outputs": [],
133-
"source": "# Compare each model against scvi_default as baseline (if available)\nbaseline = \"scvi_default\"\nif baseline in models:\n others = [k for k in models if k != baseline]\n n = len(others)\n fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))\n if n == 1:\n axes = [axes]\n\n for ax, other_name in zip(axes, others, strict=False):\n x = np.log10(models[baseline][\"theta\"])\n y = np.log10(models[other_name][\"theta\"])\n # Ensure same number of genes\n n_genes = min(len(x), len(y))\n ax.hist2d(x[:n_genes], y[:n_genes], bins=80, cmap=\"viridis\", range=[[-3, 3], [-3, 3]])\n ax.plot([-3, 3], [-3, 3], \"r--\", alpha=0.5)\n ax.set_xlabel(f\"log₁₀(θ) — {baseline}\")\n ax.set_ylabel(f\"log₁₀(θ) — {other_name}\")\n ax.set_title(other_name, fontsize=9)\n\n fig.suptitle(f\"Per-gene θ: each model vs {baseline}\", fontsize=12)\n plt.tight_layout()\n plt.show()\nelse:\n print(f\"Baseline model '{baseline}' not found\")"
175+
"source": "# Compare each model against scvi_default as baseline (if available)\nbaseline = \"scvi_default\"\nif baseline in models:\n others = [k for k in models if k != baseline]\n n = len(others)\n fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))\n if n == 1:\n axes = [axes]\n\n for ax, other_name in zip(axes, others, strict=False):\n x = np.log10(models[baseline][\"theta\"])\n y = np.log10(models[other_name][\"theta\"])\n # Ensure same number of genes\n n_genes = min(len(x), len(y))\n ax.hist2d(x[:n_genes], y[:n_genes], bins=80, cmap=\"viridis\", range=[[-3, 3], [-3, 3]])\n ax.plot([-3, 3], [-3, 3], \"r--\", alpha=0.5)\n ax.set_xlabel(f\"log\u2081\u2080(\u03b8) \u2014 {baseline}\")\n ax.set_ylabel(f\"log\u2081\u2080(\u03b8) \u2014 {other_name}\")\n ax.set_title(other_name, fontsize=9)\n\n fig.suptitle(f\"Per-gene \u03b8: each model vs {baseline}\", fontsize=12)\n plt.tight_layout()\n plt.show()\nelse:\n print(f\"Baseline model '{baseline}' not found\")"
134176
},
135177
{
136178
"cell_type": "markdown",
@@ -151,8 +193,8 @@
151193
"source": [
152194
"mu_typical = 5.0 # typical gene expression\n",
153195
"\n",
154-
"print(f\"Variance = μ + μ²/θ = {mu_typical} + {mu_typical**2}/θ\")\n",
155-
"print(f\"{'Model':<35} {'Median θ':>10} {'Var(x)':>10} {'Var/μ':>10} {'Poisson Var':>12}\")\n",
196+
"print(f\"Variance = \u03bc + \u03bc\u00b2/\u03b8 = {mu_typical} + {mu_typical**2}/\u03b8\")\n",
197+
"print(f\"{'Model':<35} {'Median \u03b8':>10} {'Var(x)':>10} {'Var/\u03bc':>10} {'Poisson Var':>12}\")\n",
156198
"print(\"-\" * 80)\n",
157199
"for name, data in models.items():\n",
158200
" med_theta = np.median(data[\"theta\"])\n",
@@ -185,9 +227,9 @@
185227
" print(f\" CV across batches: median={np.median(cv):.3f}, mean={cv.mean():.3f}, max={cv.max():.3f}\")\n",
186228
"\n",
187229
" plt.hist(cv, bins=50, alpha=0.7, edgecolor=\"black\", linewidth=0.3)\n",
188-
" plt.xlabel(\"CV of θ across batches\")\n",
230+
" plt.xlabel(\"CV of \u03b8 across batches\")\n",
189231
" plt.ylabel(\"Gene count\")\n",
190-
" plt.title(f\"{name}: per-gene variation of θ across {theta_full.shape[1]} batches\")\n",
232+
" plt.title(f\"{name}: per-gene variation of \u03b8 across {theta_full.shape[1]} batches\")\n",
191233
" plt.show()"
192234
]
193235
},
@@ -198,14 +240,14 @@
198240
"source": [
199241
"## Summary\n",
200242
"\n",
201-
"| Model | Prior | Median θ | Interpretation |\n",
243+
"| Model | Prior | Median \u03b8 | Interpretation |\n",
202244
"|-------|-------|----------|----------------|\n",
203245
"| scvi_default | None | ~1.3 | Moderate overdispersion |\n",
204246
"| scvi_custom | None | ~2.4 | Moderate overdispersion |\n",
205-
"| regularizedvi_nb | Exp on √θ (→small) | ? | θ→1/9 expected |\n",
206-
"| regularizedvi_gamma_poisson | Exp on 1/√θ (→large) | ~53 | Near Poisson |\n",
247+
"| regularizedvi_nb | Exp on \u221a\u03b8 (\u2192small) | ? | \u03b8\u21921/9 expected |\n",
248+
"| regularizedvi_gamma_poisson | Exp on 1/\u221a\u03b8 (\u2192large) | ~53 | Near Poisson |\n",
207249
"\n",
208-
"Both prior directions constrain θ via transform, but the unconstrained models land at θ~1-2, which is far from both 1/9 and 9. This suggests the prior's main effect is constraining θ to a definite range rather than the specific direction mattering."
250+
"Both prior directions constrain \u03b8 via \u221a transform, but the unconstrained models land at \u03b8~1-2, which is far from both 1/9 and 9. This suggests the prior's main effect is constraining \u03b8 to a definite range rather than the specific direction mattering."
209251
]
210252
}
211253
],

0 commit comments

Comments
 (0)