Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第三章:基础图表类型

前置知识:建议你先完成第一章第二章,尤其要理解 figureColumnDataSourceHoverTool 的基本用法。
延伸阅读:本章里用到的 dodge()factor_cmap()linear_cmap()cumsum() 等转换工具,会在第四章中系统展开;多个图表如何组合,请见第五章;交互工具的详细配置见第六章

这一章回答一个最实际的问题:

面对一组数据,你应该先画什么图?

很多初学者学习可视化时,会把注意力集中在“Bokeh 有哪些 API”。
但真正重要的是先想清楚:

  • 你想看的是趋势比较分布,还是关系
  • 你的数据是连续变量类别数据,还是空间数据
  • 你是想做快速探索,还是要做可以交付的图表

Bokeh 的优势不在“图表种类最多”,而在于:

  • 你可以直接用 Python 组织数据
  • 图表天然支持缩放、平移、悬停
  • 后续可以平滑升级到联动、布局、嵌入网页、Bokeh Server

所以本章不会把所有图形都堆给你,而是围绕常见需求,给出:

  1. 图表选择建议
  2. 当前 Bokeh 3.x 仍然稳妥的写法
  3. 什么时候该用、什么时候不该用
  4. 容易踩坑的地方

图表选择指南

图表类型决策树

先看一个实用版决策树:

你要回答什么问题?
│
├─ 看两个变量之间有没有关系
│  └─ 散点图(3.1)
│
├─ 看一个指标如何随时间或顺序变化
│  ├─ 折线图(3.2)
│  └─ 阶梯图(3.2)
│
├─ 比较几个类别谁大谁小
│  ├─ 柱状图(3.3)
│  └─ 分组柱状图 / 堆叠柱状图(3.3)
│
├─ 看数值分布
│  ├─ 直方图(3.4)
│  ├─ 箱线图(3.8)
│  └─ 误差条图(3.9)
│
├─ 看整体如何构成
│  ├─ 堆叠柱状图(3.3)
│  ├─ 面积图 / 堆叠面积图(3.6)
│  └─ 饼图 / 环形图(3.5,谨慎使用)
│
├─ 看二维矩阵或强度分布
│  ├─ 热力图(3.7)
│  └─ 等高线图(3.10)
│
├─ 看地理空间位置
│  └─ 地理数据可视化(3.11)
│
└─ 看节点与连接关系
   └─ 网络图(3.12)

按分析目的选择

分析目的推荐图表原因
看相关性散点图最直观呈现两个变量关系
看趋势折线图时间序列最常用
看类别比较柱状图长度比较最准确
看分布形态直方图、箱线图分别适合“频数分布”和“统计摘要”
看构成变化堆叠柱状图、堆叠面积图能同时看总量与构成
看局部高低区域热力图、等高线图适合二维强度场
看空间位置地图经纬度/地理对象不能硬塞进普通坐标图
看连接结构网络图突出节点和边

大数据量图表选择建议

可视化不是“数据越多越好”,而是“让模式更容易被看见”。

数据量级更稳妥的图表建议
数十到数百散点图、柱状图、折线图直接画即可
数千到数万散点图、折线图、热力图优先简化样式、减少透明叠加
数万到数十万热力图、聚合图、降采样折线不要迷信点点点全部都画出来
更大规模Datashader / 预聚合 / 服务端处理超出基础教程范畴

经验法则

  • 数据点太多时,先考虑聚合,再考虑美化
  • 类别太多时,先考虑排序、筛选、分组
  • 一个图解决不了的问题,往往应该拆成多个图联动

图表组合建议

实际工作里,下面几种组合非常常见:

目标推荐组合
先看趋势,再看异常点折线图 + 散点图
先看总体分布,再看分组差异直方图 + 箱线图
同时看总量和构成堆叠柱状图 + 明细表
看空间位置和属性地图 + HoverTool
看关系和聚类散点图 + 颜色/大小编码

常见错误

错误 1:类别比较却用饼图

饼图不是不能用,但它不适合精确比较多个接近的比例。
只要类别一多,读者就很难比较扇区面积和角度。

更稳妥的选择通常是:

  • 少量类别:柱状图
  • 看构成变化:堆叠柱状图
  • 看占比同时要排序:水平柱状图

错误 2:时间序列却用柱状图硬画

如果数据本质上有连续顺序,尤其是时间,优先考虑:

  • 折线图
  • 阶梯图
  • 面积图

柱状图更适合离散类别比较。

错误 3:点太多还坚持画散点图

当散点已经严重重叠时,你看到的不是“更多信息”,而是“更厚的墨水”。
这时应该考虑:

  • 降低透明度
  • 改成热力图 / 六边形分箱
  • 做抽样或聚合

错误 4:把“漂亮”放在“准确”前面

