借用 R4DS 一书中对数据探索工作的描述,主要包括三个关键部件:数据转换可视化图表数学建模。一直以来,在 EDA 场景中,我自己使用 R & Python 的强度是五五开,甚至略微更偏向 R 一些。因为 R 宇宙有 Tidyversedata.table 这些工具集,都支持管道,且语法简明、逻辑优雅,借助 RStudio 和 RMarkdown,数据分析效率很高。而 Python + Jupyter + Pandas 这边,往往操作稍显罗嗦,组织主题更加麻烦一些。

进入 2023 年之后,Python 的使用比重就大的多了,主要原因有两个:一是因为 Polars;二是因为 Quarto。Polars 日臻成熟和流行,它兼顾了语义的优雅和性能的高效,新引入的抽象概念和 API 带来的「心智负担」也不算高,无论是从 Pandas 还是 Tidyverse 过渡而来,都非常容易。对于喜欢 R 管道的朋友,Polars 提供的链式 API,更是帮我们在 Python 环境里找到了家。再说 Quarto,RStudio/Posit 可能 All in Quarto,期望通过 Quarto 来包含 R/Python 的一切,来对抗 Jupyter。然时至今日,如果用户不进入 RStudio App,去手动链接各种 dart-sass、deno、esbuild,在苹果 M 系列芯片上,.qmd 文件还是跑不起来,勉强跑起来了,不知不觉又切回了更为熟悉轻量的 Rmd。加之今天又看到 Yihui Down,相当难过。Anyway,Good luck and have fun.

我几乎已经完全用 Polars 替换掉了 Pandas 和 Tidyverse。唯有两点遗憾,一是 Polars 没有内置的图表接口,二是还不能无缝关联模型库。不过,这两点都可以通过调用比如 .to_pandas(),将 DataFrame 转换成相应的兼容数据形式之后,再进行处理。后者,应该还需要不少的时间,而至于前一个遗憾,在最近几天发布的 0.20.3 版中解决了。现在 Polars 也内置了类似 Pandas df.plot 方式的绘图接口 🔥,必须赶紧体验一下,并且跟 Python 生态里我常用的绘图库做个对比,做个取舍。

以上篇我在 YouTube 上看了些啥的数据集为例,做两件事,一是分别使用各个可视化框架,分别制作每月每个时段的热力图(heatmap)和每年的内容分类排名图(bump chart);二是将两附图合在一起,探索一下各框架的多图布局功能。

import polars as pl

df = pl.read_parquet("./history_aio.parquet")
pl.__version__
'0.20.4'
heatmap_dat = (
    # 挑出 2023 年
    df.filter(pl.col("year") == 2023)
    # 按月、小时段,做 count
    .group_by("month", "hour")
    .agg(
        pl.count().alias("n"),
    )
    # 透视拉宽、补列,完整 24 小时
    .pivot(index="month", columns="hour", values="n")
    .with_columns(
        pl.lit(None).alias("4"), pl.lit(None).alias("5"), pl.lit(None).alias("6")
    )
    .select(["month"] + [str(c) for c in range(1, 24)])
    # 融化还原成长表
    .melt(id_vars="month", variable_name="hour", value_name="n")
    # 变换类型并排序
    .with_columns(pl.col("hour").cast(pl.Int8))
    .sort("month", "hour")
)
heatmap_dat.head()
shape: (5, 3)
monthhourn
i8i8u32
1117
121
13null
14null
15null
bump_dat = (
    # 挑出有效类别行
    df.filter(pl.col("cat_name").is_not_null())
    # 按年、类,count 视频唯一量
    .group_by("year", "cat_name").agg(pl.n_unique("title").alias("n_video"))
    # 按量大小,排名
    .with_columns(
        pl.col("n_video").rank("ordinal", descending=True).over("year").alias("ranking")
    )
    # 按年排序
    .sort("year")
)
bump_dat.head()
shape: (5, 4)
yearcat_namen_videoranking
i32stru32u32
2016"People & Blogs…444
2016"Sports"149
2016"Howto & Style"415
2016"Autos & Vehicl…313
2016"Film & Animati…257

Polars & hvPlot

Polars 并没有自行实现绘图逻辑,它的 .plot 是通过的 hvPlot 来代理的(而 hvPlot 默认的后端又是 bokeh,所以如果在 Notebook 环境中图表未正确显示,可以尝试使用以下方法正确显示图表)。

# from bokeh.io import output_notebook

# output_notebook()
fig1 = heatmap_dat.plot.heatmap(
    x="hour", y="month", C="n", cmap="reds", title="Heatmap"
).options(toolbar=None, default_tools=[])

