# 数据可视化

Source: https://codewiki.com/zh/datascience/visualization/

> - **what**: 数据可视化把数据字段映射为位置、长度、颜色等视觉通道，让比较、趋势、分布和关系可以被看见。
> - **trap**: 能运行的绘图代码仍可能打乱标签与数值、跨越缺失值连线，或用不一致的尺度制造错误结论。
> - **fix**: 先写清分析问题、数据粒度和尺度契约，再选择图形；用边界数据验证映射，并检查最终尺寸下的真实产物。

## 是什么，为什么存在

数据可视化（data visualization）是从数据到图形的一套映射。记录先成为点、线、矩形等图形标记，再通过位置、长度、颜色或形状表达字段。图表的价值不在装饰，而在于让读者更快完成一次明确的比较或判断。

表格保留精确值，却不擅长让人迅速看出整体形态。图形把重复的数值比较转交给视觉系统，因此趋势、离群点、聚类和量级差异更容易暴露出来。代价是映射会省略细节，也可能放大作者没有意识到的假设。

你会在探索性分析、实验结果、运营报告、模型诊断和监控面板中遇到可视化。同一份数据可能需要两种图：分析人员用密集图形寻找异常，决策者则需要带上下文的简洁比较。受众和决定不同，合适的图也会不同。

数据可视化不是某个 Python 库的同义词。Matplotlib、Seaborn 或网页图表库负责构造和渲染图形，但不会替你判断聚合是否合理、轴是否诚实、缺失值是否被掩盖。这里关注这些跨工具的决定；具体 Matplotlib 对象和文件输出见相关主题。

### 从问题开始

画图前先把问题写成一句可以验证的话，例如“各地区本季度利润相差多少”或“延迟分布是否出现长尾”。“看看这份数据”没有指定关系，模型或人都容易随手套用熟悉模板。问题中的动词通常已经提示了比较、趋势、分布或关系。

还要写清数据契约。至少确认以下四项：

- 每一行代表什么，是否存在重复实体或重复时间点。
- 数值的单位、时间窗口和分母是什么。
- 缺失值、无穷值、被截断值和低于检测限的值怎样表示。
- 图表用于探索、静态报告还是交互界面，最终显示尺寸是多少。

粒度（grain）决定一个标记代表单次观测、每日汇总还是分组均值。如果没有声明粒度，重复键可能被绘图库悄悄重叠，自动聚合也可能把样本量差异隐藏起来。先验证记录数量和唯一键，再决定如何编码。

### 关系决定图形

图表类型首先由读者要比较的关系决定，而不是由字段数量决定。下面是可靠的起点，不是机械规则：

- 类别之间比较数值时，使用位置对齐的柱形或点图，并给类别稳定排序。
- 连续时间上的变化用折线；存在缺测时要断开线段，而不是暗示中间路径。
- 一个数值变量的形态用直方图、经验分布或箱线图，并公开分箱或摘要规则。
- 两个数值变量之间的关系用散点图；重叠严重时再考虑二维分箱或密度。
- 构成关系可用堆叠柱形；当精确比较各部分更重要时，改用普通柱形或点图。

饼图、雷达图和三维图并非永远错误，但它们经常把读者要做的长度比较变成角度、面积或透视判断。只有当这种取舍服务于具体任务时才使用。图形熟悉度不是牺牲可比性的充分理由。

## 工作原理

一张图可以拆成数据、变换、标记、通道、尺度和指南。数据提供字段；变换负责筛选、聚合或分箱；标记形成几何对象；通道把字段编码为可见属性；尺度完成领域到屏幕范围的映射；坐标轴、图例和标签帮助读者反向解释。

```mermaid
flowchart LR
    Q[Question] --> D[Validated data]
    D --> T[Filter / aggregate / bin]
    T --> M[Marks]
    M --> E[Visual channels]
    E --> S[Scales]
    S --> G[Axes / legends / labels]
    G --> R[Rendered chart]
```

这条流水线是审查图表的顺序。渲染结果异常时，不要只调颜色和边距；先回到问题与数据，再逐层检查变换和映射。上游语义错误无法由下游样式修复。