颜色、透明度、渐变、阴影都只是辅助。
可视化优先级永远是:

  1. 数据表达正确
  2. 结构清晰
  3. 交互有帮助
  4. 最后再谈视觉风格

3.1 散点图:从简单到复杂

散点图最适合回答:

  • 两个变量有没有关系?
  • 有没有离群点?
  • 有没有聚类?
  • 某个第三变量能不能用颜色或大小编码出来?

最简单的散点图

在 Bokeh 3.x 里,推荐优先使用 scatter()
它可以统一处理不同 marker 类型,写法也更一致。

from bokeh.plotting import figure, show

x = [1, 2, 3, 4, 5]
y = [4, 7, 6, 9, 13]

p = figure(
    title="基础散点图",
    width=600,
    height=400,
    tools="pan,wheel_zoom,box_zoom,reset,save"
)

p.scatter(x, y, size=12, marker="circle", alpha=0.7)

show(p)

使用 ColumnDataSource 和悬停提示

一旦你希望加标签、颜色映射、工具提示,就应该尽早切到 ColumnDataSource

from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "x": [1, 2, 3, 4, 5],
    "y": [4, 7, 6, 9, 13],
    "name": ["A", "B", "C", "D", "E"],
    "group": ["g1", "g1", "g2", "g2", "g2"],
})

p = figure(title="带悬停信息的散点图", width=600, height=400)
p.scatter("x", "y", source=source, size=12, marker="circle", alpha=0.8)

hover = HoverTool(tooltips=[
    ("名称", "@name"),
    ("x", "@x"),
    ("y", "@y"),
    ("分组", "@group"),
])
p.add_tools(hover)

show(p)

用颜色和大小表达更多维度

散点图非常适合做“二维位置 + 第三维颜色 + 第四维大小”的表达。

from bokeh.models import ColumnDataSource, ColorBar
from bokeh.palettes import Viridis256
from bokeh.plotting import figure, show
from bokeh.transform import linear_cmap

source = ColumnDataSource(data={
    "x": [1, 2, 3, 4, 5],
    "y": [2, 5, 8, 4, 7],
    "value": [10, 30, 50, 70, 90],
    "size": [8, 12, 16, 20, 24],
})

mapper = linear_cmap(
    field_name="value",
    palette=Viridis256,
    low=10,
    high=90,
)

p = figure(title="颜色和大小编码", width=600, height=400)
r = p.scatter(
    "x", "y",
    source=source,
    size="size",
    marker="circle",
    fill_color=mapper,
    line_color="black",
    alpha=0.8,
)

color_bar = ColorBar(color_mapper=mapper["transform"], width=8)
p.add_layout(color_bar, "right")

show(p)

不同 marker 的选择

scatter() 的一个优势,是可以通过 marker 参数切换标记类型。

from bokeh.plotting import figure, show

p = figure(title="不同 marker", width=700, height=300, x_range=(0, 6), y_range=(0, 2))

markers = ["circle", "square", "triangle", "diamond", "hex"]
for i, marker in enumerate(markers, start=1):
    p.scatter([i], [1], size=18, marker=marker, legend_label=marker)

p.legend.location = "top_left"
show(p)

什么时候不用散点图

以下情况不建议直接用散点图:

  • 点非常多,已经重叠成一团
  • 一个轴其实是类别轴,不是真正的连续变量
  • 你真正想展示的是分布,不是个体点

这时更好的替代是:

  • 热力图
  • 抖动图(jitter)
  • 箱线图
  • 直方图

实战建议

  • 探索性分析时:先画散点图
  • 点数上万时:先降低透明度,再考虑聚合
  • 想强调个别点:单独叠加第二层 glyph
  • 想要可读 tooltip:尽量把字段放进 ColumnDataSource

3.2 折线图与多系列

折线图最适合表现有顺序的数据,尤其是:

  • 时间序列
  • 序列位置
  • 累积过程
  • 连续变化趋势

单条折线

from bokeh.plotting import figure, show

days = [1, 2, 3, 4, 5, 6, 7]
sales = [120, 132, 128, 145, 160, 158, 172]

p = figure(title="每日销售趋势", width=700, height=400)
p.line(days, sales, line_width=3, color="#1f77b4")
p.scatter(days, sales, size=8, marker="circle", color="#1f77b4")

show(p)

一个常见做法是:

  • line() 表示整体趋势
  • 再叠加一层 scatter() 强调每个观测点

阶梯图

当数据不是连续变化,而是“状态在相邻时段保持不变,到某个点才跳变”时,step() 往往比 line() 更准确。

from bokeh.plotting import figure, show

x = [1, 2, 3, 4, 5]
y = [10, 10, 15, 15, 20]

p = figure(title="阶梯图", width=600, height=350)
p.step(x, y, mode="after", line_width=3)

show(p)

mode 可选:

  • "before"
  • "after"
  • "center"

