用Altair在Python中设计数据可视化样式的Synthwave

148 阅读10分钟

电子音乐的Synthwave流派带来了20世纪80年代电影和音乐的美感--其明亮、复古的未来主义风格已经跨入了*《侠盗猎车手:副城*》和《Far Cry 3: Blood Dragon》等游戏。你甚至可以沐浴在VS Code主题的霓虹灯下(在关闭发光效果的情况下,它实际上是一个非常坚实的黑暗模式--我现在就在上面写这篇文章)。Dominik Haitz在Matplotlib博客上发表了一篇出色的文章,介绍了如何在Python中把这种风格应用到你的可视化中。但是如果我们不想使用Matplotlib呢?

虽然Matplotlib不可否认是Python中可视化的主导者,但我总觉得它有点杂乱无章。混合和匹配面向对象和命令式语法(通过其pyplot 子模块),或者在Pythonic结构和其原始的类似MATLAB的设计之间跳动,导致了一个相当不直观和困难的界面,使其过于难以表达你实际上想用你的可视化做什么。虽然seaborn包装器使之更容易,但深度定制仍然需要挖掘Matplotlib的内部结构。事实上,Python中数据可视化的难度是我最常听到的支持R作为数据科学语言的论据之一。

最近,出现了一些替代性的可视化库--BokehPlotlyAltair,仅举几例,它们都从头开始为Python设计了更一致和强大的可视化体验。特别是,我最近开始使用Altair,这是一个围绕Vega(或者更恰当的说,Vega-Lite)声明式可视化语法的Python包装。这意味着,在Altair中,我只需要描述我想让绘图做什么,而不是如何做(在Matplotlib中,这通常意味着要跳过一些隐藏状态的圈圈),从而产生一个丰富的、交互式的可视化,可以导出为实时Javascript、HTML或静态图像。

所以,很自然地,我想把同样的Synthwave风格带到Altair的可视化中去!在这篇文章中,我们将通过创建一个简单的可视化,并在情节本身中进行自定义,以及通过布置一个可重用的Altair主题

让我们开始行动吧!

第一个步骤

由于Altair产生了织女星的可视化效果,我们需要一个Javascript前端来显示我们的图表。幸运的是,Altair的渲染器与Jupyter笔记本开箱即用,所以我建议用这种方式来制作可视化的原型。如果你不是在Jupyter笔记本中工作,安装altair_viewer 包可以让你从终端生成并查看可视化效果--或者,你可以直接在图表对象上调用.save("html") ,生成一个可以在任何网络浏览器中查看的文件。

首先,我们需要一些样本数据--让我们生成一对高斯曲线,我们可以很容易地将其可视化,并为其设置分类标签。

x = numpy.arange(-4, 4, 0.1)
y_left = scipy.stats.norm.pdf(x, loc=-1)
y_right = scipy.stats.norm.pdf(x, loc=1)
df = pandas.concat([
    pandas.DataFrame({"x": x, "y": y_left, "z": "left"}),
    pandas.DataFrame({"x": x, "y": y_right, "z": "right"})
]).sort_values(by="x")

Altair的可视化是围绕着Chart 对象建立的--它同时记录了我们的数据(以pandas.DataFrame 的形式)和可视化的状态。Altair的设计是完全声明性的:也就是说,每一个绘图的规范都被声明为对Chart 对象的操作,通常只是描述我们想要改变的东西(把如何改变留给Vega-Lite的内部)。每个操作都会返回Chart 的更新版本,所以操作可以以菊花链的方式来描述情节。开始吧。

altair.Chart(df).encode(x="x", y="y", color="z").mark_line()

在这里,我们已经。

  1. 创建了我们的Chart 对象(包括数据)。
  2. 对数据进行了编码(也就是说,我们将特定的数据列与视觉 "通道 "联系在一起,如X轴和Y轴以及颜色水平)。
  3. 声明一个带有我们的数据的line 标记来显示

我们已经有了一个良好的开端--通过这个最小的代码,我们已经制作了一个漂亮的图,只需向Altair描述我们想要的东西。

我们可以通过在编码中提供注释,在Chart 层面上增加更多细节。