### 标记与视觉通道

视觉编码（visual encoding）是把字段值映射到图形属性的规则。点、线、矩形和区域是常见标记；视觉通道（visual channel）则包括 x、y 位置、长度、大小、颜色、形状和透明度。同一标记可以同时承载多个通道，但每增加一个通道都会增加解码负担。

位置和长度通常适合精确的数值比较，因为读者可以沿共同尺度对齐。面积和颜色明度适合表达大致量级，却不适合要求精确读数的任务。形状和离散色相适合少量无序类别，不应被当作连续数量的替代品。

通道必须与字段语义一致。无序类别不应使用让人感觉有高低次序的渐变色；连续数值不应被随机色相切成伪类别；大小编码应基于面积而不是半径直接成比例，否则圆形会夸大差异。

### 图表选择表

| 要表达的关系 | 数据形态 | 常用起点 | 必查项 |
|---|---|---|---|
| 比较 | 类别 + 数值 | 柱形图、点图 | 排序、零基线、单位 |
| 趋势 | 时间 + 数值 | 折线图 | 间隔、缺失、时区 |
| 分布 | 一个数值字段 | 直方图、箱线图 | 分箱、样本量、离群值 |
| 关系 | 两个数值字段 | 散点图 | 重叠、尺度、混杂因素 |
| 构成 | 类别 + 总量 | 堆叠柱形图 | 分母、总和、类别顺序 |

表中的“起点”有意保留余地。时间也可以用点图，类别也可能需要分布图。只要能说明读者的任务、编码选择和被放弃的信息，偏离起点完全合理。

### 尺度与基线

尺度把数据领域映射到可见范围。线性尺度保持相等差值，只有在乘法关系更重要且所有值满足定义域时才考虑对数尺度。轴标题必须说明单位；如果做过标准化、百分比化或指数化，也要把变换写出来。

柱形长度从基线开始，因此截断零基线会直接改变长度比例。折线主要表达位置和斜率，未必必须从零开始，但收窄范围会改变视觉波动，必须给出完整刻度并避免隐藏断轴。确实需要断轴时，要用清晰断裂符号，并考虑并列小图是否更容易解释。

跨面板比较要求共享尺度。让每个面板自动选择最小值和最大值，会使相同高度或颜色代表不同数值。共享领域会牺牲局部细节，却保留全局可比性；如果需要两者，可同时提供总览和局部视图。

### 颜色与无障碍

颜色映射通常包含两步：颜色归一化（colormap normalization）把领域数值映射到固定区间，colormap 再把区间位置映射为颜色。连续量使用亮度有序的顺序色板；围绕有意义中点的偏差使用发散色板；无序类别使用彼此可辨的离散颜色。

颜色不能成为传达状态或类别的唯一方式。给关键系列增加直接标签、线型、形状或图案，并在灰度与常见色觉差异下检查。背景与文字还需要足够对比度；“色盲友好”色板并不能自动保证小字号标签可读。

图例是映射说明，不是装饰。其顺序应与图中的空间顺序或数据顺序一致，标签应使用领域名称而不是列名缩写。系列很少且空间允许时，直接在标记旁标注通常能减少来回查找。

### 变换与不确定性

筛选、聚合、排序和分箱都会改变读者看到的数据。把这些步骤视为图表的一部分，并在代码和说明中保留参数。尤其不要只写“平均值”：均值按什么分组、是否加权、遗漏了多少记录，都可能改变结论。

误差线也需要定义。标准差描述观测离散程度，标准误差描述均值估计的不确定性，置信区间还依赖方法和假设。三者外观相似，含义不同；标签只写“error”无法让读者判断。

样本量不相等时，汇总标记容易显得同样可靠。可以标注每组 `n`，显示原始点或提供分布摘要。如果隐私或密度要求不允许展示原始点，就明确聚合阈值和抑制规则。

## 示例

这些示例把语义决定写成普通 Python 数据结构，渲染库可以在下一步消费它们。这样可以先测试问题、排序、缺失与尺度，再处理像素。所有输出均由 Python 3.14.3 在本地执行产生。