多条折线

如果系列数量不多,最直接的方法是连续调用多次 line()

from bokeh.plotting import figure, show

days = [1, 2, 3, 4, 5]
product_a = [100, 120, 110, 135, 150]
product_b = [90, 105, 130, 128, 142]

p = figure(title="多系列折线图", width=700, height=400)

p.line(days, product_a, line_width=3, color="#1f77b4", legend_label="产品 A")
p.line(days, product_b, line_width=3, color="#ff7f0e", legend_label="产品 B")

p.scatter(days, product_a, size=7, color="#1f77b4", marker="circle")
p.scatter(days, product_b, size=7, color="#ff7f0e", marker="square")

p.legend.location = "top_left"
p.legend.click_policy = "hide"

show(p)

click_policy = "hide" 是一个非常实用的交互细节:
当系列较多时,用户可以临时隐藏某一条线。

一次性绘制多条线:multi_line()

如果你的数据天然是“多条线构成的集合”,multi_line() 更合适。
但它的数据结构会更特别:需要“列表的列表”。

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "xs": [[1, 2, 3, 4], [1, 2, 3, 4]],
    "ys": [[2, 4, 3, 5], [1, 3, 2, 4]],
    "color": ["#1f77b4", "#ff7f0e"],
})

p = figure(title="multi_line 示例", width=650, height=350)
p.multi_line(xs="xs", ys="ys", line_color="color", line_width=3, source=source)

show(p)

适合场景:

  • 一批结构一致的轨迹
  • 批量绘制很多条线
  • 和数据管道天然对应

缺失值的处理

Bokeh 对折线图的一个细节很重要:

  • 如果要制造“断线”,应使用 NaN
  • 不要依赖 None 来表达缺失段

在很多分析场景里,这能帮助你明确区分:

  • 真实值为 0
  • 数据缺失
  • 应该断开绘制

什么时候不用折线图

不适合用折线图的情况包括:

  • x 轴没有真实顺序,只是一组类别
  • 你想突出每一类的独立比较,而不是连续变化
  • 系列太多,线条缠在一起难以辨认

替代方案:

  • 柱状图
  • 小多图(small multiples)
  • 热力图
  • 交互筛选

3.3 柱状图

柱状图最适合做类别比较
它的核心优势不是“好看”,而是人眼最擅长比较长度

基础柱状图

from bokeh.plotting import figure, show

fruits = ["苹果", "梨", "香蕉", "葡萄"]
counts = [12, 18, 9, 15]

p = figure(x_range=fruits, title="基础柱状图", width=600, height=400)
p.vbar(x=fruits, top=counts, width=0.7)

show(p)

注意这里的 x_range=fruits

  • 这会创建一个类别轴
  • 本质上等价于使用 FactorRange

排序比默认顺序更重要

很多柱状图一旦排序,信息量会立刻上升。

from bokeh.plotting import figure, show

data = [("苹果", 12), ("梨", 18), ("香蕉", 9), ("葡萄", 15)]
data = sorted(data, key=lambda item: item[1], reverse=True)

fruits = [item[0] for item in data]
counts = [item[1] for item in data]

p = figure(x_range=fruits, title="按数值排序的柱状图", width=600, height=400)
p.vbar(x=fruits, top=counts, width=0.7)

show(p)

分组柱状图:dodge()

当你想比较“同一类别下的多个系列”时,可以用 dodge() 做视觉偏移。

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.transform import dodge

quarters = ["Q1", "Q2", "Q3", "Q4"]
source = ColumnDataSource(data={
    "quarter": quarters,
    "2023": [120, 135, 150, 170],
    "2024": [130, 145, 162, 182],
})

p = figure(x_range=quarters, title="分组柱状图", width=700, height=400)

p.vbar(x=dodge("quarter", -0.2, range=p.x_range), top="2023", width=0.35,
       source=source, color="#1f77b4", legend_label="2023")

p.vbar(x=dodge("quarter", 0.2, range=p.x_range), top="2024", width=0.35,
       source=source, color="#ff7f0e", legend_label="2024")

p.legend.location = "top_left"
show(p)

堆叠柱状图:vbar_stack()

如果你除了比较总量,还想看构成,可以用堆叠柱状图。

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "month": ["1月", "2月", "3月"],
    "线上": [30, 45, 50],
    "门店": [20, 25, 35],
    "代理": [10, 15, 12],
})

categories = ["线上", "门店", "代理"]
colors = ["#1f77b4", "#ff7f0e", "#2ca02c"]

p = figure(x_range=source.data["month"], title="堆叠柱状图", width=700, height=400)
p.vbar_stack(categories, x="month", width=0.7, color=colors, source=source, legend_label=categories)

p.legend.location = "top_left"
p.legend.click_policy = "hide"

show(p)