chart = (
    altair.Chart(df)
    .encode(
        x=altair.X("x", title="x-range"),
        y=altair.Y("y", title="gaussian pdf"),
        color=altair.Color("z:N", title="distribution")
    )
)
chart.mark_line()

其中Altair模式对象(X,Y,Color )让我们操作编码。我们也可以直接在数据字段中使用速记:例如,注解z:N ,表示该列应该被视为分类的。虽然Altair在这方面很聪明,但指出列的类型还是很有帮助的,因为Altair会智能地处理分类、序数和时间戳字段,以及默认的连续值--我们也可以通过这种方式应用聚合和转换

我对此感觉很好--图表正在表达我们希望它表达的东西。现在,让我们转向对它进行造型设计

调色板和主题

Altair允许对图表进行任何级别的配置:全局默认和主题、图表级别的偏好,或者应用于特定标记的局部覆盖。我们可以通过Chart.configure_* 方法将我们的样式应用于单个图表,但是如果我们想为我们的可视化重用一个一致的样式,我们应该将我们的变化编码在一个Altair主题中。

由于Vega以JSON可序列化的形式表达可视化,要设计一个主题,我们只需要设计一个函数,返回一个包含我们想要的设置(覆盖全局默认值)的字典,并将其注册为一个主题。例如,设置默认的图表尺寸。

def synthwave():
    return {
        "config": {
            "view": {
                "continuousWidth": 400,
                "continuousHeight": 300
            }
        }
    }

altair.themes.register("synthwave", synthwave)
altair.themes.enable("synthwave")

(注意,我们专门为连续值轴设置了尺寸--我们可以为不同的数据编码单独设置默认值)。我们可以通过在这个配置函数中应用必要的值开始设置调色板。

让我们从绘图区开始。背景色是一个直接的顶层配置,而坐标轴的绘制细节则要复杂一些。我们可以分别为网格(绘图本身的线条)、域(数据通道的可用空间--在本例中,X轴和Y轴的侧边栏)和刻度线设置配置,覆盖它们的颜色或将它们置空。

def synthwave():
    background = "#2e2157"  # dark blue-grey
    grid = "#2a3459"       # lighter blue-grey

    return {
        "config": {
            "view": {
                "continuousWidth": 400,
                "continuousHeight": 300
            },
            "background": background,
            "axis": {
                "gridColor": grid,
                "domainColor": None,
                "tickColor": None
            }
        }
    }

当然,现在我们无法读取我们的标签,所以我们也需要覆盖这些标签。同样,配置是相当细化的,所以(例如)我们可以在axis 配置下单独设计刻度线标签和轴的标题,以及为legend 添加一个配置块。

def synthwave():
    background = "#2e2157"  # dark blue-grey
    grid = "#2a3459"       # lighter blue-grey
    text = "#d3d3d3"       # light grey

    return {
        "config": {
            "view": {
                "continuousWidth": 400,
                "continuousHeight": 300
            },
            "background": background,
            "axis": {
                "gridColor": grid,
                "domainColor": None,
                "tickColor": None,
                "labelColor": text,
                "titleColor": text
            },
            "legend": {
                "labelColor": text,
                "titleColor": text
            }
        }
    }

好了!我们已经有了我们的绘图。现在我们已经有了我们的绘图表面的样式,我们准备好对我们的数据进行处理。就像Altair将一个数据通道的潜在输入编码为域一样,它的输出表示被编码为范围。这让我们可以分别配置图表如何表示分类数据和序数数据以及连续数据。对于我们的两个分布,我们只需要配置一个带有离散调色板的分类范围就可以了。

def synthwave():
    background = "#2e2157"   # dark blue-grey
    grid = "#2a3459"        # lighter blue-grey
    text = "#d3d3d3"        # light grey
    line_colors = [
        "#2de2e6",          # teal/cyan
        "#fe53bb",          # pink
        "#f5d300",          # yellow
        "#00ff41",          # matrix green
        "#ff6c11",          # hot orange
        "#fd1d53"           # hot red
    ]

    return {
        "config": {
            "view": {
                "continuousWidth": 400,
                "continuousHeight": 300
            },
            "background": background,
            "axis": {
                "gridColor": grid,
                "domainColor": None,
                "tickColor": None,
                "labelColor": text,
                "titleColor": text
            },
            "legend": {
                "labelColor": text,
                "titleColor": text
            },
            "range": {
                "category": line_colors
            }
        }
    }

