查看全课大纲(第 36 / 45 节)

主题河流图

小象实战讲义 · ECharts 数据可视化

在数据分析中,我们常常需要观察多个类别数据随时间变化的趋势。传统的折线图或堆叠面积图虽然有效,但当类别较多且数据波动频繁时,图表容易显得拥挤。本节将介绍一种独特的时序数据可视化图表——主题河流图(Theme River),它通过“河流”般的流动形态,直观地展示不同类别数据在时间维度上的“涨落”与相对关系,尤其适合呈现周期性、趋势性以及类别间的此消彼长。

💡 核心导读

  • 图表认知:主题河流图是一种以“河流宽度”表示数值、“河流流向”表示时间变化的特殊图表,它没有固定的Y轴基准线。
  • 核心用途:主要用于观察多个类别数据随时间变化的趋势和相对比例,而非精确的绝对值。
  • 数据格式:绘制前必须将数据整理为 [时间, 数值, 类别] 的三元组列表形式,顺序至关重要。
  • 关键配置:必须配置 SingleAxisOpts 为时间类型,并设置合适的提示框(Tooltip)触发方式。
  • 数据转换:掌握使用 pandas 的 unstack() 和 reset_index() 方法,将常见的“宽表”数据转换为主题河流图所需的“长表”数据。

一、认识主题河流图

主题河流图(Theme River)通过模拟河流的流动形状,来展示不同类别的数据随时间的变化情况。其核心特点在于:

  • 宽度即数值:每个类别在某个时间点的数值大小,直接体现为该时间点“河流”分流的宽度。宽度越宽,数值越大。
  • 无固定Y轴:与柱状图、折线图不同,主题河流图没有从零开始的固定纵轴。它将数据描绘在一个动态变化的中心基准线上,这条基准线本身也是一条曲线。
  • 关注趋势与对比:这种设计使得图表更侧重于展示各类别数据变化的整体趋势以及类别之间的相对比例关系,而不是每个时间点的精确数值。

下面我们通过一个简单的例子来直观感受。

# 示例1:基础主题河流图
from pyecharts.charts import ThemeRiver
from pyecharts import options as opts

# 1. 准备数据
# 数据格式必须是: [[时间1, 数值1, 类别1], [时间2, 数值2, 类别1], ...]
# 这里我们手动构造一个包含两个类别的简单数据集
data = [
    ["2024-01-01", 5, "产品A"],
    ["2024-01-02", 8, "产品A"],
    ["2024-01-03", 6, "产品A"],
    ["2024-01-04", 10, "产品A"],
    ["2024-01-01", 15, "产品B"],
    ["2024-01-02", 12, "产品B"],
    ["2024-01-03", 18, "产品B"],
    ["2024-01-04", 14, "产品B"],
]

# 2. 获取所有唯一的类别名称,用于图例
categories = list(set([item[2] for item in data]))
# 注意:set是无序的,为了演示稳定输出,我们排序一下
categories.sort()
print(f"图表包含的类别: {categories}")

# 3. 创建图表
chart = (
    ThemeRiver()
    .add(
        series_name=categories,  # 系列名称,对应图例
        data=data,               # 数据
        # 关键配置:单一坐标轴,必须设置为时间类型
        singleaxis_opts=opts.SingleAxisOpts(
            type_="time",        # 坐标轴类型为时间
            pos_top="50",        # 坐标轴距离容器顶部的距离
            pos_bottom="50",     # 坐标轴距离容器底部的距离
        ),
    )
    .set_global_opts(
        # 配置提示框:触发方式为坐标轴,指针类型为直线
        tooltip_opts=opts.TooltipOpts(trigger="axis", axis_pointer_type="line")
    )
)

# 生成HTML文件(本地查看用)
chart.render("basic_theme_river.html")
print(f"图表已生成,包含 {len(data)} 条数据记录。")
图表包含的类别: ['产品A', '产品B']
图表已生成,包含 8 条数据记录。

运行上述代码会生成一个HTML文件。在浏览器中打开,你将看到一条由“产品A”和“产品B”两条“支流”汇成的“河流”,横轴是时间。将鼠标悬停在图表上,会出现一条纵向的提示线,并显示当前时间点两个产品的具体数值。可以清晰地看到,“产品B”的河流宽度始终大于“产品A”,表明其销量更高;同时,通过河流宽度的变化,也能感知到两者销量随时间的波动趋势。