水平柱状图

当类别名很长、数量较多时,水平柱状图通常更易读。

from bokeh.plotting import figure, show

categories = ["华东大区", "华南大区", "华北大区", "西南大区"]
values = [82, 76, 69, 58]

p = figure(y_range=categories, title="水平柱状图", width=700, height=350)
p.hbar(y=categories, right=values, height=0.6)

show(p)

何时用堆叠,何时用分组

这是一个特别常见的问题:

  • 想精确比较每个系列:用分组柱状图
  • 想看总量 + 构成:用堆叠柱状图

如果两个目标都很强,最稳妥的做法不是硬塞在一张图里,而是拆成两张图。

柱状图的常见建议

  • 类别很多时,优先排序
  • 标签过长时,优先水平布局
  • 类别超过十几个时,考虑分页、筛选或分组
  • 不要在柱状图里用太多颜色,除非颜色本身有数据含义

3.4 直方图

直方图回答的问题是:

  • 数据集中在哪些区间?
  • 分布是偏左、偏右,还是接近对称?
  • 有没有长尾、双峰、多峰?

它和柱状图最大的不同是:

  • 柱状图比较类别
  • 直方图比较数值区间的频数或密度

基础直方图

Bokeh 官方推荐的思路是:

  1. 先用 numpy.histogram() 计算分箱
  2. 再用 quad() 画出来
import numpy as np
from bokeh.plotting import figure, show

data = np.random.normal(loc=0, scale=1, size=1000)
hist, edges = np.histogram(data, bins=20)

p = figure(title="基础直方图", width=700, height=400)
p.quad(
    top=hist,
    bottom=0,
    left=edges[:-1],
    right=edges[1:],
    fill_color="#4e79a7",
    line_color="white",
    alpha=0.8,
)

show(p)

叠加概率密度曲线

如果你想让读者同时看到“分箱频数”和“整体形态”,可以在直方图上叠加一条密度曲线。

import numpy as np
from scipy.stats import gaussian_kde
from bokeh.plotting import figure, show

data = np.random.normal(loc=0, scale=1, size=1000)

hist, edges = np.histogram(data, bins=30, density=True)
x = np.linspace(data.min(), data.max(), 300)
kde = gaussian_kde(data)

p = figure(title="直方图 + KDE", width=700, height=400)
p.quad(
    top=hist,
    bottom=0,
    left=edges[:-1],
    right=edges[1:],
    fill_alpha=0.5,
    fill_color="skyblue",
    line_color="white",
)
p.line(x, kde(x), line_width=3, color="firebrick")

show(p)

如何选 bins

很多“看起来奇怪的直方图”,问题不在数据,而在分箱。

经验上可以这样记:

  • bins 太少:信息被抹平
  • bins 太多:噪声被放大
  • 初步探索时:先试 2040
  • 对比多个图时:尽量保持一致的分箱规则

什么时候不用直方图

不建议用直方图的情况:

  • 样本量太少
  • 你更关心分组差异,而不是总体分布
  • 你想强调中位数、四分位数、异常值

替代方案:

  • 箱线图
  • 小提琴图(Bokeh 基础教程不展开)
  • KDE 曲线
  • 分组密度图

3.5 饼图与环形图

这一节先讲一个很重要的事实:

Bokeh 没有内置的 pie / donut 专用 API。
当前官方文档的做法,是使用:

  • wedge() 画饼图
  • annular_wedge() 画环形图

饼图的基础写法

import math
import pandas as pd
from bokeh.models import ColumnDataSource
from bokeh.palettes import Category20c
from bokeh.plotting import figure, show
from bokeh.transform import cumsum

data = pd.Series({
    "A类": 35,
    "B类": 25,
    "C类": 20,
    "D类": 15,
    "E类": 5,
}).reset_index(name="value").rename(columns={"index": "category"})

data["angle"] = data["value"] / data["value"].sum() * 2 * math.pi
data["color"] = Category20c[len(data)]

source = ColumnDataSource(data)

p = figure(title="饼图", width=500, height=400, toolbar_location=None, tools="hover",
           tooltips="@category: @value", x_range=(-1.2, 1.2))

p.wedge(
    x=0, y=1, radius=0.8,
    start_angle=cumsum("angle", include_zero=True),
    end_angle=cumsum("angle"),
    line_color="white",
    fill_color="color",
    legend_field="category",
    source=source,
)

p.axis.visible = False
p.grid.grid_line_color = None

show(p)

环形图的写法

import math
import pandas as pd
from bokeh.models import ColumnDataSource
from bokeh.palettes import Category20c
from bokeh.plotting import figure, show
from bokeh.transform import cumsum

data = pd.Series({
    "搜索": 45,
    "直访": 20,
    "广告": 18,
    "社媒": 12,
    "其他": 5,
}).reset_index(name="value").rename(columns={"index": "channel"})