曲线造型

接下来,我们要在我们的曲线下添加一个填充效果,只用一丝颜色来标记曲线本身。这意味着我们实际上是在改变我们的标记--不是一条线,而是标记出一个区域。Altair的mark_area ,也可以让我们添加边界线,所以我们可以同时保留原来的外观和风格。

def synthwave():
    background = "#2e2157"   # dark blue-grey
    grid = "#2a3459"        # lighter blue-grey
    text = "#d3d3d3"        # light grey
    line_colors = [
        "#2de2e6",          # teal/cyan
        "#fe53bb",          # pink
        "#f5d300",          # yellow
        "#00ff41",          # matrix green
        "#ff6c11",          # hot orange
        "#fd1d53"           # hot red
    ]

    return {
        "config": {
            "view": {
                "continuousWidth": 400,
                "continuousHeight": 300
            },
            "background": background,
            "axis": {
                "gridColor": grid,
                "domainColor": None,
                "tickColor": None,
                "labelColor": text,
                "titleColor": text
            },
            "legend": {
                "labelColor": text,
                "titleColor": text
            },
            "range": {
                "category": line_colors
            },
            "area": {
                "line": True,
                "fillOpacity": 0.1
            },
            "line": {
                "strokeWidth": 2
            }
        }
    }

在这里,我们将该区域标记为默认的10%不透明度的填充,并绘制其边界线--这将使用line 配置,就像一个独立的line 标记。为了显示,我们简单地用chart.mark_area() 替换对mark_line 的调用,结果是

让它发光

当然,任何Synthwave效果的最高荣誉是我们尖锐、明亮的线条所发出的模糊的光芒。我们可以通过反映Matplotlib的方法来实现这种效果,在低不透明度下重新绘制连续的宽线,以达到越接近主线越密集的效果。

在Altair的声明式语法中,这样做可能有点不直观--也就是说,我们不能像matplotlib.pyplot ,在for循环中重新绘制,因为每个标记都会覆盖前一个标记。然而,Altair确实提供了一些打印的方法来将绘图对象合并到一个单一的复合图表中,包括垂直或水平连接,重复和切面,或分层图表。在LayeredChart ,我们可以提供一组不同的输入对象,并将它们整理成一个覆盖在共享轴上的单一图表对象。通过用mark_line ,连续绘制我们的线条,然后我们可以将它们分层成一个单一的图表对象,其行为就像任何一个内置的mark_* 函数所产生的一样。

def mark_blurred_line(chart, n_glows=10, base_opacity=0.3):
    opacity = base_opacity/n_glows
    glows = (
        chart.mark_line(opacity=opacity, strokeWidth = 2 + (1.05 * i))
        for i in range(1, n_glows + 1)
    )
    return altair.layer(*glows)

# using our chart object from before
fill = chart.mark_area()
blur = mark_blurred_line(chart)
altair.layer(fill, blur)

收尾工作

就这样了!用相对较少的代码,我们实现了一些非常引人注目的可视化,而且我们可以通过启用主题来重复使用它来实现其他的可视化。当然,还有更多的事情要做(比如正确地设计调色板以处理序数或连续范围),但这只是一个填充更多配置选项的问题。我怀疑有一种更聪明的方法来创造发光效果,因为这应该可以用CSS的阴影来做,但我还没有想好如何整合。

我们也不局限于制作静态图形--由于Altair建立了完全规格的Vega图形,我们可以将这些图形直接导出为HTML,或者导出为JSON模式,可以通过JavaScript前端交互显示。虽然我怀疑我仍然会在Matplotlib中做一些快速和肮脏的绘图(通常是通过seaborn),但我真的很高兴在Altair中做引人注目的可视化是如此容易,而不觉得我在和我的可视化工具对抗。