二、主题河流图的数据格式与准备

从上面的例子可以看出,主题河流图对输入数据的格式有严格要求:必须是 [时间, 数值, 类别] 顺序的三元组列表。这个顺序不能错,否则图表无法正确解析。

然而,在实际的数据分析工作中,我们更常遇到的是类似下面的“宽表”格式,每一列代表一个类别:

import pandas as pd

# 模拟一个常见的"宽表"数据格式
data_wide = {
    'date': ['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04'],
    '产品A': [5, 8, 6, 10],
    '产品B': [15, 12, 18, 14],
    '产品C': [7, 9, 5, 11]
}
df_wide = pd.DataFrame(data_wide)
print("原始的宽表数据:")
print(df_wide)
原始的宽表数据:
         date  产品A  产品B  产品C
0  2024-01-01    5   15    7
1  2024-01-02    8   12    9
2  2024-01-03    6   18    5
3  2024-01-04   10   14   11

我们需要使用 pandas 的数据重塑功能,将这种“宽表”转换为主题河流图所需的“长表”格式。核心方法是 unstack()。

import pandas as pd

# 示例2:将宽表数据转换为主题河流图所需格式
data_wide = {
    "date": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"],
    "产品A": [5, 8, 6, 10],
    "产品B": [15, 12, 18, 14],
    "产品C": [7, 9, 5, 11],
}
df_wide = pd.DataFrame(data_wide)

# 1. 将'date'列设置为索引
df_indexed = df_wide.set_index('date')
print("设置索引后的DataFrame:")
print(df_indexed)

# 2. 使用 unstack() 将列索引(产品名)降级,与行索引(日期)合并
# 结果是一个多层索引的Series
series_unstacked = df_indexed.unstack()
print("\nunstack() 后的前6条数据(Series格式):")
print(series_unstacked.head(6))

# 3. 使用 reset_index() 将多层索引转换为普通列
df_long = series_unstacked.reset_index()
print("\nreset_index() 后的DataFrame:")
print(df_long.head(6))

# 4. 重命名列,并调整列顺序
df_long.columns = ['类别', '日期', '销售量']
df_final = df_long[['日期', '销售量', '类别']]  # 调整为 [时间, 数值, 类别] 顺序
print("\n最终处理好的长表数据(前6行):")
print(df_final.head(6))

# 5. 转换为 pyecharts 可用的列表格式
data_for_chart = df_final.values.tolist()
print(f"\n转换后的数据列表共有 {len(data_for_chart)} 条记录。")
print("第一条记录示例:", data_for_chart[0])
设置索引后的DataFrame:
            产品A  产品B  产品C
date                     
2024-01-01    5   15    7
2024-01-02    8   12    9
2024-01-03    6   18    5
2024-01-04   10   14   11

unstack() 后的前6条数据(Series格式):
     date      
产品A  2024-01-01     5
     2024-01-02     8
     2024-01-03     6
     2024-01-04    10
产品B  2024-01-01    15
     2024-01-02    12
dtype: int64

reset_index() 后的DataFrame:
  level_0        date   0
0     产品A  2024-01-01   5
1     产品A  2024-01-02   8
2     产品A  2024-01-03   6
3     产品A  2024-01-04  10
4     产品B  2024-01-01  15
5     产品B  2024-01-02  12

最终处理好的长表数据(前6行):
           日期  销售量   类别
0  2024-01-01    5  产品A
1  2024-01-02    8  产品A
2  2024-01-03    6  产品A
3  2024-01-04   10  产品A
4  2024-01-01   15  产品B
5  2024-01-02   12  产品B

转换后的数据列表共有 12 条记录。
第一条记录示例: ['2024-01-01', 5, '产品A']

流程总结:

  1. set_index('date'):将时间列设为索引。
  2. unstack():将列名(类别)从列索引“拆解”下来,与行索引(日期)共同构成一个多层索引的 Series,其值就是对应的数值。这步完成了从“二维表格”到“一维序列”的转换。
  3. reset_index():将多层索引的 Series 转换回 DataFrame,索引变成了两列。
  4. 重命名列,并按 [日期, 销售量, 类别] 的顺序整理,最终转换为列表。