data["angle"] = data["value"] / data["value"].sum() * 2 * math.pi
data["color"] = Category20c[len(data)]

source = ColumnDataSource(data)

p = figure(title="环形图", width=500, height=400, toolbar_location=None, tools="hover",
           tooltips="@channel: @value", x_range=(-1.2, 1.2))

p.annular_wedge(
    x=0, y=1,
    inner_radius=0.35,
    outer_radius=0.7,
    start_angle=cumsum("angle", include_zero=True),
    end_angle=cumsum("angle"),
    fill_color="color",
    line_color="white",
    legend_field="channel",
    source=source,
)

p.axis.visible = False
p.grid.grid_line_color = None

show(p)

什么时候可以用饼图

饼图不是绝对不能用,但要克制:

可以考虑用的情况

  • 类别很少,通常不超过 5 个
  • 你只是想表达“整体由几部分组成”
  • 不追求精确比较

不建议用的情况

  • 类别较多
  • 很多比例接近
  • 还想同时比较多个时间点或多个组别

在这些情况下,更推荐:

  • 排序柱状图
  • 堆叠柱状图
  • 100% 堆叠柱状图

3.6 面积图

面积图常用于表达:

  • 随时间变化的累计量
  • 一段区间内的变化范围
  • 多个组成部分的总量变化

基础面积图:varea()

from bokeh.plotting import figure, show

x = [1, 2, 3, 4, 5]
y = [3, 5, 4, 7, 6]

p = figure(title="基础面积图", width=700, height=350)
p.varea(x=x, y1=0, y2=y, fill_alpha=0.5, color="#1f77b4")
p.line(x, y, line_width=2, color="#1f77b4")

show(p)

这里本质上是填充了:

  • 下边界 y1=0
  • 上边界 y2=y

区间带状图

面积图还有一个很实用的用途:表示上下界之间的区间。

from bokeh.plotting import figure, show

x = [1, 2, 3, 4, 5]
lower = [10, 12, 11, 14, 15]
upper = [14, 16, 15, 18, 20]
mean = [12, 14, 13, 16, 18]

p = figure(title="区间带状图", width=700, height=350)
p.varea(x=x, y1=lower, y2=upper, fill_alpha=0.3, color="gray")
p.line(x, mean, line_width=3, color="firebrick")

show(p)

这种形式常见于:

  • 置信区间
  • 预测区间
  • 误差带

堆叠面积图:varea_stack()

如果你想看多个部分如何组成总量,可以用堆叠面积图。

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "x": [1, 2, 3, 4, 5],
    "A": [2, 3, 4, 3, 5],
    "B": [1, 2, 2, 3, 2],
    "C": [3, 2, 1, 2, 3],
})

stackers = ["A", "B", "C"]
colors = ["#4e79a7", "#f28e2b", "#59a14f"]

p = figure(title="堆叠面积图", width=700, height=350)
p.varea_stack(stackers, x="x", color=colors, alpha=0.7, legend_label=stackers, source=source)

p.legend.location = "top_left"
show(p)

面积图的注意事项

面积图很容易“看起来丰富”,但也很容易误导。

适合用面积图的情况:

  • 你关心总量变化
  • 数据有连续顺序
  • 系列数量不多

不适合的情况:

  • 只是比较独立类别
  • 系列很多,导致叠加难以分辨
  • 你真正关心的是每条线的精确比较

这种时候,折线图通常更清晰。


3.7 热力图

热力图适合表达:

  • 二维矩阵中的强弱关系
  • 分类 × 分类 的交叉统计
  • 空间格点中的数值高低

先说一个实用判断:

  • 如果你的数据是“分类 × 分类”,通常用 rect()
  • 如果你的数据本质是二维数组/栅格,通常用 image()
  • 这一章先讲最常见的分类热力图

分类热力图:rect() + 颜色映射

from bokeh.models import ColumnDataSource, ColorBar
from bokeh.palettes import Viridis256
from bokeh.plotting import figure, show
from bokeh.transform import linear_cmap

days = ["周一", "周二", "周三", "周四", "周五"]
hours = ["9点", "10点", "11点", "12点"]

source = ColumnDataSource(data={
    "day": ["周一", "周二", "周三", "周四", "周五",
            "周一", "周二", "周三", "周四", "周五",
            "周一", "周二", "周三", "周四", "周五",
            "周一", "周二", "周三", "周四", "周五"],
    "hour": ["9点"] * 5 + ["10点"] * 5 + ["11点"] * 5 + ["12点"] * 5,
    "value": [12, 18, 15, 10, 8, 22, 26, 24, 18, 14, 30, 35, 28, 25, 20, 16, 20, 18, 15, 12],
})

mapper = linear_cmap("value", Viridis256, low=8, high=35)