fig1

热力图是 hvPlot 预置的模板图表,所以一行代码,简单映射一下 xy 轴,再指定一下颜色,即可实现相当不错的效果。对于 bokeh 后端,如果不想看见它的工具栏,不喜欢它默认启用的缩放效果,我们也只需要在 .plot 之后跟 .opts 或者 .options 把它们关掉即可。

排名变化图 Bump Chart 并非 hvPlot 预置模板,需要自行实现。我们可以将其分解为散点图和条线图的结合体,分作制作这两者,然后将其合二为一。

fig2 = (
    bump_dat.plot.line(x="year", y="ranking", by="cat_name")
    * bump_dat.plot.scatter(x="year", y="ranking", by="cat_name")
).opts(
    title="Bump Chart",
    toolbar=None,
    default_tools=[],
    invert_yaxis=True,
    legend_opts=dict(border_line_width=0, label_text_font_size="6pt", label_height=2),
    padding=0.05,
    height=480,
)

fig2

微调的图表参数,可以大胆假设,先填进去,脚本报错之后,框架根据你的填写,会贴心的爆出它的合理推测或支持的参数集,如此两三个来回,再结合文档,实现想要的图表效果,门槛也不算太高。上图比较打眼的部分是右边的图例(legend)栏,它的排序没有能跟离它最近的 2023 年排名保持一致,观感不好。我们可以单独对它进行重排,或者拿掉它之后单独对 2023 的排名做 label 标注。不过这个过程在这里似乎都不同容易实现,且先留空。

AttributeError: unexpected attribute 'border_width' to Legend, similar attributes are border_line_width, border_line_dash or label_width

图内叠加用 *,而多图堆叠也非常方便,用 + 即可。

fig3 = (fig1.opts(height=480) + fig2).opts(toolbar=None)
fig3

Altair

交互式图表库,我最早用的是 Altair。Altair 的语法结构不算复杂,使用时也比较符合直觉, Chart().mark_x().encode()...properties() 一路串下去,Chart 吃数据,mark_x 吃形状,encode 吃配置,properties 做一些找补。特别有意思的是,在串联环节当中,Altair 还引入了便捷的数据变换机制,方便用户操作数据。并且文档和 API 组织的相当好,使用时没有 Plotly、Bokeh 那么松散的感觉,遇到问题时,按图索骥也比较通畅。

import altair as alt

alt.__version__
'5.2.0'
fig4 = (
    # 吃数据
    alt.Chart(heatmap_dat.fill_null(0))
    # 画方块
    .mark_rect()
    # 微调配置
    .encode(
        # x 及其数据类型
        x="hour:O",
        # y 及其数据类型、排序方式
        y=alt.Y("month:O", sort="descending"),
        # color 填充规则
        color=alt.condition(
            "datum.n != 0", alt.Color("n:Q").scale(scheme="reds"), alt.value("white")
        ),
        # 悬停信息
        tooltip=[
            alt.Tooltip("month"),
            alt.Tooltip("hour"),
            alt.Tooltip("n:Q", title="# of videos"),
        ],
    )
    # 补充定义
    .properties(title="Heatmap", width=500, height=300)
)

fig5 = (
    alt.Chart(bump_dat)
    # 画线、标点
    .mark_line(point=True)
    .encode(
        x="year:O",
        y=alt.Y("ranking:Q", sort="descending"),
        # 填充且干预排序
        color=alt.Color(
            "cat_name",
            sort=[
                "Science & Technology",
                "People & Blogs",
                "Film & Animation",
                "Education",
                "Gaming",
                "Entertainment",
                "Howto & Style",
                "Music",
                "Travel & Events",
                "Sports",
                "News & Politics",
                "Pets & Animals",
                "Autos & Vehicles",
                "Comedy",
                "Nonprofits & Activism",
            ],
        ),
        tooltip=[
            alt.Tooltip("year:O"),
            alt.Tooltip("cat_name"),
            alt.Tooltip("ranking:Q"),
        ],
    )
    .properties(
        title="Bump Chart",
        width=500,
        height=300,
    )
)

# 超方便的多图堆叠
# (fig1 & fig2)
fig6 = (fig4 | fig5).configure_axis(grid=False).properties(title="Bye, hell subplots.")
fig6

如上可见,图+图的堆叠及操控尤其方便,直出画面的效果简洁大方。缺点就是数据量一大(5000)就报警。

MaxRowsError: The number of rows in your dataset is greater than the maximum allowed (5000).

Try enabling the VegaFusion data transformer which raises this limit by pre-evaluating data
transformations in Python.
    >> import altair as alt
    >> alt.data_transformers.enable("vegafusion")