三、实战:可视化多超市销售趋势

现在,我们应用上述知识,对一个更真实的销售数据集进行可视化。假设我们有一个包含五家超市超过一个月日销量数据的CSV文件(这里我们用代码模拟生成)。

# 示例3:实战-多超市销售主题河流图
import pandas as pd
import numpy as np

# 固定随机种子,确保每次运行数据一致
np.random.seed(2024)

# 模拟生成销售数据
dates = pd.date_range(start='2024-03-01', end='2024-03-31', freq='D')
supermarkets = ['超市A', '超市B', '超市C', '超市D', '超市E']

# 为每家超市生成有趋势的随机销量
data_dict = {'date': dates}
for market in supermarkets:
    # 模拟一个基本的上升或下降趋势,并加上随机波动
    trend = np.linspace(10, 50, len(dates)) if market in ['超市A', '超市C'] else np.linspace(50, 10, len(dates))
    noise = np.random.randint(-10, 11, size=len(dates))
    data_dict[market] = np.maximum(trend + noise, 0).astype(int)  # 销量不为负

df_sales = pd.DataFrame(data_dict)
print("模拟生成的宽表销售数据(前5行):")
print(df_sales.head())

# 使用之前的方法转换数据格式
df_sales_indexed = df_sales.set_index('date')
df_long_format = df_sales_indexed.unstack().reset_index()
df_long_format.columns = ['超市', '日期', '销售量']
df_final_format = df_long_format[['日期', '销售量', '超市']]

# 日期格式化为字符串,确保pyecharts正确识别
df_final_format['日期'] = df_final_format['日期'].dt.strftime('%Y-%m-%d')
data_for_river = df_final_format.values.tolist()
categories = df_final_format['超市'].unique().tolist()
categories.sort()

print(f"\n数据转换完成,共有 {len(data_for_river)} 条记录,{len(categories)} 个类别。")
模拟生成的宽表销售数据(前5行):
        date  超市A  超市B  超市C  超市D  超市E
0 2024-03-01    8   50   18   47   45
1 2024-03-02    1   45   14   53   38
2 2024-03-03    2   53    4   49   50
3 2024-03-04    8   44   24   49   54
4 2024-03-05   14   47   14   50   41

数据转换完成,共有 155 条记录,5 个类别。
import numpy as np
import pandas as pd
from pyecharts.charts import ThemeRiver
from pyecharts import options as opts

# 重新生成同一份固定销售数据,保证本块可单独运行
np.random.seed(2024)
dates = pd.date_range(start="2024-03-01", end="2024-03-31", freq="D")
supermarkets = ["超市A", "超市B", "超市C", "超市D", "超市E"]
data_dict = {"date": dates}
for market in supermarkets:
    trend = (
        np.linspace(10, 50, len(dates))
        if market in ["超市A", "超市C"]
        else np.linspace(50, 10, len(dates))
    )
    noise = np.random.randint(-10, 11, size=len(dates))
    data_dict[market] = np.maximum(trend + noise, 0).astype(int)

df_sales = pd.DataFrame(data_dict)
df_long_format = df_sales.set_index("date").unstack().reset_index()
df_long_format.columns = ["超市", "日期", "销售量"]
df_final_format = df_long_format[["日期", "销售量", "超市"]]
df_final_format["日期"] = df_final_format["日期"].dt.strftime("%Y-%m-%d")
data_for_river = df_final_format.values.tolist()
categories = sorted(df_final_format["超市"].unique().tolist())

# 创建主题河流图
river_chart = (
    ThemeRiver()
    .add(
        series_name=categories,
        data=data_for_river,
        singleaxis_opts=opts.SingleAxisOpts(
            type_="time",
            pos_top="50",
            pos_bottom="50",
        ),
        # 可以添加标签显示,但在密集数据下可能影响阅读
        # label_opts=opts.LabelOpts(is_show=False)
    )
    .set_global_opts(
        tooltip_opts=opts.TooltipOpts(trigger="axis", axis_pointer_type="line"),
        # 添加图例,可以通过点击图例筛选显示/隐藏某个超市的数据
        legend_opts=opts.LegendOpts(pos_top="10px")
    )
)