p = figure(
    title="分类热力图",
    x_range=days,
    y_range=list(reversed(hours)),
    width=700,
    height=350,
    toolbar_location="above",
)

p.rect(
    x="day",
    y="hour",
    width=1,
    height=1,
    source=source,
    line_color="white",
    fill_color=mapper,
)

color_bar = ColorBar(color_mapper=mapper["transform"], width=8)
p.add_layout(color_bar, "right")

show(p)

为什么热力图适合“密集概览”

热力图的优点是:

  • 适合同时展示很多格子
  • 对模式识别友好
  • 非常适合做概览和异常定位

例如上面的图,你很容易一眼看出:

  • 哪个时间段整体更高
  • 哪一列或哪一行明显偏高
  • 是否存在局部极值

何时改用 image()

如果你的数据是规则二维数组,比如:

  • 温度场
  • 地形高度
  • 仿真结果矩阵
  • 图像像素数据

那么更适合使用 image(),因为那是官方针对栅格数据的图像 glyph。

但如果你的目标是“分类交叉表”,rect() 更直观,也更容易加 tooltip。

热力图的注意事项

  • 颜色映射要有明确的上下界
  • 不要同时叠加太多文本标签
  • 颜色过于花哨会削弱模式识别
  • 想表达精确值时,热力图应配合 hover,而不是在每个格子里塞数字

3.8 箱线图

箱线图适合快速比较不同组的分布特征:

  • 中位数
  • 四分位数
  • 须线范围
  • 异常值

Bokeh 没有“一键箱线图”高层接口,但官方推荐的做法很明确:

  • vbar() 画箱体
  • Whisker 画须线
  • scatter() 画异常值

一个基础箱线图示例

import pandas as pd
from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure, show

df = pd.DataFrame({
    "group": ["A"] * 8 + ["B"] * 8,
    "value": [5, 7, 8, 8, 9, 10, 12, 20, 4, 5, 6, 6, 7, 8, 9, 14],
})

groups = []
q1s = []
q2s = []
q3s = []
lowers = []
uppers = []
outlier_x = []
outlier_y = []

for group, sub in df.groupby("group"):
    q1 = sub["value"].quantile(0.25)
    q2 = sub["value"].quantile(0.50)
    q3 = sub["value"].quantile(0.75)
    iqr = q3 - q1

    lower = max(sub["value"].min(), q1 - 1.5 * iqr)
    upper = min(sub["value"].max(), q3 + 1.5 * iqr)

    groups.append(group)
    q1s.append(q1)
    q2s.append(q2)
    q3s.append(q3)
    lowers.append(lower)
    uppers.append(upper)

    outliers = sub[(sub["value"] < lower) | (sub["value"] > upper)]["value"]
    outlier_x.extend([group] * len(outliers))
    outlier_y.extend(outliers.tolist())

source = ColumnDataSource(data={
    "group": groups,
    "q1": q1s,
    "q2": q2s,
    "q3": q3s,
    "lower": lowers,
    "upper": uppers,
})

p = figure(x_range=groups, title="箱线图", width=650, height=400)

# 箱体
p.vbar(x="group", width=0.6, top="q3", bottom="q2", source=source, fill_color="#a6cee3", line_color="black")
p.vbar(x="group", width=0.6, top="q2", bottom="q1", source=source, fill_color="#1f78b4", line_color="black")

# 中位数线
p.segment(x0=groups, y0=q2s, x1=groups, y1=q2s, line_width=3, color="black")

# 须线
whisker = Whisker(source=source, base="group", upper="upper", lower="lower")
p.add_layout(whisker)

# 异常值
p.scatter(outlier_x, outlier_y, marker="circle", size=8, color="firebrick", alpha=0.8)

show(p)

箱线图适合什么问题

它最适合回答:

  • 哪个组的中位数更高?
  • 哪个组波动更大?
  • 哪个组异常值更多?
  • 不同组的分布是否偏斜?

箱线图不擅长什么

箱线图是统计摘要,不展示原始形状细节。
它不适合回答:

  • 分布是否双峰?
  • 两组密度曲线形状是否不同?
  • 个体点的聚集模式如何?

这时可以考虑:

  • 直方图
  • KDE
  • 抖动散点
  • SinaPlot / beeswarm(本教程不展开)

3.9 误差条图

误差条图常见于:

  • 实验结果展示
  • 估计值 + 不确定性
  • 平均值 + 标准误 / 标准差 / 置信区间

在 Bokeh 中,最方便的做法是:

  • line()scatter()vbar() 画主体
  • Whisker 添加误差条

误差条基础示例

from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "x": [1, 2, 3, 4, 5],
    "mean": [10, 12, 15, 14, 18],
    "lower": [9, 11, 13, 12, 16],
    "upper": [11, 13, 17, 16, 20],
})