Or, see https://altair-viz.github.io/user_guide/large_datasets.html for additional information
on how to plot large datasets.

Plotly

Plotly 为满足「灵活自定义」以及「开箱即用」的两种需求,提供了两种不同的途径。一种底层 API,强调精确控制,称作 graphic_objects;一种上层 API,强调简单易用,称作 express。上一篇文章的全部图表,均出自 Plotly 框架。一般是先用 express 尝试,express 表达不出来的图表,再使用 graphic_objects 描绘。

import plotly

plotly.__version__
'5.18.0'
import plotly.graph_objects as go
from plotly import express as px
from plotly.subplots import make_subplots
fig7 = (
    # 方块热力图在 plotly express 中表述为 imshow
    px.imshow(
        # 为了迎合 imshow 的表达方式,我们需要先揉捏一下数据
        heatmap_dat.pivot(index="month", columns="hour", values="n")
        .to_pandas()
        .set_index("month"),
        # 数据呈现方式
        labels=dict(x="hour", y="month", color="n_videos"),
        color_continuous_scale="reds",
        # 图表配置
        template="plotly_white",
        title="Heatmap",
    ).update_layout(
        xaxis1=dict(showgrid=False),
        yaxis1=dict(showgrid=False),
    )
)

fig7

在 Plotly 里边实现 Bump Chart 也需要我们自行设计实现方式,这时候就需要从 express 切换到 graphic_objects。这一切换过程往往会给我们造成一些困扰,主要来源于 API 设计上的不完全兼容,比如同样是绘制散点图,这两套 API 散点图方法提供的参数可能就不一致,从而造成理解偏差。

def plotly_bump(df):
    fig = go.Figure()

    # Bump Chart 可以
    # 为每一个类别
    for name, data in df.group_by(["cat_name"]):
        name = name[0]
        trace = go.Scatter(
            x=data["year"],
            y=data["ranking"],
            mode="lines+markers",
            name=name,
            line=dict(width=2),
            marker=dict(size=10),
            text=name,
            hoverinfo="text",
            legendrank=data["ranking"][-1],
        )
        fig.add_trace(trace)

    fig.update_layout(
        title="Bump Chart",
        xaxis=dict(title="Year"),
        yaxis=dict(title="Rank"),
        legend=dict(title="Category"),
        yaxis_autorange="reversed",
        template="plotly_white",
        height=500,
        width=800,
        xaxis1=dict(showgrid=False),
        yaxis1=dict(showgrid=False),
    )

    return fig


fig8 = plotly_bump(bump_dat)
fig8

可见,Plotly 设计的 figure、trace、layout, 及其各项属性都是自成一派的,它有自己鲜明的特色,默认的交互效果也非常好,只不过 ggplot2 这一脉的用户,需要一个习惯的过程。快速分析时我很喜欢用 Plotly 做可视化,但是遇到自定义场景时又很头痛,往往需要耗费很多时间去搜索、查阅。

def plotly_subplots():
    fig = make_subplots(rows=1, cols=2, subplot_titles=["Heatmap", "Bump Chart"])

    # Heatmap
    heatmap_trace = go.Heatmap(
        x=heatmap_dat["hour"],
        y=heatmap_dat["month"],
        z=heatmap_dat["n"],
        hoverongaps=False,
        opacity=0.7,
        colorscale="reds",
        colorbar=dict(x=0.45, y=0.5, len=1.1),
    )
    fig.add_trace(
        heatmap_trace,
        row=1,
        col=1,
    )

    # Bump Chart
    for name, data in bump_dat.group_by(["cat_name"]):
        name = name[0]
        trace = go.Scatter(
            x=data["year"],
            y=data["ranking"],
            mode="lines+markers",
            name=name,
            line=dict(width=2),
            marker=dict(size=10),
            text=name,
            hoverinfo="text",
            legendrank=data["ranking"][-1],
            legendgroup="2",
        )
        fig.add_trace(trace, row=1, col=2)

    # Update layout
    fig.update_layout(
        title_text="Heatmap and Bump Chart Side by Side",
        template="plotly_white",
        xaxis1=dict(showgrid=False),
        yaxis1=dict(showgrid=False),
        yaxis2_autorange="reversed",
        height=400,
        width=1300,
    )

    return fig


fig9 = plotly_subplots()
fig9

花了不少时间让上面的两幅图表排在一起,结论暂时只有一个:不要折腾 Plotly 的 subplots,会变得不幸。

Bokeh

import bokeh

bokeh.__version__
'3.3.3'