# 渲染图表
river_chart.render("supermarket_sales_river.html")
print(f"数据记录数: {len(data_for_river)}")
print(f"类别数: {len(categories)}")
print(f"图表类型: {type(river_chart).__name__}")
主题河流图,多条彩色河带随时间起伏变化
图:主题河流图:几条话题的讨论量随时间此消彼长,河带宽度表示该时刻的占比。手机端可左右滑动查看,点击图片可看全图。
数据记录数: 155
类别数: 5
图表类型: ThemeRiver

打开生成的 supermarket_sales_river.html,你可以观察到:

  • 整体趋势:哪些超市的销量“河流”总体较宽(销量高),哪些较窄。
  • 变化规律:例如,“超市A”和“超市C”的河流可能呈现出逐渐变宽的趋势,而“超市B”和“超市D”则可能逐渐变窄,这与我们模拟数据时设定的趋势相符。
  • 相对关系:在任何一天,通过比较各条“支流”的宽度,可以快速判断哪家超市是当日的销售主力。
  • 交互:点击图例中的超市名称,可以单独显示或隐藏该超市的数据流,便于聚焦分析。

📝 动手练一练

  1. 数据转换练习:假设你有一个 DataFrame df,列包括 ‘Year’、‘Country’、‘GDP’。请写出将其转换为主题河流图所需数据格式的 pandas 代码步骤。
  2. 图表解读:在生成的超市销售主题河流图中,如果你发现“超市E”的河流在3月中旬突然收窄,并在下旬恢复,这可能暗示了什么业务情况?结合提示框功能,如何验证你的猜想?
点击查看参考答案
  1. 数据转换参考答案:
    # 假设df已有'Year', 'Country', 'GDP'三列,但顺序或结构可能不符合要求
    # 目标格式是 [时间, 数值, 类别] -> [Year, GDP, Country]
    # 如果df已经是一行一个国家一年一个GDP的记录(即长表),只需调整列顺序:
    df_river_data = df[['Year', 'GDP', 'Country']].values.tolist()
    
    # 如果df是宽表,例如列是不同国家,行是年份:
    # df_wide 可能列如:['Year', 'CountryA_GDP', 'CountryB_GDP', ...]
    # 则需要先设置索引,再unstack
    # df_indexed = df_wide.set_index('Year')
    # df_long = df_indexed.unstack().reset_index()
    # df_long.columns = ['Country', 'Year', 'GDP'] # 注意unstack后列名顺序
    # df_final = df_long[['Year', 'GDP', 'Country']]
    # df_river_data = df_final.values.tolist()

2.  **图表解读参考答案**:
    “超市E”的河流在3月中旬突然收窄,表示该超市在该时间段的销量显著下降。可能的原因包括:库存短缺、促销活动结束、竞争对手强力促销、或该时间段内的负面事件(如门店整顿)。
    **验证方法**:在生成的交互图表中,将鼠标悬停在3月中旬“超市E”的河流区域,通过提示框查看具体的销量数值,确认下降的幅度。同时,可以对比观察其他超市在同一时间段的河流宽度变化,判断这是否是行业普遍现象(如节假日结束)还是“超市E”独有的问题。结合业务部门的实际活动日历,可以进一步定位原因。
</details>

## 本章小结

本节我们深入学习了主题河流图这一独特的时序数据可视化工具。它打破了传统坐标轴的束缚,用流动的视觉语言讲述数据随时间演变的故事,特别擅长揭示多个类别数据在趋势和比例上的宏观关系。

### 📋 行动清单
- [ ] **理解原理**:明确主题河流图“宽度表数值、流动表时间、无固定Y轴”的核心特征,知道其适用于趋势分析而非精确读数。
- [ ] **掌握数据转换**:熟练使用 `df.set_index().unstack().reset_index()` 这一套“组合拳”,将分析中常见的宽表数据转换为图表需要的 `[时间, 数值, 类别]` 长表列表。
- [ ] **完成一次实战**:使用自己手头或模拟的一份多类别时序数据(如不同产品的月销售额、不同渠道的周访问量),成功绘制出交互式主题河流图,并尝试从图中解读出至少两个业务洞察。

—— 小象教研组