第三章:基础图表类型
前置知识:建议你先完成第一章和第二章,尤其要理解
figure、ColumnDataSource、HoverTool的基本用法。
延伸阅读:本章里用到的dodge()、factor_cmap()、linear_cmap()、cumsum()等转换工具,会在第四章中系统展开;多个图表如何组合,请见第五章;交互工具的详细配置见第六章。
这一章回答一个最实际的问题:
面对一组数据,你应该先画什么图?
很多初学者学习可视化时,会把注意力集中在“Bokeh 有哪些 API”。
但真正重要的是先想清楚:
- 你想看的是趋势、比较、分布,还是关系
- 你的数据是连续变量、类别数据,还是空间数据
- 你是想做快速探索,还是要做可以交付的图表
Bokeh 的优势不在“图表种类最多”,而在于:
- 你可以直接用 Python 组织数据
- 图表天然支持缩放、平移、悬停
- 后续可以平滑升级到联动、布局、嵌入网页、Bokeh Server
所以本章不会把所有图形都堆给你,而是围绕常见需求,给出:
- 图表选择建议
- 当前 Bokeh 3.x 仍然稳妥的写法
- 什么时候该用、什么时候不该用
- 容易踩坑的地方
图表选择指南
图表类型决策树
先看一个实用版决策树:
你要回答什么问题?
│
├─ 看两个变量之间有没有关系
│ └─ 散点图(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:把“漂亮”放在“准确”前面
颜色、透明度、渐变、阴影都只是辅助。
可视化优先级永远是:
- 数据表达正确
- 结构清晰
- 交互有帮助
- 最后再谈视觉风格
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 官方推荐的思路是:
- 先用
numpy.histogram()计算分箱 - 再用
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 太多:噪声被放大
- 初步探索时:先试
20到40 - 对比多个图时:尽量保持一致的分箱规则
什么时候不用直方图
不建议用直方图的情况:
- 样本量太少
- 你更关心分组差异,而不是总体分布
- 你想强调中位数、四分位数、异常值
替代方案:
- 箱线图
- 小提琴图(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() 很方便
它帮你省掉了两类工作:
- 自己构造图的节点和边数据源
- 自己手工指定布局提供器
这使得你可以快速把已有的 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”,而是下面这些更重要的判断:
- 先根据分析目标选图,再考虑 Bokeh 写法
- Bokeh 3.x 中很多图形都适合以
ColumnDataSource为中心组织数据 - 散点、折线、柱状图是最常用的三大基础图
- 分布类问题优先想到直方图、箱线图、误差条
- 构成类问题优先想到堆叠图,饼图和环形图应谨慎使用
- 等高线、地图、网络图虽然更“高级”,但也更依赖数据结构是否匹配
- 交互不是装饰,而是帮助你更快读图的工具
接下来建议阅读:
- 第四章:数据变换与数据管理
你会系统掌握dodge()、颜色映射、数据组织方式等内容。 - 第五章:布局与样式
你会学会如何把多个基础图组合成真正可交付的可视化页面。 - 第六章:交互功能
你会进一步理解 Hover、选择、联动和工具栏配置。
如果你已经能根据数据特征快速想到“应该先画哪种图”,那么这章的目标就达到了。