Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions Python/Scatter3dPlot.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: "3D Scatter Plot (Python)"
author:
- "**[Editor]** [Shixiang Wang](https://github.com/ShixiangWang);"
- "**[Contributors]** Codex (GPT-5) — AI-generated, independently AI-reviewed;"
---

The Python counterpart of [3D Scatter](../Hiplot/159-scatter-3d.html) — a 3D scatter places each subject at $(x, y, z)$ coordinates of three quantitative variables, adding one biomarker dimension to the classic 2D plot. In translational research it helps when group separation genuinely lives in three variables (age x marker x marker, cell size x granularity x intensity) and a static 2D projection visibly flattens it \[1\].

matplotlib's `mplot3d` renders the projection \[2\]. Two properties make 3D scatter plots easy to misread: the *view angle* decides which clusters hide behind which, and *depth* is encoded only by size/alpha shading — a printed 3D plot is a single fixed projection of a 3D object. Both should be treated as parameters to report, like a model.

This tutorial covers two workflows:

1. A default-view 3D scatter of three patient groups;
2. A rotation panel showing how the same data changes its story.

## Example

![](../images/Python/Scatter3dPlot_demo.png){fig-align="center" width="65%"}

## Setup

- System Requirements: Cross-platform (Linux/MacOS/Windows)
- Programming Language: **Python**
- Dependencies: `matplotlib`, `numpy`

```{python}
#| label: packages-setup
#| output: false
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (registers 3d projection)
```

## Data Preparation

We simulate 240 subjects in three groups with distinct (age, marker A, marker B) profiles: responders are older with mid-range markers, non-responders have the highest markers, healthy donors the lowest. The separation direction is chosen so some viewpoints separate the groups and others collapse them.

```{python}
#| label: data-prep
rng = np.random.default_rng(21)
groups = {
"Responder": (np.array([60, 25, 8]), np.array([8, 6, 2.2]),
"#1B7837"),
"Non-responder": (np.array([48, 40, 15]), np.array([9, 7, 3.0]),
"#B2182B"),
"Healthy": (np.array([35, 12, 4]), np.array([10, 4, 1.4]),
"#4C72B0"),
}
n_per = 80
data = []
for g, (mu, sd, color) in groups.items():
xyz = np.column_stack([rng.normal(m, s, n_per)
for m, s in zip(mu, sd)])
data.append((g, xyz, color))

def draw(ax, elev, azim, title):
for g, xyz, color in data:
ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], s=14, alpha=0.6,
color=color, label=g, depthshade=True)
ax.set_xlabel("Age (years)")
ax.set_ylabel("Marker A (U/mL)")
ax.set_zlabel("Marker B (ng/mL)")
ax.view_init(elev=elev, azim=azim)
ax.set_title(title, fontsize=10, pad=0)
ax.set_box_aspect((1, 1, 0.8))
```

## Visualization

### 1. Default View

```{python}
#| label: fig-py-3d-basic
#| fig-cap: "Three patient groups in biomarker space at elev=20, azim=-60: the groups occupy distinct regions of the 3D volume."
#| fig-align: center

fig = plt.figure(figsize=(7.5, 5.6))
ax = fig.add_subplot(111, projection="3d")
draw(ax, elev=20, azim=-60,
title="Three patient groups in biomarker space")
ax.legend(loc="upper left", fontsize=9)
fig.tight_layout()
plt.show()
```

### 2. Rotation Panel

```{python}
#| label: fig-py-3d-views
#| fig-cap: "The same dataset from two viewpoints: the separability reading at left is not available edge-on at right, where depth order and the reversed axis direction become hard to judge."
#| fig-align: center

fig = plt.figure(figsize=(11.5, 4.8))
for i, (elev, azim, title) in enumerate([
(20, -60, "View A (elev=20, azim=-60): groups look separable"),
(2, 55, "View B (elev=2, azim=55): edge-on view obscures depth")]):
ax = fig.add_subplot(1, 2, i + 1, projection="3d")
draw(ax, elev=elev, azim=azim, title=title)
fig.suptitle("One dataset, two stories: rotation changes the reading",
y=1.0)
fig.tight_layout()
plt.show()
```