### 先按关系选择起点

第一个示例把分析关系和字段类型组成一个小契约。未知组合不会猜一个“漂亮”的图，而是要求补充问题。

<!-- quick -->

```python
# file: choose_chart.py
from dataclasses import dataclass


@dataclass(frozen=True)
class Question:
    relationship: str
    fields: tuple[str, ...]


def choose_chart(question: Question) -> str:
    match question.relationship, question.fields:
        case "comparison", ("category", "number"):
            return "ordered bar"
        case "trend", ("time", "number"):
            return "line with explicit gaps"
        case "distribution", ("number",):
            return "histogram"
        case "relationship", ("number", "number"):
            return "scatter"
        case _:
            return "clarify the analytical question"


questions = [
    Question("comparison", ("category", "number")),
    Question("trend", ("time", "number")),
    Question("distribution", ("number",)),
    Question("relationship", ("number", "number")),
]

for question in questions:
    print(f"{question.relationship}: {choose_chart(question)}")
```

```text
comparison: ordered bar
trend: line with explicit gaps
distribution: histogram
relationship: scatter
```

<!-- /quick -->

映射表只负责给出起点，不代表自动决定最终图形。例如，分布比较可能需要分面直方图或箱线图，而不是单个直方图。重要的是未识别的关系会显式失败，不会静默落到任意默认值。

### 排序时保留标签与数值

类别比较经常需要排序，但标签与数值必须作为同一条记录移动。这个示例还验证空输入、重复标签和非有限数值，并让数值领域包含零基线。

```python
# file: prepare_bars.py
from dataclasses import dataclass
from math import isfinite


@dataclass(frozen=True)
class Bar:
    region: str
    profit_millions: float


def prepare_bars(rows: list[Bar]) -> tuple[list[Bar], tuple[float, float]]:
    if not rows:
        raise ValueError("at least one row is required")
    if len({row.region for row in rows}) != len(rows):
        raise ValueError("region labels must be unique")
    if not all(isfinite(row.profit_millions) for row in rows):
        raise ValueError("values must be finite")

    ordered = sorted(rows, key=lambda row: row.profit_millions, reverse=True)
    values = [row.profit_millions for row in ordered]
    domain = (min(0.0, min(values)), max(0.0, max(values)))
    return ordered, domain


rows = [
    Bar("North", 8.1),
    Bar("West", -2.5),
    Bar("South", 5.4),
    Bar("East", 14.2),
]
ordered, domain = prepare_bars(rows)
print("order:", [row.region for row in ordered])
print("values:", [row.profit_millions for row in ordered])
print("domain:", domain, "baseline: 0.0")
```

```text
order: ['East', 'North', 'South', 'West']
values: [14.2, 8.1, 5.4, -2.5]
domain: (-2.5, 14.2) baseline: 0.0
```

排序后的地区和利润仍逐行对应。因为利润包含负值，领域从 `-2.5` 延伸到 `14.2`，零点位于中间。渲染时正负柱形应从同一个零基线向相反方向延伸。

这里没有人为增加留白。实际渲染器可以增加视觉边距，但数据领域与显示领域应分别命名，以免边距逻辑改变基线。测试应检查零点仍在最终尺度内。

### 缺失观测会断开折线

直接删除缺失行后连接剩余点，会画出一段从缺失日期上方穿过的线。下面的预处理把连续观测拆成独立线段，同时单独保留缺失日期。

```python
# file: break_missing_lines.py
from collections.abc import Iterable


Point = tuple[str, float | None]


def contiguous_segments(points: Iterable[Point]) -> list[list[tuple[str, float]]]:
    segments: list[list[tuple[str, float]]] = []
    current: list[tuple[str, float]] = []

    for day, value in points:
        if value is None:
            if current:
                segments.append(current)
                current = []
            continue
        current.append((day, value))

    if current:
        segments.append(current)
    return segments


observations = [
    ("2026-08-01", 18.2),
    ("2026-08-02", 18.7),
    ("2026-08-03", None),
    ("2026-08-04", 21.1),
    ("2026-08-05", 20.6),
]

for index, segment in enumerate(contiguous_segments(observations), start=1):
    print(f"segment {index}: {segment[0][0]} -> {segment[-1][0]} ({len(segment)} points)")
print("missing:", [day for day, value in observations if value is None])
```