p = figure(title="误差条图", width=650, height=400)

p.line("x", "mean", source=source, line_width=2)
p.scatter("x", "mean", source=source, size=10, marker="circle")

whisker = Whisker(source=source, base="x", lower="lower", upper="upper")
p.add_layout(whisker)

show(p)

和面积误差带的区别

误差条和误差带都能表达不确定性,但适合场景不同:

  • 点比较稀疏:误差条更好
  • 连续时间序列:误差带更好
  • 重点看每个观测点:误差条
  • 重点看整体范围:面积带

注意事项

  • 一定要明确误差条表示什么:标准差、标准误、置信区间?
  • 如果只是“画出来像误差条”,却没说明统计含义,图会很容易被误解
  • 误差条太多时,可能会互相遮挡,此时可以考虑分面或减少系列数

3.10 等高线图

这一节需要特别更新一个认知:

在当前 Bokeh 3.x 中,已经有 figure.contour()

所以如果你要画真正的等高线图,不应该再用 image() 假装等高线。
image() 适合显示栅格图像;
contour() 才是等值线 / 等值区域的原生接口。

基础等高线图

import numpy as np
from bokeh.plotting import figure, show

x = np.linspace(-3, 3, 100)
y = np.linspace(-3, 3, 100)
xx, yy = np.meshgrid(x, y)
z = np.sin(xx) * np.cos(yy)

p = figure(title="等高线图", width=500, height=450)
p.contour(
    x=x,
    y=y,
    z=z,
    levels=10,
    line_color="black",
    fill_color=["#440154", "#482777", "#3e4989", "#31688e", "#26828e",
                "#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725"],
)

show(p)

什么时候用等高线图

适合场景:

  • 二维标量场
  • 地形高度
  • 温度、压力、浓度等空间分布
  • 优化目标函数的形态观察

什么时候不用等高线图

不适合的情况:

  • 数据本质是类别矩阵
  • 你只有少量离散点,而没有规则网格
  • 你真正想表达的是像素/栅格,而不是等值线

这时更适合:

  • 热力图
  • image()
  • 散点图 + 插值(视情况而定)

3.11 地理数据可视化

地理可视化和普通二维图最大的区别是:
它不仅仅是“x 和 y 的值”,还涉及:

  • 坐标参考系
  • 经纬度与 Web Mercator
  • 地图底图来源
  • GeoJSON 等地理对象

底图地图:add_tile()

当前官方文档中,Bokeh 推荐通过 add_tile() 使用 XYZ 瓦片服务。
你可以直接传入 tile provider 名称字符串。

from bokeh.plotting import figure, show

p = figure(
    title="底图地图示例",
    width=700,
    height=500,
    x_axis_type="mercator",
    y_axis_type="mercator",
)

p.add_tile("CartoDB Positron")

show(p)

一个带点位的地图示例

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show

source = ColumnDataSource(data={
    "x": [12958000, 12980000, 13000000],
    "y": [4850000, 4865000, 4880000],
    "name": ["点 A", "点 B", "点 C"],
})

p = figure(
    title="地图上的点位",
    width=700,
    height=500,
    x_axis_type="mercator",
    y_axis_type="mercator",
    tools="pan,wheel_zoom,reset,save,hover",
    tooltips=[("名称", "@name")],
)

p.add_tile("CartoDB Positron")
p.scatter("x", "y", source=source, size=12, marker="circle", color="firebrick", alpha=0.8)

show(p)

重要:坐标必须匹配投影

如果你用的是底图瓦片,最常见的坑就是:

  • 底图使用 Web Mercator
  • 你的点数据还是经纬度

这会导致点“跑偏”甚至完全不显示。

所以要记住:

  • x_axis_type="mercator" / y_axis_type="mercator"
  • 你的点位也应该先转换到 Web Mercator

GeoJSON 数据

如果你的数据本身是地理对象,如:

  • 行政区边界
  • 路线
  • 多边形区域

那么应优先考虑 GeoJSONDataSource
它可以直接承接地理数据结构,而不是手工拆成很多列。

什么时候用地图

只有当地理位置本身有意义时,才值得上地图。
如果你只是想比较几个城市的数值大小,很多时候:

  • 排序柱状图
  • 点图
  • 小表格

反而比地图更清晰。


3.12 网络图可视化

网络图适合表达:

  • 节点与连接关系
  • 社交网络
  • 依赖关系
  • 路由拓扑
  • 知识图谱中的局部结构

在 Bokeh 里,网络图底层基于 GraphRenderer
而在教程实践里,最方便的入口通常是 from_networkx()

基础网络图示例

import networkx as nx
from bokeh.plotting import figure, from_networkx, show

G = nx.karate_club_graph()

p = figure(title="NetworkX + Bokeh 网络图", width=700, height=500,
           x_range=(-2, 2), y_range=(-2, 2),
           tools="pan,wheel_zoom,reset,save")