::: callout-tip
## Practice tips

- Report `elev`/`azim` in the caption when a claim depends on the viewpoint; a reviewer cannot otherwise reproduce your separation.
- `alpha` + `depthshade` only *suggest* depth — points still overplot. Lower `alpha` and smaller markers reduce false occlusion judgments.
- Prefer 2D panels of the variable pairs (Application panel B) for any quantitative comparison; use the 3D view to motivate them.
- Set `ax.set_box_aspect()` to a physically meaningful ratio, or a 3x range axis will visually dominate a 1x range axis.
- Interactive rotation (notebook `%matplotlib widget`, plotly `Scatter3d`) is for exploration; export the chosen angle for publication.
:::

## Application

The publication pairing places the chosen 3D view (A) next to its strongest 2D projection (B): the 3D panel motivates the three-group structure, and the 2D panel carries the readable, quantitative comparison that the paper's statistics refer to.

![](../images/Python/Scatter3dPlot_app1.png){fig-align="center" width="85%"}

## Reference

\[1\] Li J, Martens JB. Judging correlation from scatterplots: an evaluation of 3D visualizations. IEEE Trans Vis Comput Graph. 2010;16(6):1027-1036.

\[2\] Hunter JD. Matplotlib: a 2D graphics environment. Comput Sci Eng. 2007;9(3):90-95. doi:10.1109/MCSE.2007.55
126 changes: 126 additions & 0 deletions Python/Scatter3dPlot.zh.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: "3D 散点图 (Python)"
author:
- "**[编辑]** [王诗翔](https://github.com/ShixiangWang)"
- "**[贡献]** Codex (GPT-5) — AI 生成,独立 AI 复核"
---

[3D 散点图](../zh/Hiplot/159-scatter-3d.html)的 Python 对应版本——3D 散点图把每个受试者放到三个定量变量的 $(x, y, z)$ 坐标上,在经典二维散点之上再加一维生物标志物。当组间分离确实存在于三个变量(年龄 x 标志物 x 标志物、细胞大小 x 颗粒度 x 荧光强度)、而二维投影明显压平了结构时,转化医学研究可以用它辅助探索 \[1\]。

matplotlib 的 `mplot3d` 负责渲染 \[2\]。3D 散点易被误读的两个原因:*视角*决定哪些簇藏在哪些簇后面;*深度*只靠大小/透明度着色暗示——印刷出来的 3D 图是三维对象的单一固定投影。两者都应作为需要报告的参数对待,就像模型一样。

本教程涵盖两种工作流:

1. 三个患者组的默认视角 3D 散点;
2. 展示同一数据"换角度换结论"的旋转面板。

## Example

![](../images/Python/Scatter3dPlot_demo.png){fig-align="center" width="65%"}

## Setup

- 系统要求:跨平台(Linux/MacOS/Windows)
- 编程语言:**Python**
- 依赖包:`matplotlib`、`numpy`

```{python}
#| label: packages-setup
#| output: false
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (注册 3d 投影)
```

## Data Preparation

我们模拟 240 名受试者的三组(年龄, 标志物 A, 标志物 B)轮廓:应答者年龄较大、标志物居中,非应答者标志物最高,健康供者最低。分离方向经过挑选——有些视角能分开三组,有些视角会把它们压扁。

```{python}
#| label: data-prep
rng = np.random.default_rng(21)
groups = {
"Responder": (np.array([60, 25, 8]), np.array([8, 6, 2.2]),
"#1B7837"),
"Non-responder": (np.array([48, 40, 15]), np.array([9, 7, 3.0]),
"#B2182B"),
"Healthy": (np.array([35, 12, 4]), np.array([10, 4, 1.4]),
"#4C72B0"),
}
n_per = 80
data = []
for g, (mu, sd, color) in groups.items():
xyz = np.column_stack([rng.normal(m, s, n_per)
for m, s in zip(mu, sd)])
data.append((g, xyz, color))

def draw(ax, elev, azim, title):
for g, xyz, color in data:
ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], s=14, alpha=0.6,
color=color, label=g, depthshade=True)
ax.set_xlabel("Age (years)")
ax.set_ylabel("Marker A (U/mL)")
ax.set_zlabel("Marker B (ng/mL)")
ax.view_init(elev=elev, azim=azim)
ax.set_title(title, fontsize=10, pad=0)
ax.set_box_aspect((1, 1, 0.8))
```