```text
segment 1: 2026-08-01 -> 2026-08-02 (2 points)
segment 2: 2026-08-04 -> 2026-08-05 (2 points)
missing: ['2026-08-03']
```

两个线段不会跨过 `2026-08-03`。真实系统还应区分“未采集”“不适用”和“采集失败”，因为三种状态可能需要不同标记。把它们都变成零会制造不存在的观测。

函数假设输入已按时间排序。生产边界应先解析时间、验证唯一性并明确排序；否则同一天重复值或乱序值会产生看似合理但顺序错误的折线。

### 多面板共享颜色领域

如果两个面板各自自动缩放，同一个 `32` 可能得到不同颜色。下面先从全部可比较数据求共享领域，再计算统一的归一化位置。

```python
# file: shared_color_scale.py
from math import isfinite


def normalize(value: float, low: float, high: float) -> float:
    if not all(isfinite(number) for number in (value, low, high)):
        raise ValueError("scale values must be finite")
    if low >= high:
        raise ValueError("scale requires low < high")
    return (value - low) / (high - low)


panels = {
    "control": [18.0, 25.0, 32.0],
    "candidate": [22.0, 32.0, 40.0],
}
all_values = [value for values in panels.values() for value in values]
shared_domain = (min(all_values), max(all_values))

print(f"shared domain: {shared_domain[0]:.0f}..{shared_domain[1]:.0f}")
for panel, values in panels.items():
    encoded = ", ".join(
        f"{value:.0f}->{normalize(value, *shared_domain):.2f}"
        for value in values
    )
    print(f"{panel}: {encoded}")
```

```text
shared domain: 18..40
control: 18->0.00, 25->0.32, 32->0.64
candidate: 22->0.18, 32->0.64, 40->1.00
```

两个面板中的 `32` 都映射到 `0.64`。渲染器应让它们共享 colormap，并配一根带单位的公共颜色条。若阈值来自业务规则，应优先使用固定领域，而不是让每批数据重新定义颜色含义。

当全部值相等时，`low >= high` 会显式失败。调用方可以为常量数据指定领域或改用单色并直接标注数值，但不应让除零产生的无效颜色悄悄进入图表。

## 陷阱

> **陷阱:** 分别排序标签和数值会破坏记录对应关系，柱形看起来仍然整齐，却把一个类别的值放到另一个类别名下。
>
> **修复：** 始终排序完整记录或索引，并用唯一类别与可手算的小样本检查首尾标记。

> **陷阱:** 自动尺度会让每个小图看起来都充满画布，相同高度或颜色却可能代表不同数值。
>
> **修复：** 对需要比较的面板共享数值领域、刻度和颜色归一化；局部视图另行标记范围。

> **陷阱:** 删除 `None` 或 `NaN` 后直接连线，会暗示缺测区间内存在连续观测；把缺失值填成零则会制造骤降。
>
> **修复：** 保留缺失状态并断开线段；只有领域规则支持插值时才填补，同时标明方法和区间。

> **陷阱:** 只用红色与绿色表达成功和失败，会让部分读者无法区分，也会在灰度打印时丢失含义。
>
> **修复：** 同时使用文字标签、形状、线型或图案，并在最终尺寸、灰度和键盘操作路径下检查产物。

> **陷阱:** 均值柱形没有样本量或离散程度时，会把稀少而不稳定的数据表现得与大样本同样确定。
>
> **修复：** 标注 `n`，按任务显示原始点、分布或定义明确的不确定性区间，并公开聚合规则。

<!-- deep -->

## 图形无法还原的内容

图表是一种有损表示。筛选掉的记录、聚合前的组内差异、分箱前的精确位置和被裁剪的极端值，都无法从最终像素恢复。可靠流程因此需要把图表规格、变换参数和数据版本放在产物之外保存。

### 聚合改变估计对象