graph = from_networkx(G, nx.spring_layout, scale=1.5, center=(0, 0))

# 设置节点和边样式
graph.node_renderer.glyph.size = 12
graph.node_renderer.glyph.fill_color = "#1f77b4"
graph.edge_renderer.glyph.line_alpha = 0.4
graph.edge_renderer.glyph.line_width = 1.5

p.renderers.append(graph)

show(p)

为什么 from_networkx() 很方便

它帮你省掉了两类工作:

  1. 自己构造图的节点和边数据源
  2. 自己手工指定布局提供器

这使得你可以快速把已有的 NetworkX 图对象可视化出来。

可以继续做什么

网络图在 Bokeh 中还可以继续扩展:

  • 给节点加 hover 信息
  • 给节点按属性着色
  • 调整 selection / inspection policy
  • 自定义布局
  • 自定义 node / edge glyph

网络图的局限与建议

网络图很容易“看起来很炫”,但也很容易“什么都看不清”。

所以建议:

  • 节点少时适合展示结构
  • 节点多时应先做筛选、聚类、分层
  • 不要试图把整个大图一次性全画出来

对于较复杂网络,布局和筛选策略往往比“画图 API”更重要。


3.13 图表类型对比与选择

这一节把前面的内容收束成一张“查表”。

全部图表类型对比总表

图表类型Bokeh 常用方法适合数据最适合回答的问题注意事项
散点图scatter()两个连续变量有没有关系、离群点、聚类点多会重叠
折线图line()有顺序的连续数据趋势如何变化不适合纯类别比较
阶梯图step()离散阶段变化状态何时跳变只适合阶梯式变化
柱状图vbar() / hbar()类别 + 数值谁大谁小类别太多会拥挤
分组柱状图dodge() + vbar()分组类别数据同类下多个系列比较系列太多难读
堆叠柱状图vbar_stack()构成数据总量和构成如何变化不利于精确比较子项
直方图quad() + np.histogram()单变量连续数据分布形态如何bins 选择很关键
饼图wedge()少量构成数据整体由几部分组成不适合精确比较
环形图annular_wedge()少量构成数据构成展示和饼图局限相似
面积图varea()连续序列总量/区间如何变化容易掩盖精确比较
堆叠面积图varea_stack()多部分连续构成总量与构成系列多时难读
热力图rect() + 颜色映射二维矩阵/交叉表哪些区域更高更低适合模式识别,不适合精读
箱线图vbar() + Whisker分组分布中位数、离散程度、异常值不展示完整分布形态
误差条图Whisker估计值 + 不确定性不确定性有多大需说明误差含义
等高线图contour()二维标量场等值线/区域结构如何需规则网格更合适
地图add_tile()GeoJSONDataSource空间数据数据在地理上如何分布注意投影转换
网络图from_networkx()节点边关系结构如何连接图太大时可读性差

按数据特征快速选择

只有一个数值变量

优先考虑:

  • 直方图
  • 箱线图

如果你更想看“频数分布”,选直方图。
如果你更想看“统计摘要”,选箱线图。

一个类别变量 + 一个数值变量

优先考虑:

  • 柱状图
  • 水平柱状图
  • 箱线图(如果每个类别下有一批样本)

两个连续变量

优先考虑:

  • 散点图
  • 折线图(前提是有顺序)

连续时间序列

优先考虑:

  • 折线图
  • 阶梯图
  • 面积图

二维矩阵

优先考虑:

  • 热力图
  • 等高线图
  • image()(若是栅格图像)

地理位置数据

优先考虑:

  • 地图底图 + 点位
  • GeoJSON 面对象可视化

图结构数据

优先考虑:

  • 网络图

图表选择速查口诀

看关系,用散点;
看趋势,用折线;
看跳变,用阶梯;
比大小,用柱状;
看分布,直方箱;
看构成,堆叠优先,饼环慎用;
看强弱,用热力;
看场分布,用等高;
看位置,用地图;
看连接,用网络。

本章小结

这一章你应该掌握的,不只是“会调哪些 API”,而是下面这些更重要的判断:

  1. 先根据分析目标选图,再考虑 Bokeh 写法
  2. Bokeh 3.x 中很多图形都适合以 ColumnDataSource 为中心组织数据
  3. 散点、折线、柱状图是最常用的三大基础图
  4. 分布类问题优先想到直方图、箱线图、误差条
  5. 构成类问题优先想到堆叠图,饼图和环形图应谨慎使用
  6. 等高线、地图、网络图虽然更“高级”,但也更依赖数据结构是否匹配
  7. 交互不是装饰,而是帮助你更快读图的工具

接下来建议阅读:

如果你已经能根据数据特征快速想到“应该先画哪种图”,那么这章的目标就达到了。