## Visualization

### 1. 默认视角

```{python}
#| label: fig-py-3d-basic
#| fig-cap: "elev=20、azim=-60 下的三维生物标志物空间:三组占据体积中的不同区域。"
#| fig-align: center

fig = plt.figure(figsize=(7.5, 5.6))
ax = fig.add_subplot(111, projection="3d")
draw(ax, elev=20, azim=-60,
title="Three patient groups in biomarker space")
ax.legend(loc="upper left", fontsize=9)
fig.tight_layout()
plt.show()
```

### 2. 旋转面板

```{python}
#| label: fig-py-3d-views
#| fig-cap: "同一数据的两个视角:左图的组间分离在右图的侧视中不可得——深度顺序与反转的轴方向都难以判断。"
#| fig-align: center

fig = plt.figure(figsize=(11.5, 4.8))
for i, (elev, azim, title) in enumerate([
(20, -60, "View A (elev=20, azim=-60): groups look separable"),
(2, 55, "View B (elev=2, azim=55): edge-on view obscures depth")]):
ax = fig.add_subplot(1, 2, i + 1, projection="3d")
draw(ax, elev=elev, azim=azim, title=title)
fig.suptitle("One dataset, two stories: rotation changes the reading",
y=1.0)
fig.tight_layout()
plt.show()
```

::: callout-tip
## 实践要点

- 当结论依赖视角时,在图注中报告 `elev`/`azim`;否则审稿人无法复现你的分离结果。
- `alpha` + `depthshade` 只是*暗示*深度——点仍会互相覆盖。降低 `alpha`、缩小点径可减少错误的遮挡判断。
- 任何定量比较都优先用变量两两的 2D 面板(Application 面板 B);3D 视图只用来引出它们。
- 用 `ax.set_box_aspect()` 设定有物理含义的比例,否则量程 3x 的轴会在视觉上碾压量程 1x 的轴。
- 交互旋转(notebook 的 `%matplotlib widget`、plotly `Scatter3d`)用于探索;出版导出时固定所选角度。
:::

## Application

出版搭配把选定的 3D 视角(A)与最强的 2D 投影(B)并排:3D 面板引出三组结构,2D 面板承载论文统计所对应的可读、可量化的比较。

![](../images/Python/Scatter3dPlot_app1.png){fig-align="center" width="85%"}

## Reference

\[1\] Li J, Martens JB. Judging correlation from scatterplots: an evaluation of 3D visualizations. IEEE Trans Vis Comput Graph. 2010;16(6):1027-1036.

\[2\] Hunter JD. Matplotlib: a 2D graphics environment. Comput Sci Eng. 2007;9(3):90-95. doi:10.1109/MCSE.2007.55
116 changes: 116 additions & 0 deletions Python/WafflePlot.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: "Waffle Plot (Python)"
author:
- "**[Editor]** [Shixiang Wang](https://github.com/ShixiangWang);"
- "**[Contributors]** Codex (GPT-5) — AI-generated, independently AI-reviewed;"
---

The Python counterpart of [Waffle Chart](../Composition/Waffle.html) — a waffle chart replaces area-encoding pie/donut wedges with a grid of unit blocks, one block per patient (or per 1% of the cohort), so proportions are *counted* rather than angle-estimated. In clinical reporting it is the honest alternative to pies for cohort composition, RECIST response categories, and consent/enrollment breakdowns, because human readers judge counts and lengths far more accurately than angles \[1\].

`pywaffle` provides the `Waffle` figure class on top of matplotlib \[2\]; the same grid is also easy to hand-roll. The two decisions that matter: the block scale (1 block = 1 patient, or 1%) and the rounding rule when counts do not divide into the grid — state both, or the blocks silently misstate the proportions.

This tutorial covers two workflows:

1. A 10 x 10 waffle of RECIST response for one cohort;
2. Two treatment arms side by side with a shared category palette.

## Example

![](../images/Python/WafflePlot_demo.png){fig-align="center" width="60%"}

## Setup

- System Requirements: Cross-platform (Linux/MacOS/Windows)
- Programming Language: **Python**
- Dependencies: `matplotlib`, `pywaffle`

```{python}
#| label: packages-setup
#| output: false
import matplotlib.pyplot as plt
from pywaffle import Waffle
```

## Data Preparation

We use a small Phase-II-style cohort: 60 patients assessed by RECIST at week 12 — 25 partial responses, 22 stable disease, 13 progressive disease. A control arm of the same size (12/26/22) motivates the two-panel comparison.

```{python}
#| label: data-prep
treated = {"Partial response": 25, "Stable disease": 22,
"Progressive disease": 13}
control = {"Partial response": 12, "Stable disease": 26,
"Progressive disease": 22}
colors = {"Partial response": "#1B7837", "Stable disease": "#2166AC",
"Progressive disease": "#B2182B"}
assert sum(treated.values()) == sum(control.values()) == 60
```

## Visualization

### 1. Single-Cohort Waffle

```{python}
#| label: fig-py-waffle-basic
#| fig-cap: "RECIST response as a 10 x 10 grid: one block = one patient, so the 13 progressions are countable instead of encoded as a wedge angle."
#| fig-align: center

fig = plt.figure(figsize=(7, 5.6), FigureClass=Waffle,
rows=10, columns=10,
values=treated, colors=list(colors.values()),
block_aspect_ratio=1,
title={"label": "RECIST response, n = 60 patients",
"fontsize": 12},
legend={"loc": "lower center", "bbox_to_anchor": (0.5, -0.14),
"ncol": 3, "fontsize": 9, "frameon": False})
fig.tight_layout()
plt.show()
```

### 2. Two Arms Side by Side

```{python}
#| label: fig-py-waffle-arms
#| fig-cap: "Control and treatment waffles share one palette and one block scale, so the extra green blocks in the treatment arm read as a count, not an angle."
#| fig-align: center

legend_spec = {"loc": "lower center", "bbox_to_anchor": (0.5, -0.16),
"ncol": 2, "fontsize": 8, "frameon": False}
fig = plt.figure(figsize=(11, 6.2), FigureClass=Waffle,
plots={
121: {"rows": 10, "columns": 6, "values": control,
"colors": list(colors.values()),
"title": {"label": "Control (n = 60)",
"fontsize": 11},
"legend": legend_spec},
122: {"rows": 10, "columns": 6, "values": treated,
"colors": list(colors.values()),
"title": {"label": "Treatment (n = 60)",
"fontsize": 11},
"legend": legend_spec},
})
fig.suptitle("One block = 1 patient", fontsize=12, y=0.99)
plt.show()
```

::: callout-tip
## Practice tips

- Fix the block scale first: 1 block = 1 patient below ~200 subjects, 1 block = 1% (100 blocks) above — never let the library auto-scale silently between panels.
- When converting counts to percentage blocks, round explicitly and state the rule; `round()` can make blocks sum to 99 or 101.
- Keep one palette and one legend across arms so blocks, not colors, carry the comparison.
- Waffles show composition only — pair them with counts in the labels when the denominator matters.
- Ordered categories (RECIST) should keep a consistent color direction across figures; unordered ones should use colorblind-safe hues.
:::

## Application

For a large cohort, switch the block scale to 1 block = 1% and the waffle becomes a direct objective-response-rate statement: the 37 green blocks below are the ORR itself, which is why waffles work well in patient-facing summaries and trial posters.

![](../images/Python/WafflePlot_app1.png){fig-align="center" width="60%"}

## Reference

\[1\] Cleveland WS, McGill R. Graphical perception: theory, experimentation, and application to the development of graphical methods. J Am Stat Assoc. 1984;79(387):531-554. doi:10.1080/01621459.1984.10478080

\[2\] Liu G. PyWaffle: waffle charts in matplotlib. 2024. https://github.com/gyli/PyWaffle
Loading
Loading