按用户平均和按请求平均回答的是不同问题。前者让每个用户权重相同，后者让请求更多的用户权重更大。两种计算都可能正确，但轴标签只写“平均延迟”无法说明估计对象。

先聚合再取平均还可能产生未加权的组均值平均。若组大小不同，它通常不等于全体记录均值。审查生成代码时，要把分子、分母和权重写成显式公式，并用大小不同的两个组做手算。

时间聚合还依赖窗口边界、时区和迟到数据策略。日线在 UTC 与业务本地时区中可能分到不同日期。比较两个版本时必须共享这些规则，否则看见的变化可能只是分桶变化。

### 分箱会制造边界

直方图把连续值映射到离散区间。箱宽太大会隐藏多峰或间隙，太小则让随机波动显得像结构。不要声称存在某种形态，除非它在合理的多个箱宽下仍然可见，或有领域阈值支持该边界。

箱边界还规定落在边界上的值属于哪一箱。不同工具可能使用左闭右开、右闭或特殊末箱规则。需要复现实验时，应保存完整边界数组，而不只是“使用 20 个箱”。

二维分箱和密度图可以缓解散点重叠，却会进一步隐藏单条记录。颜色条要说明计数、比例还是变换后的密度；使用对数颜色尺度时，也要说明零计数怎样处理。

### 尺度决定可见差异

线性尺度保留差值比例，对数尺度保留比值关系。把增长过程放在对数轴上可能更容易比较相对增速，但读者不能再把相同屏幕距离理解为相同绝对差值。标题、刻度和说明必须让变换可见。

双 y 轴把两套独立尺度叠在同一空间，几乎可以通过改变范围制造任意视觉相关性。优先使用共享 x 轴的上下小图、指数化后的单尺度，或直接展示业务上有意义的差值。确实使用双轴时，要固定领域并清楚标色两套轴。

裁剪极端值能让主体更清楚，但必须留下痕迹。可以用溢出标记、单独面板或文字计数说明范围外记录。默默丢弃会同时改变分布形态和样本量。

### 不确定性不是装饰带

一条带状区域可能表示观测范围、四分位区间、模型预测区间或均值置信区间。它们回答的问题不同。图例应写出区间名称、水平和计算单位，例如按用户重采样，而不是只写“95%”。

重复测量或时间相关数据不能随意当作独立样本。生成代码经常调用默认置信区间 API，却没有确认抽样单位。若推断方法不在本主题范围内，至少应展示原始分布、样本量，并避免给出未经验证的区间。

误差条遮住或超出绘图区时，也不能靠调透明度解决。先检查上下界是否与点估计使用同一单位和变换，再决定轴范围。对数轴上的对称数据误差，渲染后通常不会保持视觉对称。

### 最终产物才是接口

Notebook 中的大图缩到报告栏宽后，刻度和直接标签可能重叠。交互工具提示在静态导出、屏幕阅读器或打印件中也可能消失。验收对象应是最终 PNG、SVG、PDF 或网页状态，而不是绘图函数没有抛出异常。

无障碍替代内容需要传达图表的目的与主要结论，同时提供数据表或下载路径以支持精确读取。替代文本不必逐点复述全部数值，但不能只写“折线图”。交互图还要保证键盘可达、焦点可见，并让状态变化不只依赖颜色。

可复现性要求图表变换与分析使用同一数据快照。若报表查询和数字摘要分别运行，迟到数据可能让标题数字与图形不一致。先生成一个经过验证的中间表，再让摘要与图表共同消费它。

<!-- /deep -->

[检查点: datascience/visualization](https://codewiki.com/zh/datascience/visualization/#checkpoint)

## 延伸阅读

- [Vega-Lite：编码](https://vega.github.io/vega-lite/docs/encoding.html)
- [Vega-Lite：标记](https://vega.github.io/vega-lite/docs/mark.html)
- [Matplotlib：选择 colormap](https://matplotlib.org/stable/users/explain/colors/colormaps.html)
- [Matplotlib：断轴示例](https://matplotlib.org/stable/gallery/subplots_axes_and_figures/broken_axis.html)
- [WCAG 2.2：颜色的使用](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)