不像绝大多数的图表库,Bokeh 已经不再内置模板 API,即便最常见的散点图、折线图,使用 Bokeh 来实现,看起来也没有那么直观,给人一种从底层一砖一瓦开始的基建感。

from bokeh.layouts import row
from bokeh.models import ColumnDataSource, Legend, LegendItem
from bokeh.plotting import figure, show, save
from bokeh.transform import factor_cmap, linear_cmap
from bokeh.io import output_notebook

output_notebook()
Loading BokehJS ...
# 需要 Pandas DataFrame
fig10_dat = heatmap_dat.to_pandas()

# 控制热力图细节
fig10 = figure(
    title="Heatmap",
    # 小时
    x_range=[str(x) for x in range(0, 24)],
    # 月份,多一份留白,让每个小格子等宽等长
    y_range=[str(x) for x in range(1, 14)],
    x_axis_location="above",
    tools="",
    toolbar_location=None,
    tooltips=[("hour", "@hour"), ("month", "@month"), ("n", "@n")],
    width=600,
    height=300,
)

# 去掉干扰
fig10.grid.grid_line_color = None
fig10.axis.axis_line_color = None
fig10.axis.major_tick_line_color = None
fig10.outline_line_color = None

# 映射数据
fig10.rect(
    x="hour",
    y="month",
    width=1,
    height=1,
    source=fig10_dat,
    fill_color=linear_cmap(
        "n", "Reds256", high=fig10_dat.n.min(), low=fig10_dat.n.max(), nan_color="white"
    ),
    line_color=None,
)

# 展示图表
show(fig10)
# 初始化图表配置
fig11 = figure(
    title="Slope Graph",
    x_range=(2015.9, 2023.1),
    y_range=(17.5, 0.5),
    x_axis_location="above",
    tools="",
    toolbar_location=None,
    width=600,
    height=300,
    tooltips=[("category", "@cat_name"), ("ranking", "@ranking")],
)

# 预先设定 17 个分类的颜色映射关系
cmap6 = factor_cmap(
    "cat_name",
    "Category20_17",
    sorted(bump_dat["cat_name"].unique()),
)

# 预先设定好图例排序
fig11_legend_indexes = [
    "Science & Technology",
    "People & Blogs",
    "Film & Animation",
    "Education",
    "Gaming",
    "Entertainment",
    "Howto & Style",
    "Music",
    "Travel & Events",
    "Sports",
    "News & Politics",
    "Pets & Animals",
    "Autos & Vehicles",
    "Comedy",
    "Nonprofits & Activism",
    "Movies",
    "Trailers",
]

fig11_legend_items = []

# 分别绘制折线图和散点图,并把图例信息传出来
for name, data in bump_dat.group_by(["cat_name"]):
    src = data.to_pandas()
    name = name[0]
    line = fig11.line(
        x="year",
        y="ranking",
        source=src,
        line_width=2,
        line_color=cmap6.transform.palette[cmap6.transform.factors.index(name)],
    )
    scatter = fig11.scatter(
        x="year",
        y="ranking",
        source=src,
        size=8,
        color=cmap6.transform.palette[cmap6.transform.factors.index(name)],
    )

    fig11_legend_items.append(LegendItem(label=name, renderers=[line, scatter]))

# 排除干扰
fig11.grid.grid_line_color = None
fig11.axis.axis_line_color = None
fig11.axis.major_tick_line_color = None
fig11.outline_line_color = None
fig11.xaxis.minor_tick_out = 0
fig11.xaxis.major_tick_in = 0
fig11.yaxis.minor_tick_out = 0
fig11.yaxis.major_tick_in = 0

# 微调图例
fig11_legend_items_sorted = sorted(
    fig11_legend_items, key=lambda x: fig11_legend_indexes.index(x.label.value)
)
fig11.add_layout(Legend(items=fig11_legend_items_sorted, click_policy="mute"), "right")

# 排除干扰
fig11.legend.label_text_font_size = "6pt"
fig11.legend.glyph_height = 1
fig11.legend.spacing = 5
fig11.legend.label_height = 5
fig11.legend.location = (0, 0)
fig11.legend.background_fill_alpha = 0.6
fig11.legend.border_line_alpha = 0

# 展示图表
show(fig11)

至于多图堆叠,bokeh 提供了清晰的方案,比如我们这里的一行两列,直接调用 row 方法即可。

fig12 = row(fig10, fig11)
show(fig12)

小结

今年应该会深入使用 Polars 及其背后的 hvPlot,以及 hvPlot 背后的 HoloViz、bokeh 生态,继续关注 Altair。另外,将尝试减少 Plotly 的使用,好用是好用,微调也确实痛苦。