Plotly-和-Dash-智能仪表盘和数据应用-二-

66 阅读1小时+

Plotly 和 Dash 智能仪表盘和数据应用(二)

原文:annas-archive.org/md5/e5313a0f17775ffeed6651154fcfc198

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:第六章:通过散点图探索变量,并使用滑块过滤子集

现在我们将探讨一种最通用、最有用且无处不在的图表类型——散点图。顾名思义,我们基本上是在笛卡尔平面上散布标记(可以是点、方块、圆圈、气泡或其他符号),其中它们的水平和垂直距离表达它们所代表的数值。其他视觉属性,如大小、颜色和符号,可能会用来表示其他属性,正如我们在一些前面的示例中看到的那样。由于大多数关于图形和创建图表的基础知识已经涵盖过,我们不会花太多时间在这方面,而是专注于散点图的具体细节和可用选项。我们还将探索并使用滑块这一新的交互式组件。我们现在就开始,但首先,以下是我们将要涵盖的主题:

  • 了解使用散点图的不同方式:标记、线条和文本

  • 在单个图表中创建多个散点图追踪

  • 使用散点图映射和设置颜色

  • 通过管理不透明度、符号和刻度来处理过度绘图和异常值

  • 介绍滑块和范围滑块

  • 自定义滑块的标记和值

技术要求

我们将在本章中使用上一章中使用的相同工具。我们还会稍微关注一下 Plotly 的graph_objects模块来创建散点图,因为它提供了其他工具,并且在进一步自定义我们的图表时非常有用。需要使用的包有 Plotly、Dash、Dash Core Components、Dash HTML Components、Dash Bootstrap Components、pandas 和 JupyterLab。

本章的代码文件可以在 GitHub 上的github.com/PacktPublishing/Interactive-Dashboards-and-Data-Apps-with-Plotly-and-Dash/tree/master/chapter_06找到。

查看以下视频,观看代码演示:bit.ly/3ancblu

我们首先探索使用散点图可以绘制的不同方式,或者说不同的内容。

了解使用散点图的不同方式:标记、线条和文本

使用graph_objects创建散点图时,我们有很多不同的选择,正如在引言中提到的那样,因此我们将与 Plotly Express 一起进行探索。为了让你了解散点图的多样性,以下代码提取了Figure对象可用的所有scatter方法,以及 Plotly Express 中可用的那些方法:

import plotly.graph_objects as go
import plotly.express as px
fig = go.Figure()
[f for f in dir(fig) if 'scatter' in f]
['add_scatter',
 'add_scatter3d',
 'add_scattercarpet',
 'add_scattergeo',
 'add_scattergl',
 'add_scattermapbox',
 'add_scatterpolar',
 'add_scatterpolargl',
 'add_scatterternary']
[f for f in dir(px) if 'scatter' in f]
['scatter',
 'scatter_3d',
 'scatter_geo',
 'scatter_mapbox',
 'scatter_matrix',
 'scatter_polar',
 'scatter_ternary']

如你所见,可用方法之间存在一些重叠,也有一些方法在两个模块中并不适用。我们不会详细讨论所有这些方法,但知道它们是很有帮助的,因为你可以轻松地将散点图的常规知识应用于其他类型的图表。现在让我们看看使用这些选项时的一些区别。

标记、线条和文本

go.Scatter对象中一个有趣的选项是mode参数。它可以包含标记、线条和/或文本的任意组合。你可以指定其中一个、两个或三个选项。当指定多个选项时,必须将它们作为一个单一字符串指定,其中各元素由加号分隔,例如"markers+text"。让我们首先了解一下我们将在本章中关注的指标,并立即探索绘图选项:

  1. 运行所需的导入并创建poverty数据框:

    import pandas as pd
    import plotly.graph_objects as go
    poverty = pd.read_csv('data/poverty.csv')
    
  2. 我们的数据集包含三个不同的日收入水平来衡量贫困。它们衡量的是“贫困线下的平均收入或消费不足——每天1.90”。它们还针对其他两个水平(1.90”。它们还针对其他两个水平(3.20 和$5.50)有相同的衡量标准。这些数据也可以在不同的列中以绝对数值呈现,但我们将在本章中重点关注百分比。它们的列名以贫困缺口(Poverty gap)开头,我们可以将其作为模式来提取我们需要的列:

    perc_pov_cols =\
    poverty.filter(regex='Poverty gap').columns
    perc_pov_cols
    Index(['Poverty gap at $1.90 a day (2011 PPP) (%)',
           'Poverty gap at $3.20 a day (2011 PPP) (% of population)',
           'Poverty gap at $5.50 a day (2011 PPP) (% of population)'],   
          dtype='object')
    
  3. 为了简化问题,我们将所有相关的变量名和对象以perc_pov_开头,以明确我们正在处理贫困百分比。请记住,我们现在在应用中有几个对象和函数,我们希望确保保持简单、清晰和一致。我们现在使用刚刚创建的列表来创建三个变量,每个贫困水平一个:

    perc_pov_19 = perc_pov_cols[0]
    perc_pov_32 = perc_pov_cols[1]
    perc_pov_55 = perc_pov_cols[2]
    
  4. 像往常一样,我们需要查看这些指标的描述,最重要的是,它们可能存在的限制:

    series[series['Indicator Name']==\
    perc_pov_19]['Short definition'][25]
    'Poverty gap at $1.90 a day (2011 PPP) is the mean shortfall in income or consumption from the poverty line $1.90 a day (counting the nonpoor as having zero shortfall), expressed as a percentage of the poverty line. This measure reflects the depth of poverty as well as its incidence. As a result of revisions in PPP exchange rates, poverty rates for individual countries cannot be compared with poverty rates reported in earlier editions.'
    
  5. 三个指标的定义基本相同,限制条件也与上一章看到的相似。请随意阅读细节,但请记住,这些数字并不完美,如果我们要做出任何解释时需要小心。我们现在为一个国家创建一个变量,并利用它创建一个包含countryperc_pov_19数据的子集数据框:

    country = 'China'
    df =\
    poverty[poverty['Country Name']==country][['year', perc_pov_19]].dropna()
    
  6. 创建一个Figure,然后使用相关方法添加一个散点图。mode参数应该给出之前讨论的选项之一,这里仅以mode显示:

    fig = go.Figure()
    fig.add_scatter(x=df['year'],
                    y=df[perc_pov_19],
                    text=df[perc_pov_19],
                    mode=mode)
    fig.show()
    

图 6.1展示了运行前述代码时对于mode的每个可能选项的效果,图表标题显示了如何设置该选项:

图 6.1 – 设置散点图模式参数的不同方式

图 6.1 – 设置散点图模式参数的不同方式

你还可以看到图 6.2中的其他选项:

图 6.2 – 设置散点图模式参数的其他方法

图 6.2 – 设置散点图模式参数的其他方法

Plotly Express 为散点图和线图提供了独立的函数。你可以使用 scatter 函数绘制文本,可以通过选择 DataFrame 中包含文本的列,或提供一个文本元素列表来实现。Plotly Express 的 scatter 函数包含一个 text 参数,可以用来处理这个任务。

现在,我们来看一下如何利用这段代码创建多个散点图轨迹。

在单个图表中创建多个散点轨迹

我们将尽可能专注于使用 Plotly Express,因为它的便利性以及之前在第四章《数据处理与准备——为 Plotly Express 铺路》中讨论过的其他优点。尽管如此,了解如何使用 Figure 对象仍然非常重要,因为你会遇到许多需要与之打交道的情况,特别是当你需要进行大量自定义时。此外,尽管 Plotly Express 支持最重要的图表类型,但并非所有类型都得到支持。

让我们通过为其他国家添加轨迹来扩展前面的图表,并比较这两种方法。我们从 graph_objects 模块的 Figure 对象开始:

  1. 创建一个 countries 列表进行过滤:

    countries = ['Argentina', 'Mexico', 'Brazil']
    
  2. 创建一个 poverty 的子集,我们称之为 df,其中 Country Name 列的值在 countries 列表中(使用 isin 方法)。然后,我们提取 yearCountry Nameperc_pov_19 列,并删除缺失值:

    df = (poverty
          [poverty['Country Name'].isin(countries)]
          [['year','Country Name', perc_pov_19]]
          .dropna())
    
  3. 创建一个 Figure 对象,并将其分配给一个变量 fig

    fig = go.Figure()
    
  4. 现在,我们想为每个要绘制的国家添加一个轨迹。可以通过遍历国家并创建一个仅包含当前国家数据的子 DataFrame 来完成:

    for country in countries:
        df_country = df[df['Country Name']==country]
    
  5. 我们现在在相同的循环中(且具有相同的缩进级别)通过使用 add_scatter 方法添加一个新轨迹。注意,我们设置了 mode='markers+lines',并使用 name 属性设置了该轨迹在图例中的标题:

        fig.add_scatter(x=df_country['year'],
                        y=df_country[perc_pov_19],
                        name=country,
                        mode='markers+lines')
    
  6. 我们还需要为 y 轴添加标题,然后我们就可以轻松地显示图形:

    fig.layout.yaxis.title = perc_pov_19
    fig.show()
    

运行前面的代码将生成 图 6.3 中的图表:

图 6.3 – 使用 graph_objects 模块创建多个散点图

图 6.3 – 使用 graph_objects 模块创建多个散点图

现在,让我们将其与使用 Plotly Express 的方法进行比较。生成图表的代码简洁明了,几乎不需要解释:

px.scatter(df, x='year', y=perc_pov_19, color='Country Name')

我们为 data_frame 参数选择了值,并从 df 中选择了我们想要的 xycolor 参数的列。然后,代码生成了 图 6.4 中的图表:

图 6.4 – 使用 Plotly Express 创建多个散点图

图 6.4 – 使用 Plotly Express 创建多个散点图

我们还会自动给坐标轴加上标签,图例也会正确标注,甚至图例还会有标题,使用我们为color参数选择的列名。

不过,有一个小问题。与前面的图表相比,断开的点并不像之前那样容易阅读。这在本例中尤其重要,因为我们正在表达一系列事件,而这些线条使得表达更加清晰。对于交互式仪表板,我们无法预测用户将选择什么,这意味着他们可能会生成比这个图表更难以阅读的图表。Plotly Express 为散点图和线图提供了独立的函数,因此,为了使其成为“线条+标记”图表,我们需要将其分配给一个Figure对象,然后添加线条轨迹。以下是执行此操作的步骤:

  1. 创建一个Figure对象,并将其赋值给一个变量fig

    fig = px.scatter(df,
                     x='year',
                     y=perc_pov_19,
                     color='Country Name')
    
  2. 创建另一个Figure对象,完全像上一个一样,只是名称和图表类型不同:

    fig_lines = px.line(df,
                        x='year',
                        y=perc_pov_19,
                        color='Country Name')
    
  3. fig_lines中,我们希望将其轨迹添加到fig中。如果你记得,轨迹可以在Figure对象的data属性下找到。data属性是一个元组,每个元素对应一个轨迹。因此,我们需要遍历这些轨迹(即data属性中的轨迹),并将它们添加到fig中:

    for trace in fig_lines.data:
        trace.showlegend = False
        fig.add_trace(trace)
    fig.show()
    

请注意,每个新的线条轨迹都会在图例中有其标签。因此,我们会在图例中看到重复的线条标签,我们需要将它们去掉。我们通过将每个轨迹的showlegend属性设置为False来解决这个问题。运行这段代码会生成图6.5

图 6.5 – 使用 Plotly Express 创建多个散点图和线图

图 6.5 – 使用 Plotly Express 创建多个散点图和线图

比较用两种不同方法生成相同图表所需的脑力和代码量,我们可以看到并没有太大区别。这是当你想要生成自定义的或足够适合发布的内容时的典型情况。不过,对于探索目的来说,Plotly Express 显然是一个很好的起点,一旦你对数据有了足够的了解,你可以更好地决定采用哪种方法。

我们已经看过了如何在散点图中隐式地管理颜色(颜色是自动设置的),现在我们准备探索更多的颜色管理选项。如果你正在阅读打印的黑白版,你会看到不同的颜色阴影,这些阴影可能是可区分的,但正如我们之前所做的,我们还将使用符号来明确表示,使其易于理解。

现在,让我们来探索管理颜色的不同选项。

使用散点图进行映射和设置颜色

颜色在传递和表达我们图表中的信息中至关重要。这也是一个非常大的话题,完整讨论超出了本书的范围。我们将重点讨论两种类型变量的颜色——离散和连续。我们还将处理在图表中使用颜色的两种方式:将变量映射到颜色和手动设置颜色。

我们首先探索这两种类型变量之间的差异。

离散和连续变量

简单来说,连续变量是指在一定范围内可以取无限多个可能值的变量。例如,人口是一个可以取任何值的数字,基于一个国家的人口数量。连续变量通常是数字(整数或实数)。身高、体重和速度也是其他示例。

另一方面,离散变量是可以取有限集合中任一项值的变量。最重要的是,离散变量不能取这些项之间的值。国家就是一个这样的例子。一个国家要么是国家 A,要么是国家 B,但不能是 10%的 A 和 90%的 B。离散变量通常是文本变量,且通常具有相对较少的独特项。

我们使用颜色表达变量性质的方式如下:

  • 对于连续变量,我们使用一种颜色渐变,随着其代表的值变化,颜色会在两种或多种颜色之间逐渐变化。例如,如果我们的颜色渐变从最低值为白色,最高值为蓝色,那么其中的所有值将会呈现白色和蓝色的不同深浅。一个颜色中蓝色比白色多的标记,意味着它的值更接近该变量的最大值,反之亦然。我们稍后会尝试这个。

  • 离散变量是不同的项,我们为它们使用的颜色需要尽可能地彼此区分,尤其是那些彼此相邻的颜色。通过一些示例可以更清楚地理解这一点,我们从连续变量开始。

使用颜色表示连续变量

使用我们开始时的相同指标,我们希望选择一个任意年份,并绘制每个国家的指标值。我们已经知道如何做到这一点。现在,我们想为我们的图表添加一个新的维度。我们希望使用颜色来表示另一个值,例如人口。这将使我们能够看到人口和我们正在绘制的指标(此处为$1.90 贫困线)之间是否存在相关性。让我们准备好我们的变量和数据:

  1. 创建所选指标和年份的变量:

    indicator = perc_pov_19
    year = 1991
    
  2. 使用指标和年份,我们创建一个poverty子集,其中年份列等于我们的变量year,并且is_country列为True。然后我们删除缺失值并根据这一列对数据进行排序。以下代码实现了这一点:

    df =\
    poverty[poverty['year'].eq(year) & poverty['is_country']].dropna(subset=[indicator]).sort_values(indicator)
    
  3. 我们只需要选择我们想要映射其值到适当颜色的列,然后像平时一样使用 Plotly Express:

    px.scatter(df,
               x=indicator,
               y='Country Name',
               color='Population, total')
    

上述代码生成了图 6.6中的图表:

图 6.6 – 使用 Plotly Express 设置连续变量的颜色

图 6.6 – 使用 Plotly Express 设置连续变量的颜色

我们基本上是为我们的可视化添加了一个新层次,即我们选择的那一列。每一个视觉属性都为图表添加了一个维度,使得图表更加丰富,但添加过多的维度可能会让图表显得过于复杂,难以阅读。我们需要找到一个合适的平衡,确保所展示的内容既有意义又易于阅读,适合我们的观众。

我们可以立刻看到,图表中人口最多的国家(美国,亮黄色)在我们的指标中是最小的值之一。我们也可以看到,由于其他大多数标记的颜色更接近紫色,这表明人口最多的国家在该指标上的值相较于其他国家来说非常极端。虽然它在人口上看起来像是一个异常值,但在贫困指标上却不是如此。当我们将鼠标悬停在标记上时,弹出的信息框也采用相同的颜色,而且由于它比标记大得多,因此很容易将颜色与其在颜色条上的相对位置关联。颜色color_scale_continuous。我们可以在图 6.7中看到如何做到这一点,并了解其效果,我们选择了cividis尺度:

图 6.7 – 选择不同的连续颜色尺度

图 6.7 – 选择不同的连续颜色尺度

这个图表没有额外的信息,唯一的变化是我们将颜色尺度更换成了不同的一个。这个颜色尺度很直观,颜色在深蓝色和亮黄色之间变化,并涵盖了所有介于两者之间的颜色组合。该尺度也被称为“顺序”尺度,因为它展示了从低值到高值的变化。你可以通过运行px.colors.named_colorscales()获取所有命名的颜色尺度列表,该命令将返回这些尺度的名称。更有趣的是,你可以查看并比较所有这些尺度,从而选择你想要的一个。你可以通过运行px.colors.sequential.swatches()生成一个包含所有可用顺序色阶的图表,部分输出如图 6.8所示:

图 6.8 – Plotly 中可用的前几个顺序色阶

图 6.8 – Plotly 中可用的前几个顺序色阶

另一个展示颜色尺度效果的有趣方式是使用swatches_continuous函数。例如,图 6.9展示了运行px.colors.sequential.swatches_continuous()的结果:

图 6.9 – Plotly 中可用的前几个顺序色阶,显示在颜色条中

图 6.9 – Plotly 中的前几个连续比例尺,它们在色条中的显示效果

这样更好地展示了它们实际的外观,并展示了颜色之间的平滑过渡。

您可以使用swatches功能来获取其他类型的颜色标度和序列。只需运行上一个命令,并用以下任何一个替换sequentialcartocmoceancolorbrewercyclicaldivergingqualitative

到目前为止,我们已经通过选择要使用的列的值自动映射了数据值和颜色。还有手动设置颜色标度的选项。

手动创建颜色标度

一种方法是通过为color_continuous_scale参数提供两种或更多颜色的列表来实现。默认情况下,您提供的第一种颜色将分配给最小值,最后一种颜色将分配给最大值。中间的值将采用这两种颜色的组合,产生两种颜色的阴影。这显示了数据点距离极端值有多近。稍后我们将看到使用两种以上颜色的示例。使用相同的代码并设置color_continuous_scale=["steelblue", "darkorange"]将生成 图 6.10 中的图表:

图 6.10 – 手动设置连续颜色标度

图 6.10 – 手动设置连续颜色标度

这让人一窥选项的精细程度,但这只是表面。有时,您可能希望重新调整数据,使颜色从最小值到最大值呈现更平滑的过渡。我们刚刚创建的图表是这样的一个好例子。在人口方面我们有一个离群值,所以如果我们想要这样做,最好将color参数设置为我们数据的一个经过缩放的版本。总的来说,因为有许多经过良好验证和测试的比例尺可供选择,所以最好从中选择,而不是手动设置您自己的颜色。另一个重要考虑因素是色盲,尝试使用适合患有色盲症的人群的比例尺。您不希望使用一些读者无法区分的颜色。您可以通过在线搜索来简单检查一个颜色标度是否适合色盲人群。

现在让我们设置一个使用三种颜色的比例尺。RdBu(红蓝)比例尺从红色变到蓝色,中间值取白色作为它们的颜色。这是其中一个默认的比例尺。让我们用这个比例尺快速绘制一个简单的图表:

y = [-2, -1, 0, 1, 2, 3, 4, 5, 6]
px.scatter(x=range(1, len(y)+1),
           y=y,
           color=y,
           color_continuous_scale='RdBu')

我们创建了一个在范围[-2, 6]内的整数列表,并将它们的颜色映射到RdBu比例尺,生成了 图 6.11 中的图表:

图 6.11 – 手动设置连续的分歧颜色标度

图 6.11 – 手动设置连续的分歧颜色标度

在这种情况下,你可以看到颜色从红色到白色再到蓝色的过渡,经过每种颜色的中间色调。这也被称为“分歧”色标。这里有一个中点(在这个例子中是白色点),颜色在这个点分歧,表示两种不同类型的值。通常,我们用这个来显示高于和低于某个特定值的值。在这种情况下,我们想用红色表示负值,白色表示零值,蓝色表示正值。但我们没有得到这个结果。白色的中点被设置为数据的中点,而这个中点恰好是我们列表中的第五个元素,其值为 2。

这可以通过使用color_continuous_midpoint参数来修正,正如你在图 6.12中看到的那样:

图 6.12 – 手动设置连续分歧颜色尺度的中点

图 6.12 – 手动设置连续分歧颜色尺度的中点

我们现在有了一个更有意义的中点,在这个中点上,颜色的分歧使得正负值一目了然。另一个重要的效果是,它还展示了数据的偏斜程度。请注意,图表中没有红色标记。我们有两个粉红色标记,而蓝色值则更多。这与数字列表完全对应,列表中包含两个负值和六个正值。颜色条也清楚地表明,我们只覆盖了红色光谱的一部分,而蓝色则完全覆盖。

还有许多其他选项可用于设置颜色、缩放数据以及表示不同的值。我鼓励你进一步了解这个主题,幸运的是,Plotly 提供了许多选项,可以让你按自己的需求定制颜色。

现在让我们来看看颜色如何与离散变量一起使用。

使用颜色与离散变量

现在的目标不是可视化值之间的差异程度。我们现在想根据某个标准对值进行分组,并查看这些值组之间的差异。如果我们简单地将color参数设置为具有文本值的列,立刻就能看到结果。例如,我们可以设置color="Income Group"来获得图 6.13中的图表:

图 6.13 – 使用颜色与分类变量

图 6.13 – 使用颜色与分类变量

一切都自动为我们处理了。仅仅因为我们选择了一个具有文本值的列,Plotly Express 就根据该列对数据进行了分组,并选择了一组彼此不同的颜色,使我们能够看到不同组之间值的变化。我们还使用了符号,使其在特别是灰度版本的图表中更易于查看。这是通过设置symbol='Income Group'实现的。

与连续变量一样,我们也可以通过提供一个颜色序列给color_discrete_sequence参数,来自定义自己的离散颜色序列。图 6.14展示了设置此参数后的效果,使用的是 Plotly 提供的其中一种颜色序列:

图 6.14 – 为分类变量设置不同的颜色序列

图 6.14 – 为分类变量设置不同的颜色序列

请注意,我们通过从可用列表中选择一个序列px.colors.qualitative.G10,并且正如你可能猜到的,你可以通过运行px.colors.qualitative.swatches()来生成所有可用的颜色序列。

就像我们对待连续变量一样,我们也可以通过提供一个命名颜色的列表,手动设置离散变量的颜色。我们还可以使用颜色的十六进制表示,例如#aeae14,或 RGB 值,例如'rgb(25, 85, 125)'。将我们选择的颜色传递给color_discrete_sequence参数后,我们得到的图表如图 6.15所示:

图 6.15 – 为分类变量手动设置颜色序列

图 6.15 – 为分类变量手动设置颜色序列

当你手动选择所需颜色时,必须确保提供的颜色列表元素数量与你试图可视化的变量的唯一值数量相同。否则,它会循环使用你提供的颜色,这可能会导致误导。再次强调,通常最好选择可用的已建立的颜色序列,但如果你愿意,你也可以手动设置。当我们设置我们想要的颜色时,并没有指定哪一项应该使用哪个颜色。我们只是简单地声明我们希望唯一值使用这组颜色。有时,你可能希望明确地将某些颜色映射到特定类别。一旦知道了唯一值,你可以将字典提供给color_discrete_map参数,然后将每个值映射到你选择的颜色:

color_discrete_map={'High income': 'darkred',
                    'Upper middle income': 'steelblue',
                    'Lower middle income': 'orange',
                    'Low income': 'darkblue'}

设置此选项会生成图 6.16中的图表:

图 6.16 – 为分类变量的每个值手动设置颜色

图 6.16 – 为分类变量的每个值手动设置颜色

请注意,大多数连续变量参数包含“scale”,而离散变量则包含“sequence”。这有助于记住并理解在将颜色映射到这两种变量时的基本区别。

对于连续变量,我们使读者能够根据颜色大致看到标记的值以及在数据集中的相对位置。这并不是非常清晰,但你可以大致看出某个国家的人口约为两千万,而且它看起来是该数据集中人口最多的国家之一。当然,用户可以悬停并查看精确值。如果是离散变量,我们主要更关心通过这些变量进行分组,并查看这些组之间的趋势。

我们展示了颜色处理的一小部分内容,现在我们将考虑一些可能在散点图中出现的其他问题,即异常值和绘制大量数据点。

通过管理不透明度、符号和尺度来处理重叠绘制和异常值

假设我们现在对查看变量与人口之间的关系感兴趣,且我们仍然使用我们之前工作的年份。我们希望将Population, total放在* x 轴上,将perc_pov_19放在 y *轴上。

我们首先创建一个poverty的子集,其中year等于 2010,is_countryTrue,并使用Population, total对值进行排序:

df =\
poverty[poverty['year'].eq(2010) & poverty['is_country']].sort_values('Population, total')

现在让我们来看一下当我们绘制这两个变量时,结果是什么样子的。下面是代码:

px.scatter(df,
           y=perc_pov_19,
           x='Population, total',
           title=' - '.join([perc_pov_19, '2010']),
           height=500)

运行此操作将生成图 6.17

图 6.17 – 图表中的重叠绘制和异常值

图 6.17 – 图表中的重叠绘制和异常值

存在一个异常值——中国,其人口接近 14 亿,这迫使所有标记被压缩到图表的一个非常窄的部分。我们还看到y-轴上有一小群数值超过 25,但差异远没有水平轴上的差异那样极端。另一个重要的问题是,许多标记彼此重叠。如果使用纯色标记,这意味着如果一个标记叠加在另一个标记上,结果不会有任何区别;即使是一千个标记也是如此。这两个问题同时存在,使得这个图表变得非常难以阅读。

我们将探索一些可能有助于这些情况的技术,并评估它们何时以及如何可能有用。

由于我们有许多点挤在图表的一个非常小的部分,我们很可能会有几个点重叠。让我们来看一下改变标记的不透明度和大小的效果。

控制标记的不透明度和大小

opacity参数的取值范围是[0, 1],包含 0 和 1。我们可以手动指定一个数字来控制我们希望标记的透明度。值为0表示完全透明,这也可以看作是隐藏标记(或它们的一个子集)的一种方式。值为1表示标记将完全不透明,呈现分配给它们的颜色,并完全覆盖它们所在的区域。这也意味着opacity0.1时,标记的透明度为 10%。这意味着需要将 10 个标记叠加在一起,才能完全覆盖它们所在的区域。如果我们将其设置为0.5(或 50%),这意味着两个标记将完全覆盖该区域,依此类推。

由于标记点较小,并且我们没有那么多的数值,我们也可以增加它们的大小,以便更好地观察。size参数,像其他所有参数一样,可以取我们 DataFrame 中某列的名称,或者是数字列表。这是我们用来表达某一列值的另一个视觉属性,其中相对大小反映了每个标记所代表的相对值。它有时也被称为气泡图。对于这种情况,我们希望提供一个固定的大小。这可以通过提供与我们分析的 DataFrame 长度相同的列表来轻松实现。这将给标记一个统一的默认大小,可能这不是我们想要的,所以我们可以通过size_max参数来控制它。重新使用相同的代码,并设置opacity=0.1size=[5]*len(df)size_max=15,我们可以得到图 6.18中的图表:

图 6.18 – 修改标记的透明度和大小

图 6.18 – 修改标记的透明度和大小

这看起来稍微好一些。我们有了更大的标记,且opacity0.1时,我们可以更清楚地看到大部分标记集中在原点附近。很可能还有更多的细节,但由于我们有异常值,那些差异看起来非常小。

透明度和可见性之间总是存在一个权衡。你的标记越透明,你就能越清楚地看到,特别是在有成百上千个标记的情况下。但与此同时,它们可能会变得如此透明,以至于你什么也看不见。在0.1opacity下,我们正在接近那个阶段。

现在,让我们来看一下另一种技巧,它涉及到在坐标轴上使用对数刻度。

使用对数刻度

正常的刻度是直观且易于理解的。就像物理物体一样,一块木板的长度是另一块的两倍,它包含的木材也是两倍,前提是它们的宽度和深度相同。例如,在前面的两张图中,0 和 0.2 亿之间的距离与 0.2 和 0.4 亿之间的距离是相同的。这个“数据距离”也是相同的。在正常刻度下,在这个例子中,每个刻度对应的是一定量的增加(在此例中是 0.2 亿)。而在对数刻度下,每增加一个刻度,就意味着前一个刻度的倍数。

例如,数字 10、20、30 和 40 形成了一个典型的序列,这是你在正常刻度上可能看到的。如果刻度是对数刻度,我们不会再加 10,而是使用log_x=True,我们会得到更新后的图表,见图 6.19

图 6.19 – 使用对数刻度

图 6.19 – 使用对数刻度

现在我们的图表看起来有了很大不同,但实际上它仍然是相同的图表。请注意,我们已将opacity值改为0.25,因为0.1太难看清,而且由于标记现在比之前更分散,我们有了一个更加细致的视图,了解人口是如何分布的。我们可以看到,最不透明的部分是在一千万附近。与 14 亿相比,这几乎为零,这也是之前图表告诉我们的内容,但现在我们有了更好的视角。

请注意,主要刻度每个都比前一个大 10 倍(10k100k1M10M100M1B),或者每增加一个主要刻度就加一个零。同时,我们可以看到次要刻度,25,意味着这些位置分别表示前一个主要刻度值的两倍和五倍。

让我们探索另一种可能在这种情况下考虑的选项。这次我们不使用任何透明度,但我们通过更改使用的符号给标记引入了大量空间。设置符号的方式与设置离散颜色一样,可以通过symbol_sequence参数来管理,它将循环遍历我们提供的选项,并为列中的每个唯一离散值分配一个符号。我们为其提供一个包含单一值的列表,因此所有标记将使用相同的符号。

现在我们移除opacity参数,并将其替换为symbol_sequence=['circle_open'],得到如图 6.20所示的新图表:

图 6.20 – 修改标记的符号

图 6.20 – 修改标记的符号

这可能更好,因为我们通过更改透明度并没有牺牲任何可见性。我们达到了查看标记集中的位置的目的,因为需要很多开放的圆圈才能完全覆盖一个区域。对数坐标轴使标记在水平方向上分布,这样就更容易看出它们的分布情况。刻度标签清楚地显示了数值,但如果我们的受众不熟悉这种尺度,我们可能需要使其非常明确和直观。

我们可以想象为用户提供我们刚刚尝试的所有选项。我们可以考虑设置一个组件,允许用户修改透明度,另一个组件切换正常和对数坐标轴,或许还有一个组件用于更改符号。理想情况下,我们不应该让用户在阅读图表时感到困难。最好是我们自己做这项工作,探索数据后提供合理的默认值。根据我们目前所做的探索,我们来考虑这些默认值可能是什么。

我们知道这个图表绘制的是各个国家的数据,并且它们的数量不能超过 200 个。这意味着我们可以为这些标记设置一个适合的默认透明度级别。标记数量达到数千时,可能需要更低的opacity级别,比如0.02。空心圆似乎在引入空间方面对我们有很好的效果,所以我们也可以选择空心圆作为默认符号,完全忽略透明度问题。size参数也是如此。我们知道我们绘制的是人口数据,而且它很可能总是包含离群值,就像这个例子一样,因此我们可能会保持对数刻度作为默认值。

一种更通用的互动图表可能允许用户修改他们想要探索的指标。在这种情况下,我们可能会为他们提供这些选项。然而,随着自由度和通用性的增加,用户将需要自己处理更多的数据处理细节。

我们已经对我们的指标有了很好的理解,并且看到了许多国家的示例。正如我们刚刚看到的,这个探索过程对于构建具有合理默认设置的仪表盘至关重要。现在让我们来探讨本章的新互动组件——Slider组件。

引入滑块和区间滑块

SliderRangeSlider组件基本上是用户可以水平或垂直拖动的圆形控件,用于设置或更改某个值。它们通常用于设置连续值,因为它们的外观和拖动功能非常适合这一用途。但这并不是强制要求,因为我们也可以将它们用于分类/离散值。我们已经看到perc_pov_指标有三个级别,并且我们知道可以从数据集中选择所有年份。现在,我们希望创建两个滑块。一个让用户选择他们想要分析的贫困水平,另一个让他们选择年份。每种选择组合将创建一个不同的子集,并生成不同的图表。图 6.21显示了我们将要实现的最终结果的上部分:

图 6.21 – 两个滑块控制图表

图 6.21 – 两个滑块控制图表

如你所见,新的功能需要三个主要组件——两个Slider组件和一个Graph组件。当然,我们还有其他一些组件来控制布局,以及标签,但重点主要是如何创建和集成这个新功能。

重要提示

RangeSlider组件几乎与Slider组件相同。主要的区别在于它包含多个滑块手柄,用户可以在其中调整他们希望筛选数据的最大值和最小值。现在,我们将重点关注普通的Slider组件,而RangeSlider组件将在后续章节中讨论。

和往常一样,我们将把这个创建为 JupyterLab 中的独立应用程序,一旦它正常工作,我们就将其添加到应用中。首先,让我们了解 Slider 组件,了解它是如何工作的,然后创建我们的应用布局。

你可以创建一个最小化的应用程序,并在应用的布局中创建 Slider 组件,就像你用其他组件一样,通过调用 dcc.Slider()

app = JupyterDash(__name__)
app.layout = html.Div([
    dcc.Slider()
])
app.run_server(mode='inline')

这将创建一个包含单个组件的简单应用,正如你在图 6.22中看到的那样:

图 6.22 – 一个简单的滑块组件

图 6.22 – 一个简单的滑块组件

这样视觉上很容易使用,用户可以清楚地看到他们可以水平滑动圆圈。然而,目前没有任何指导,用户也不知道他们在修改什么值,因此我们需要修复这一点。我们将从创建我们的第一个滑块开始,包含我们正在分析的三种贫困水平。让我们看看我们将要使用的参数:

  • min:顾名思义,这是滑块的最小值。

  • max:这也设置了值的上限。

  • step:当我们从 min 滑动到 max 时,增量的大小应该是多少?默认情况下,它设置为 1,但你可以将其设置为更高或更低的值。例如,如果你希望用户调整透明度,你可以设置 min=0max=1step=0.01。这将为用户提供 100 个选项可供选择。

  • dots:滑块是否应显示圆点,还是应该是简单的线条?在我们的例子中,我们希望用户从三个不同的值中选择,所以将此选项设置为 True 是有意义的。

  • included:请注意,图 6.22 中滑块左侧的蓝色部分和右侧的灰色部分。在滑动过程中,蓝色部分会随着滑块的滑动而扩展/收缩,这是默认行为。在我们的例子中,我们提供了三个不同的选项,因此我们希望移除这种行为来强调这一点,因此我们将其值设置为 False

  • value:这是滑块应取的默认值。

这是一个 Slider 组件的示例,范围从 010

dcc.Slider(min=0,
           max=10,
           step=1,
           dots=True,
           included=False)

这生成了图 6.23中的新滑块:

图 6.23 – 带有自定义选项的滑块组件

图 6.23 – 带有自定义选项的滑块组件

现在,圆点引导用户选择位置,并且提示选项彼此是不同的,特别是我们设置了 included=False

Slider 组件的另一个重要参数是 marks 参数。我们需要向用户展示每个圆点对应的值。在某些情况下,如果没有足够的空间显示所有值,我们会跳过一些值。在我们的年份滑块中会有这种情况,但首先让我们创建贫困指标滑块。我们首先不使用 marks 参数创建它,然后再添加该参数:

dcc.Slider(id='perc_pov_indicator_slider',
           min=0,
           max=2,
           step=1,
           value=0,
           included=False)

对于id,与其他变量一样,我们遵循了以perc_pov_开头的规则,以便与应用中的其他相关对象保持一致。回调函数将从这个组件接收到的值将是012,这是基于我们给出的minmaxstep参数的。现在,这些值在我们的情况中没有实际意义,因为我们实际上希望得到指标名称的完整文本。我们可以通过获取滑块的值并将其用作我们创建的perc_pov_cols列表的索引来简化处理。在回调函数中,我们将使用这个整数值来提取相应的指标。稍后我们会在构建回调函数时看到这一点。现在,让我们来创建滑块的刻度标记。

自定义滑块的刻度标记和值

创建这些刻度标记的最简单方法是使用字典:{0: '$1.9', 1: '$3.2', 2: '$5.5'}。字典的键将作为value属性使用,而字典的值将是用户在每个贫困水平上看到的内容。对于我们的情况,这就足够了,我们可以按此使用。

我们还可以选择性地自定义标签的样式,这可以是任何 CSS 属性的字典。如果你查看图 6.21,你会看到两个滑块的刻度(数字)颜色非常浅,可能会给人一种它们属于同一个滑块的印象。我们可以通过将它们的颜色设置为深色来改进这一点。我们还可以为指标滑块设置粗体字体。这有助于将它们与年份区分开来,并且也能突出它们的独特性。年份是容易立即理解的,但用户很可能不熟悉数据集中追踪的贫困水平。

我们希望获得与我们的图表一致的颜色。由于我们将使用 cividis 色标,因此这是一个了解如何提取其颜色的好机会。px.colors.sequential模块包含了顺序色标的颜色列表,其中就包括了 cividis。我们可以通过运行以下命令来获取 cividis:

px.colors.sequential.Cividis
['#00224e',
 '#123570',
 '#3b496c',
 '#575d6d',
 '#707173',
 '#8a8678',
 '#a59c74',
 '#c3b369',
 '#e1cc55',
 '#fee838']

我们收到的列表包含了实际上用于构建该色标的 10 种颜色。回想一下,我们之前尝试过手动使用 2 个和 3 个颜色。还值得注意的是,通过在色标名称后面添加_r,你可以获得色标的反转版本,例如,px.colors.sequential.Cividis_r。这将给我们相同的色标,但在这种情况下,黄色将对应较低的值。

现在,我们希望为刻度标记的标签使用的颜色将是 cividis 色标中的最深色,我们可以很容易地提取并将其赋值给一个变量,如下所示:

cividis0 = px.colors.sequential.Cividis[0]

使用这个方法后,我们现在可以按照以下方式设置marks参数:

marks={0: {'label': '$1.9', 'style': {'color': cividis0, 'fontWeight': 'bold'}}, 
       1: {'label': '$3.2', 'style': {'color': cividis0, 'fontWeight': 'bold'}},
       2: {'label': '$5.5', 'style': {'color': cividis0, 'fontWeight': 'bold'}}}

我们所做的基本上是扩展字典,其中,值不再是字符串,而是以以下形式出现的字典:

 {'label': <label>, 'style': {<attribute_1>: <value_1>, <attribute_2>: <value_2>}

重要提示

通常,CSS 属性如font-sizefont-weight是用连字符分隔的,并且是小写字母书写的。而在 Dash 中,您可以使用相同的属性,但必须去掉连字符,改用驼峰式命名法(如fontSizefontWeight),正如前面的代码片段所示。

类似于刚才所做的,现在让我们创建另一个具有类似自定义的滑块。首先,为了隔离我们的子集,我们可以为这些变量创建一个特殊的 DataFrame:

perc_pov_df =\
poverty[poverty['is_country']].dropna(subset=perc_pov_cols)
perc_pov_years = sorted(set(perc_pov_df['year']))

关键是我们从perc_pov_cols中删除了任何缺失值,并且我们还通过使用sortedset创建了一个排序后的唯一年份列表perc_pov_years

以下代码创建了我们的新滑块,用于选择年份:

dcc.Slider(id='perc_pov_year_slider',
           min=perc_pov_years[0],
           max=perc_pov_years[-1],
           step=1,
           included=False,
           value=2018,
           marks={year: {'label': str(year),
                         'style': {'color': cividis0}}
                  for year in perc_pov_years[::5]})

这与我们为指标所做的几乎相同。我们将默认值设置为 2018 年,这是我们拥有数据的最新年份。如果这是一个动态更新的应用程序,我们也可以将此值设置为perc_pov_years中的最大年份。请注意,我们将标记设置为每五年显示一个。如果不这样做,滑块将非常难以使用。通过这样设置,我们可以看到在图 6.24中字体和颜色的细微差别:

图 6.24 – 更新颜色的滑块

图 6.24 – 更新颜色的滑块

布局的最后部分将是Graph组件:

dcc.Graph(id='perc_pov_scatter_chart')

如我之前所提到的,我们还拥有Label组件,以及ColRow组件,用于更好地管理布局,但这些组件未被讨论,因为我们已经创建了多个使用它们的示例。

现在我们准备好创建回调函数,将我们刚才创建的三个元素链接起来:

  1. 我们首先创建函数的装饰器。这个过程与之前的示例一样简单。唯一的不同之处在于,这次我们有两个输入参数。在函数的定义中,参数的顺序将对应于Input元素的顺序,因此我们将根据顺序命名它们:

    @app.callback(Output('perc_pov_scatter_chart', 'figure'),
                  Input('perc_pov_year_slider', 'value'),
                  Input('perc_pov_indicator_slider', 'value'))
    
  2. 在接下来的部分中,我们创建函数的签名,并写出前几行代码。参数名为yearindicator。我们现在使用indicator值(一个整数)来从perc_pov_cols中获取相应的元素。然后,我们创建变量df,它会过滤perc_pov_df,只保留year年份的数据。接着,我们定义dropnasort_values。有一个年份没有任何数据,但它必须出现在滑块的值中,因此我们需要处理用户选择该年份的情况。我们通过简单的检查if df.empty来完成,如下所示:

    def plot_perc_pov_chart(year, indicator):
        indicator = perc_pov_cols[indicator]
        df = (perc_pov_df
              [perc_pov_df['year'].eq(year)]
              .dropna(subset=[indicator])
              .sort_values(indicator))
        if df.empty:
            raise PreventUpdate
    
  3. 现在我们已经准备好了数据框(DataFrame),可以创建Figure并返回。大部分代码现在应该都很熟悉了。hover_name 参数用于在用户悬停在标记上时显示弹出框标题。将其设置为Country Name会使标题显示相应国家的名称并加粗。我们还使用了上一章中使用过的动态高度技巧,在这里我们设置了固定高度,并为每个国家增加了 20 像素。我们在最后添加的ticksuffix选项应该是显而易见的,用来表明这些值是百分比:

    fig = px.scatter(df,
                     x=indicator,
                     y='Country Name',
                     color='Population, total',
                     size=[30]*len(df),
                     size_max=15,
                     hover_name='Country Name',
                     height=250 +(20*len(df)),
                     color_continuous_scale='cividis',
                     title=indicator + '<b>: ' + f'{year}' +'</b>')
    fig.layout.paper_bgcolor = '#E5ECF6'
    fig.layout.xaxis.ticksuffix = '%'
    return fig
    

向我们的应用添加布局元素和回调后,我们最终获得了额外的功能,通过这两个滑块的组合可以生成超过 130 个图表。图 6.25 显示了最终结果:

图 6.25 – 两个滑块和一个散点图 – 最终结果

图 6.25 – 两个滑块和一个散点图 – 最终结果

恭喜你又为你的应用增加了新功能!这一次,我们创建了第一个多输入回调,它丰富了用户可以生成的选项,而不会让人感到复杂或压倒性。

从功能角度来看,下拉框和滑块之间并没有本质上的区别。我们本来可以使用下拉框来实现同样的功能,并且它也会正常工作。下拉框的优势在于它们在空间利用方面极为高效。一个小矩形就可以容纳数十个甚至数百个隐藏选项,用户可以进行搜索。这些选项可以是很长的字符串,可能无法并排显示在例如滑块旁边。

另一方面,滑块提供了更好的视角。它们隐式地包含了关于选项的元数据。你可以立即看到最小值和最大值,以及它们的分布范围。当你选择一个选项时,你可以很容易地判断你的选择相对于其他可用选项的极端程度。在贫困水平滑块的情况下,用户可以立即看到所有可选项。最后,滑块更类似于我们与物理物品的互动方式,所以使用它们可能比其他互动组件更具吸引力。因此,空间限制、我们分析的变量类型以及我们希望展示的方式是影响我们选择使用哪些互动组件的因素。

你可能注意到我们在如何将组件组合到应用中的讨论上减少了,这其实是有意为之。我们已经多次讨论过这些话题,设计上也希望鼓励你亲自去实践并尝试其他选项。你随时可以回到代码库,检查你的工作以及细节部分。

现在让我们回顾一下本章所涵盖的内容。

总结

我们介绍了散点图,并展示了如何使用graph_objects模块和 Plotly Express 来创建它们。我们学习了如何创建多个轨迹,并尝试了不同的方法。接着,我们讨论了颜色映射和设置,并探讨了连续变量和离散(分类)变量之间的处理差异。我们看到了不同的刻度——顺序型、发散型和定性型。我们还展示了如何设置自定义颜色、序列和刻度。我们还解决了一些问题,比如异常值和过度绘图。我们尝试了不透明度、改变符号和标记大小,并使用对数刻度来使我们的图表更易读。我们还介绍了滑块并学习了它们的工作原理,创建了两个可以协同工作、生成表达三个值的图表的滑块(而之前只有两个值)。然后,我们创建了一个回调函数来管理这些交互,并将其集成到我们的应用程序中。

到目前为止,通过所有示例和技巧的讲解,我们已经接近于以创建幻灯片和演示文稿的方式来创建仪表板。一旦掌握了布局元素,定制任何我们想要的大小和位置就变得非常简单。现在,随着我们探索不同的图表类型和数据可视化技巧,管理和修改这些内容将变得更加容易。

到目前为止,我们所探索的所有图表都使用了常规的几何形状,包括圆形、线条和矩形。在下一章中,我们将探索不规则形状以及如何以地图的形式进行可视化。地图非常吸引人且容易识别,但不像简单的常规形状那样直接可视化。接下来,我们将探讨这一点。

第七章:第七章:探索地图图表并通过 Markdown 丰富仪表板

在本章中,我们将探索如何处理地图,这是最具吸引力的图表类型之一。创建和处理地图的方式有很多种,并且有许多类型的地图图表。地图也有许多专门的地理和科学应用。我们将主要关注两种最常见的地图图表类型:分级地图散点图地图。分级地图是我们最熟悉的地图类型。这些地图通过为地理区域上色来表示一个国家、州、区或任意多边形,并显示它们之间的数量变化。我们在上一章建立的大部分知识可以轻松地应用到散点图地图上,因为它们本质上是相同的,只是有一些差异。类似于xy轴,我们使用的是经度和纬度,并且还有不同的地图投影。我们还将学习 Dash Core Component 中的新组件Markdown

然后,我们将探索如何使用Mapbox,它提供了丰富的接口,包含不同的图层、主题和缩放级别。它还允许我们创建分级地图和散点图地图。

我们将主要覆盖以下主题:

  • 探索分级地图

  • 使用动画帧为图表添加新图层

  • 使用回调函数与地图配合

  • 创建Markdown组件

  • 理解地图投影

  • 使用散点图地图

  • 探索 Mapbox 地图

  • 探索其他地图选项和工具

  • 将交互式地图集成到我们的应用中

技术要求

我们将使用与上一章类似的工具。我们主要使用 Plotly Express 来创建图表。所使用的包包括 Plotly、Dash、Dash Core Components、Dash HTML Components、Dash Bootstrap Components、pandas 和 JupyterLab。

本章的代码文件可以在 GitHub 上找到,地址是github.com/PacktPublishing/Interactive-Dashboards-and-Data-Apps-with-Plotly-and-Dash/tree/master/chapter_07

查看以下视频,了解代码在bit.ly/3sAY8z8中的实际操作。

我们将从探索如何轻松地为国家创建分级地图开始。

探索分级地图

Choropleth 地图基本上是表示地图上某个区域的着色多边形。Plotly 自带了国家地图(以及美国州地图),因此,如果我们有关于国家的信息,绘制地图非常容易。我们的数据集中已经包含了这类信息。每一行都包含了国家名称和国家代码。此外,我们还拥有年份、一些关于国家的元数据(地区、收入组等),以及所有指标数据。换句话说,每个数据点都与一个地理位置相关联。那么,让我们从选择一个年份和一个指标开始,看看我们选择的指标在各个国家之间的变化:

  1. poverty文件加载到数据框中,并创建yearindicator变量:

    import pandas as pd
    poverty = pd.read_csv('data/poverty.csv')
    year = 2016
    indicator = 'GINI index (World Bank estimate)'
    
  2. 创建一个包含所选年份值并仅包含国家的poverty子集:

    df = poverty[poverty['is_country'] & poverty['year'].eq(year)]
    
  3. 使用 Plotly Express 中的choropleth函数创建 choropleth 地图,选择标识国家的列和用于颜色的列:

    import plotly.express as px
    px.choropleth(df, locations="Country Code", color=indicator)
    

你可以在图 7.1中看到前面代码的结果:

图 7.1 – 国家 choropleth 地图

图 7.1 – 国家 choropleth 地图

我们提供的国家代码已包含在 Plotly 中,并且采用三字母 ISO 格式。与散点图一样,你可以看到,由于我们为颜色提供了数值列,因此选择了连续的颜色刻度。否则,我们将得到离散的颜色序列。例如,设置color='Income Group'会产生图 7.2中的图表:

图 7.2 – 具有离散颜色序列的国家 choropleth 地图

图 7.2 – 具有离散颜色序列的国家 choropleth 地图

如你所见,类似于我们在第六章中看到的,通过散点图探索变量并使用滑块过滤子集,颜色系统的工作方式是类似的。

我们也可以使用常规的国家名称来绘制图表。为此,我们只需要设置locationmode='country names',其他的工作方式与之前相同。这里有一个使用国家名称的示例:

px.choropleth(df, 
              locations=['Australia', 'Egypt', 'Chile'], 
              color=[10, 20, 30], 
              locationmode='country names')

这会生成图 7.3中的图表:

图 7.3 – 使用国家名称的国家 choropleth 地图

图 7.3 – 使用国家名称的国家 choropleth 地图

颜色条的标题是color,因为它的含义不明确,而且它不是数据框中某一列的名称。我们可以通过设置labels={'color': <metric_name>}来重新命名它,以指示在我们案例中的度量是什么。现在让我们看看如何使图表具有交互性(不使用回调)。

利用动画帧为图表添加新图层

在上一个示例中,我们将年份设置为一个变量,并获取了该年份所需指标的快照。由于年份代表的是连续的值,并且可以作为分组变量使用,我们可以将年份用于 animation_frame 参数,使图表具有交互性。这将在图表下方引入一个新的控制柄,用户可以拖动它到所需年份,或者按下播放按钮,观看相应指标在各年份中的变化。这将是一个帧序列,就像观看视频一样。这样做的效果是,对于选定的年份,我们将从数据框中获取一个子集,其中 year 列中的行等于所选年份。图表会自动更新,颜色会对应于所选年份的值。

这是更新后的代码,用于生成按年份动画的图表:

fig = px.choropleth(poverty[poverty['is_country']],|
                    color_continuous_scale='cividis',
                    locations='Country Code',
                    color=indicator,
                    animation_frame='year')
fig.show()

现在我们可以看到更新后的图表,见 图 7.4

图 7.4 – 含动画帧的国家区域图

图 7.4 – 含动画帧的国家区域图

如你所见,我们所需做的就是选择一个列名用作 animation_frame,其他一切都由系统自动处理。我们使用了一个仅包含国家的数据框,其中包括所有年份。进一步的子集操作是通过传递给 animation_frame 的参数自动完成的。我们可以拖动控制柄到特定年份,或者按下播放按钮,观看它如何随着时间变化。请注意,我们还更改了颜色比例,尝试使用不同的比例。迄今为止使用的两种颜色比例也应能在灰度版本的地图上读取。

既然我们已经有了基本的地图,接下来让我们探索如何控制地图的多个方面。地图图表的 layout 属性有一个子属性叫做 geo,在其中有几个有用的地理属性,允许我们控制地图的许多方面。这些属性的工作方式与其他属性相同。

我们基本上通过运行 fig.layout.geo.<attribute> = value 来设置所需的值。让我们来探索一些这些属性及其对之前图表的影响:

  • 移除地图周围的矩形框架:

    fig.layout.geo.showframe = False
    
  • 显示国家边界,即使我们没有某些国家的数据:

    fig.layout.geo.showcountries = True
    
  • 使用不同的地球投影。选择 natural earth 投影类型(稍后会详细讲解):

    fig.layout.geo.projection.type = 'natural earth'
    
  • 通过设置地图应显示的最小和最大纬度值来限制图表的垂直范围,从而更专注于各个国家:

    fig.layout.geo.lataxis.range = [-53, 76]
    
  • 使用相同的技术限制图表的水平范围:

    fig.layout.geo.lonaxis.range = [-137, 168]
    
  • 将陆地的颜色改为 'white',以清晰地标识出哪些国家缺少数据:

    fig.layout.geo.landcolor = 'white'
    
  • 设置地图的背景颜色(即海洋的颜色),以及整幅图表的“纸张”背景颜色。使用我们为应用程序设置的相同颜色,以确保主题的一致性:

    fig.layout.geo.bgcolor = '#E5ECF6'
    fig.layout.paper_bgcolor = '#E5ECF6'
    
  • 设置国家边界和海岸线的颜色为 'gray'

    fig.layout.geo.countrycolor = 'gray'
    fig.layout.geo.coastlinecolor = 'gray'
    
  • 由于颜色条的标题占用了很多水平空间,因此用<br>字符替换空格,将标题分成多行:

    fig.layout.coloraxis.colorbar.title =\
    indicator.replace(' ', '<br>')
    

结果,我们得到了更新后的图表,见图 7.5

图 7.5 – 具有自定义地理选项的国家分级图

图 7.5 – 具有自定义地理选项的国家分级图

通过几条命令,我们改变了图表的外观。我们将范围限制为主要集中在国家和陆地上,尽可能减少其他元素。我们还设置了统一的背景颜色,并显示了国家边界。还有一些其他选项可以轻松在fig.layout.geo属性下进行探索。现在我们准备好使指标选择动态化了;让我们看看如何实现。

使用回调函数与地图配合

到目前为止,我们所做的一切都是基于一个指标,使用该指标从数据集中选择所需的列。我们可以轻松创建一个下拉框,允许用户选择任何可用的指标,并让他们探索整个数据集。year变量已经是交互式的,并且是图表的一部分,通过animation_frame参数使用。因此,这可以成为用户在我们的应用程序中开始的第一个交互式探索图表,帮助他们了解可用的指标及其随时间变化的趋势。

设置这个过程非常简单,就像我们之前做过的那样。我们将实现它,之后我们将看到如何使用Markdown组件来为地图图表和所选指标添加上下文。

让我们在 JupyterLab 中独立实现此功能的必要步骤:

  1. 创建一个Dropdown组件,其中可选项是poverty的列名,使用第 3 列到第 54 列之间的列:

    dcc.Dropdown(id='indicator_dropdown',
                 value='GINI index (World Bank estimate)',
                 options=[{'label': indicator, 'value': indicator}
                          for indicator in poverty.columns[3:54]])
    
  2. 在我们刚刚创建的下拉框下,创建一个空的Graph组件:

    dcc.Graph(id='indicator_map_chart')
    
  3. 指标名称的长度各异,有些指标长度很长,几乎占据了整个屏幕的一半。我们可以通过类似之前的方法来处理,创建一个简单的函数。该函数接受一个字符串,将其拆分为单词,每三个单词分为一组,然后用<br>字符连接它们:

    def multiline_indicator(indicator):
        final = []
        split = indicator.split()
        for i in range(0, len(split), 3):
            final.append(' '.join(split[i:i+3]))
        return '<br>'.join(final)
    
  4. 创建一个回调函数,将下拉框与地图图表连接起来:

    @app.callback(Output('indicator_map_chart', 'figure'),
                  Input('indicator_dropdown', 'value'))
    
  5. 定义一个函数,接受所选指标并返回所需的地图图表。请注意,我们通过使用指标作为标题值来设置图形的标题。我们还使用了Country Name列来设置悬停名称,这是当用户悬停在某个国家时出现的框的标题。高度也被设置为650像素。其余的地理属性在此省略,以避免重复,但它们与我们之前设置的相同。我们还使用刚才创建的multiline_indicator函数来修改颜色条的标题:

    def display_generic_map_chart(indicator):
        df = poverty[poverty['is_country']]
        fig = px.choropleth(df,
                            locations='Country Code',
                            color=indicator,
                            title=indicator,
                            hover_name='Country Name',
                            color_continuous_scale='cividis',
                            animation_frame='year',
                            height=650)
        fig.layout.geo.showframe = False
        …
        fig.layout.coloraxis.colorbar.title =\
    multiline_indicator(indicator)
    

在 JupyterLab 中运行该应用程序,你可以探索不同的指标。图 7.6展示了通过选择不同的指标和年份生成的图表的几个示例:

图 7.6 – 交互式生成的地图图表示例

图 7.6 – 交互式生成的地图图表示例

一旦提供选项,用户可以在下拉菜单中搜索各种关键词并选择他们感兴趣的内容。然而,许多指标的具体含义和它们的限制仍然不太清晰。现在是我们向用户展示这些细节的好机会,以便让他们明确看到自己正在查看的内容。如前所述,度量中的限制至关重要,必须加以强调,以确保用户意识到它们。让我们来看一下如何使用Markdown组件添加格式化文本。

创建一个 Markdown 组件

Markdown 是一种以易于编写和易于阅读的方式生成 HTML 的方法。输出将像任何 HTML 文档一样显示,但编写和阅读过程要容易得多。比较以下两个代码片段,它们生成相同的 HTML 输出:

使用纯 HTML,我们将编写如下代码:

<h1>This is the main text of the page</h1>
<h3>This is secondary text</h2>
<ul>
  <li>first item</li>
  <li>second item</li>
  <li>third item</li>
</ul>

相同的代码可以用 Markdown 编写如下:

# This is the main text of the page
### This is secondary text
* first item
* second item
* third item

我认为很明显,Markdown 更容易编写,也更容易阅读,特别是当你有嵌套项(例如我们这里的<ul>无序列表)时。

Markdown组件的工作方式相同。上面的代码只需传递给children参数,它将呈现为之前展示的 HTML。让我们在 JupyterLab 中创建一个最小的应用程序,看看Markdown组件是如何工作的:

  1. 进行必要的导入并实例化应用程序:

    from jupyter_dash import JupyterDash
    import dash_core_components as dcc
    app = JupyterDash(__name__)
    
  2. 创建应用程序的布局属性:

    app.layout = html.Div([])
    
  3. 将带有前述文本的Markdown组件传递给刚刚创建的div。请注意,尤其在处理多行文本时,使用三引号更为方便:

    dcc.Markdown("""
    # This is the main text of the page
    ### This is secondary text
    * first item
    * second item
    * third item
    """)
    
  4. 运行该应用程序:

    app.run_server(mode='inline')
    

上述代码创建了一个迷你应用程序,输出如图 7.7所示:

图 7.7 – Markdown 组件的示例输出

图 7.7 – Markdown 组件的示例输出

Markdown 有多种显示文本的方式,如编号列表、表格、链接、粗体和斜体文本等。我们将介绍其中的一些功能,但即使你不熟悉它们,也很容易掌握。请记住,不同平台使用的 Markdown 有各种“变种”。你可能会遇到稍有不同的标记/语法规则,但一般来说,它们有很多重叠之处。

我们现在将在用户选择了他们想要的指标后,向地图中添加一些信息。基本上,我们会将重要信息添加到地图和滑块下方。图 7.8展示了这将如何显示,以便给你一个我们正在努力实现的目标的概念:

图 7.8 – Markdown 组件的示例

图 7.8 – Markdown 组件的示例

你在图中看到的所有文本和格式都是由Markdown组件生成的。

为了在应用中为其创建一个特殊区域,我们只需在地图下方添加一个 Markdown 组件,并为其指定一个唯一的 ID。

生成这个组件的过程将发生在我们为生成地图所创建的相同回调函数中。该回调函数现在应该接收两个 Output 元素而非一个,并且返回时应该返回两个元素(图形和生成的 Markdown)。为了获取该组件所需的内容,我们需要打开包含所有指标详细信息的文件。这之前已经做过,但作为提醒,我们可以通过运行 series = pd.read_csv('data/PovStatsSeries.csv') 来获得。现在让我们实现这些步骤:

  1. Graph 组件正下方,添加新的 Markdown 组件(注意,我们还将其背景颜色设置为与地图一致,以保持整个应用的一致性)。_md 后缀表示 Markdown

    dcc.Markdown(id='indicator_map_details_md',
                 style={'backgroundColor': '#E5ECF6'})
    
  2. 更新回调函数,加入新组件:

    @app.callback(Output('indicator_map_chart', 'figure'),
                  Output('indicator_map_details_md', 'children'),
                  Input('indicator_dropdown', 'value'))
    
  3. 在回调函数中完成 fig 变量的定义后,我们现在执行创建 Markdown 输出所需的步骤。通过获取 Indicator Name 列等于所选指标的行,创建 series 的适当子集:

    series_df =\
    series[series['Indicator Name'].eq(indicator)]
    
  4. series_df 中提取 Limitations and exceptions 列的值。请注意,由于一些值缺失,并且缺失的值不是字符串,我们用字符串 N/A 填充它们,并且如果存在,替换任何两个换行符 \n\n 为单个空格。然后,我们提取其 values 属性下的第一个元素:

    limitations =series_df['Limitations and\
    exceptions'].fillna('N/A').str.replace('\n\n',\
    ' ').values[0]
    
  5. 现在我们已经定义了两个变量,series_dflimitations,我们将使用 Python 的 f-string 格式化方法,通过花括号将变量插入到适当的位置:f'{<variable_name>}'。我们首先使用 <h2> 元素插入指标名称。在 Markdown 中,标题对应其 HTML 等效元素,其中哈希符号的数量对应标题级别。在这里,我们使用两个符号来表示 <h2>

    ## {series_df['Indicator Name'].values[0]}  
    
  6. 接下来,我们以常规文本添加详细描述,前面不加任何哈希符号:

    {series_df['Long definition'].values[0]}
    
  7. 接下来,我们为 Unit of measurePeriodicitySource 添加项目符号。可以通过在每行前添加星号来创建项目符号。这是一个简单的过程,获取来自正确列的正确元素。请注意,对于 Unit of measure 的缺失值,我们使用单词 count 来填充,这将替换掉那些指标是简单计数而非百分比的缺失值。例如,人口就是一个这样的例子。对于 Periodicity,我们只需在缺失的值处替换为 N/A。在任何文本前后加上的星号会使其加粗,类似于运行 <b>text</b>

    * **Unit of measure** {series_df['\
    Unit of measure'].fillna('count').values[0]}
    * **Periodicity**\
    {series_df['Periodicity'].fillna('N/A').values[0]}
    * **Source** {series_df['Source'].values[0]}
    
  8. <h3> 中添加 Limitations and exceptions 子标题:

    ### Limitations and exceptions:  
    
  9. 接下来,我们在常规文本中添加已经创建的 limitations 变量:

    {limitations}  
    

将前面的代码整合在一起,这是创建Markdown组件的完整代码,并展示了它在回调函数中的相对位置。请注意,在某些情况下,series数据框中没有某些指标的详细信息。在这种情况下,我们将Markdown变量设置为一个字符串,表示缺少此类详细信息。此条件也可以在以下代码中看到,在检查series_df.empty时;否则,其他部分将像之前一样运行。

…
fig.layout.coloraxis.colorbar.title =\
multiline_indicator(indicator)
series_df = series[series['Indicator Name'].eq(indicator)]
if series_df.empty:
    markdown = "No details available on this indicator"
else:
    limitations = series_df['Limitations and exceptions'].fillna('N/A').str.replace('\n\n', ' ').values[0]
    markdown = f"""
    ## {series_df['Indicator Name'].values[0]}  
    {series_df['Long definition'].values[0]}  
    * **Unit of measure** {series_df['Unit of measure'].fillna('count').values[0]}
    * **Periodicity**
{series_df['Periodicity'].fillna('N/A').values[0]}
    * **Source** {series_df['Source'].values[0]}
    ### Limitations and exceptions:  
    {limitations}
"""
return fig, markdown

我们最终返回一个元组fig, markdown,而不仅仅是之前版本中的fig。将这段代码添加到应用中会将相应的 Markdown 添加到地图,并为其提供更好的上下文,同时指出用户需要记住的限制。

接下来我们将讨论地图可以展示的不同投影,以及如何进行更改。

理解地图投影

我们在地图中使用了某种投影类型的示例,现在我们将更详细地探讨这个话题。当我们试图将地球(或其部分)绘制在一个平面矩形上时,形状不可避免地会发生某种程度的扭曲。因此,有不同的方式或投影可以使用。没有任何投影是完美的,它们在形状、面积、相对位置等方面存在权衡。哪个投影更合适的细节取决于应用场景,并超出了本书的讨论范围。不过,我们将探讨如何更改使用的投影,并查看如何获取可用的投影。

使用 Plotly Express,我们在地图函数中有一个projection参数,该参数接受一个字符串,可以用来设置所需的投影类型。或者,我们也可以像之前那样,通过将值赋给fig.layout.geo.projection.type来设置。

图 7.9展示了几种可用的投影选项及其相应的名称。

图 7.9 – 可用地图投影的示例

图 7.9 – 可用地图投影的示例

如你所见,有不同的方式来展示地球。虽然正投影可能在形状上看起来更逼真,但它的问题是我们只能看到地球的一部分,因此失去了透视感。而方位等面积投影实际上在交互使用并缩放到某些区域时相当逼真。可以随意尝试不同的投影方式,并选择最适合你的。

到目前为止,我们已经实验过多边形或区域地图,现在我们将探索另一种我们通常比较熟悉的地图类型:散点图地图。

使用散点地图图

x轴和y轴与经纬度之间的主要区别在于地球的形状。当我们接近赤道时,垂直经线之间的距离尽可能远,而当我们接近南北极时,它们之间的距离则尽可能近。图 7.10展示了这一点:

图 7.10 – 地球地图,显示经纬度线

图 7.10 – 地球地图,显示经纬度线

换句话说,当我们接近赤道时,地图的形状更加接近矩形,因为一单位经度与一单位纬度几乎相等。接近极地时,比例完全不同,矩形开始接近三角形。这与矩形平面不同,在矩形平面上,垂直方向的单位距离对应于水平方向的相同单位距离,无论你处于平面上的哪个位置。当然,这假设了两个轴上的比例是线性的。例外的是对数坐标轴,关于这一点我们已经在第六章使用散点图探索变量和通过滑块过滤子集中进行了讨论。地图投影会帮我们处理这个问题,所以我们不需要担心这个问题。因此,我们可以像思考 xy 轴一样思考地图投影,并选择我们需要的投影。

让我们看看如何使用 scatter_geo 函数通过 Plotly Express 绘制散点图。

我们从一个非常简单的例子开始:

df =\
poverty[poverty['year'].eq(2010) & poverty['is_country']]
px.scatter_geo(df, locations='Country Code')

首先,我们创建了 df,其中年份为 2010,并过滤掉非国家数据。然后,就像我们在使用 choropleth 地图时所做的那样,我们选择了用于 locations 参数的列。这生成了图 7.11中的简单图表:

图 7.11 – 使用 scatter_geo 函数的散点图

图 7.11 – 使用 scatter_geo 函数的散点图

你可以看到做这件事是多么简单。除了标记国家并显示国家代码值外,这张图表没有太多信息。

国家名称默认由 Plotly 提供支持。另一个有趣的应用可能是使用 latlon 参数在地图上绘制任意位置,正如你在下面的代码和图 7.12中所看到的那样:

px.scatter_geo(lon=[10, 12, 15, 18], lat=[23, 28, 31, 40])

这将产生以下输出:

图 7.12 – 使用经纬度数据的散点图

图 7.12 – 使用经纬度数据的散点图

你可以轻松地应用我们在第六章使用散点图探索变量和通过滑块过滤子集中讨论的概念,来修改大小和地图颜色,设置不透明度等等。

现在,我们将通过引入另一种更丰富的地图制作方式——使用 Mapbox,来探索这些选项。

探索 Mapbox 地图

Mapbox 是一个开源地图库,由同名公司支持,该公司还提供额外的服务、图层和主题,用于生成丰富的地图应用程序。我们在这里将使用的选项可以立即与 Plotly 一起使用,但还有一些其他样式和服务需要你注册账户并在每次生成地图时使用令牌。

一个例子应该能帮助我们快速入手,因为我们已经非常熟悉散点图:

px.scatter_mapbox(lon=[5, 10, 15, 20],
                  lat=[10, 7, 18, 5],
                  zoom=2,
                  center={'lon': 5, 'lat': 10},
                  size=[5]*4,
                  color_discrete_sequence=['darkred'],
                  mapbox_style='stamen-watercolor')

前面的代码应该很简单。lonlat 参数相当于散点图中的 xy 参数。sizecolor_discrete_sequence 参数已经讲解过。一个有趣的新参数是 zoom 参数,我们在这里将其设置为 2。这个参数可以取从 0(整个世界)到 22(建筑物级别缩放)之间的整数值,包括 022。我们还可以看到,设置地图中心是多么简单,我们是使用第一个点的坐标(5, 10)来设置的。最后,mapbox_style 参数提供了一些有趣的选项,可以用来以不同样式显示地图。stamen-watercolor 样式给它一种艺术感,如图 7.13所示:

图 7.13 – 使用 Mapbox 和自定义样式的散点图

图 7.13 – 使用 Mapbox 和自定义样式的散点图

将鼠标悬停在地图上的i上,会显示瓷砖和数据的来源。正如你所看到的,很多层和工作都凝聚在这个简单的函数中。现在,让我们使用相同的方法绘制一些来自数据集的数据。

因为 scatter_mapbox 主要处理纬度和经度数据,而我们的数据集没有关于国家的这种数据,所以我们将获取这些数据,进行合并,然后将标记放置到对应的位置。

有很多来源提供这种数据,快速的在线搜索可以找到一些好的来源。我们可以使用 pandas 的 read_html 函数来获取数据。它接受一个 URL,下载该 URL 上的所有 <table> 元素,并返回一个 DataFrame 对象的列表。我们只需要选择我们想要的那个。在这个例子中,它是第一个。以下代码实现了这一点,并创建了 lat_long 变量,这是一个 DataFrame:

lat_long =\
pd.read_html('https://developers.google.com/public-data/docs/canonical/countries_csv')[0]

如果你还记得我们在 第四章中讨论的内容,数据操作与准备 - 为 Plotly Express 铺路,我们讲解了几种数据操作,我们将使用 pandasmerge 函数,通过左连接操作将 lat_long 合并到 poverty 中。

我们首先通过在 JupyterLab 中打印 lat_long 数据框来查看其结构,你可以在图 7.14中看到顶部和底部的五行数据:

图 7.14 – 包含国家纬度和经度数据的 lat_long 数据框

图 7.14 – 包含国家纬度和经度数据的 lat_long 数据框

poverty 数据框中还有一个名为 2-alpha code 的列,包含使用相同的两字母标准的国家代码,所以我们将使用这些列进行合并,如下所示:

poverty = pd.merge(left=poverty, right=lat_long, how='left',
                   left_on='2-alpha code', right_on='country')

这将把lat_long列添加到poverty中,按照它们所属的行对齐,并在必要时进行重复。请记住,我们使用left方法进行合并,这意味着left参数是合并的依据。你可以在图 7.15中查看合并后的一些随机行和重要列,以便更清楚地理解:

图 7.15 – 与 lat_long 合并的贫困 DataFrame 子集

图 7.15 – 与 lat_long 合并的贫困 DataFrame 子集

请注意,在没有经度和纬度值的情况下,我们会得到NaN。例如,在有相同国家名称的情况下(如塔吉克斯坦),经度和纬度值会简单地被复制,以保持这些值与各自国家的映射,无论我们选择哪一行。

我们现在准备创建一个气泡图(散点图,其中标记的大小反映某个数量)。我们只需要创建一个包含国家并去除所需指标Population, total缺失值的poverty子集。可以使用以下代码完成:

df =\
poverty[poverty['is_country']].dropna(subset=['Population, total'])

创建气泡图需要调用scatter_mapbox函数,但我们将逐个讨论给定的参数:

  1. 调用刚刚创建的子集的函数:

    px.scatter_mapbox(df, …)
    
  2. 选择用于经度和纬度值的列:

    lon='longitude', lat='latitude'
    
  3. 设置所需的缩放级别,以显示整个地球:

    zoom=1
    
  4. 将指标的值映射到标记的大小,并设置合适的最大值:

    size='Population, total', size_max=80
    
  5. 将每个国家所属的收入组映射到标记的颜色上(在这种情况下是离散变量):

    color='Income Group'
    
  6. 选择year列作为用于动画的列:

    animation_frame='year'
    
  7. 设置合适的不透明度,因为我们肯定会有重叠的标记:

    opacity=0.7
    
  8. 为整个图表设置适当的高度,以像素为单位:

    height=650
    
  9. 向悬浮框添加更多信息,通过包括另外两列数据,使得用户将鼠标悬停在标记上时显示:

    hover_data=['Income Group', 'Region']
    
  10. 选择自定义颜色序列,以区分各个国家所属的收入组:

    color_discrete_sequence=px.colors.qualitative.G10
    
  11. 为地图设置自定义样式:

    mapbox_style='stamen-toner'
    
  12. 为悬浮框设置标题,使用国家名称:

    hover_name=df['Country Name']
    
  13. 为图表设置标题:

    title="Population by Country"
    

运行前面的代码会生成一个交互式图表,如图 7.16所示:

图 7.16 – 基于国家按年份动画显示人口的 scatter_mapbox 气泡图

图 7.16 – 基于国家按年份动画显示人口的 scatter_mapbox 气泡图

这是我们刚才讨论的完整代码,以便更清晰地理解:

px.scatter_mapbox(df,
                  lon='longitude',
                  lat='latitude',
                  zoom=1,
                  size='Population, total',
                  size_max=80,
                  color='Income Group',
                  animation_frame='year',
                  opacity=0.7,
                  height=650,
                  hover_data=['Income Group', 'Region'],
                  color_discrete_sequence=px.colors.qualitative.G10,
                  mapbox_style='stamen-toner',
                  hover_name=df['Country Name'],
                  title='Population by Country')

你可以看到设置所有选项是多么简单,所涉及的代码是多么简洁。我们只需要了解选项及其工作原理。

由于这是一个交互式图表,用户可以进行缩放,因此通过简单地缩放一级,就能轻松处理我们所遇到的重叠问题。图 7.17展示了用户缩放后的同一图表:

图 7.17 – 放大显示更清晰的散点图 Mapbox 图表

图 7.17 – 放大显示更清晰的散点图 Mapbox 图表

泡泡图相比于地区图的一个优势是,它能够展示数值与国家(或任何地点)的地理区域之间的关系。例如,图 7.16 展示了加拿大、俄罗斯和澳大利亚三个有趣的案例,它们的人口相对于面积来说较少。换句话说,它们的人口密度较低。这为这个指标提供了更多的视角。

如你所见,显示和与地图互动的方式有很多种,我们只是触及了可做的事情的表面。接下来,我们将看看一些其他可用的选项,万一你有兴趣进一步了解。

探索其他地图选项和工具

以下是一些关于地图探索的提示,无需过多深入细节。

你可能已经考虑过将自定义多边形或区域可视化为地区图。目前我们所讨论的仅是标准的国家。当然,你也可以选择可视化一个包含任意点的自定义区域。

有一个标准的 GeoJSON 格式用于表示这些信息。它主要由点、线和多边形组成。点只是地图上的位置,类似于我们用于散点图的点。线是连接的一组点,按一定顺序排列,且第一个点和最后一个点不相同。正如你所猜测的,多边形类似于线,但条件是第一个点和最后一个点相同。请注意,许多国家由多个多边形组成。大多数 Plotly 地图函数支持 GeoJSON,你可以用它来进行自定义地图绘制。

当你有自定义数据用于特定位置时,并且需要获取相关数据时,这非常有用。

另一个重要且有用的项目是 geopandas,值得考虑学习。顾名思义,它是一个像 pandas 一样工作的专用库,提供了用于地理数据的特殊数据结构和技术,最显著的是 GeoDataFrame。如果你有更专业的地图需求,或者经常需要进一步自定义地图,它是值得学习的。

现在我们来将我们创建的功能添加到应用中。

将互动地图集成到我们的应用中

我们创建的地图,结合了 DropdownMarkdown 组件,可以成为我们应用中的第一个探索工具。现在我们可以去掉人口条形图,代之以我们刚刚创建的组件,供用户探索所有指标,在地图上查看它们,滚动查看年份,并且对于每个指标,获取完整的详细信息,同时看到局限性和潜在问题。一旦某个内容引起用户注意,他们可以找到其他图表,获取更多关于他们感兴趣的指标的细节(如果有的话)。

为了将新功能完全整合到我们的应用中,我们需要按照以下步骤进行操作:

  1. app.py 模块的顶部添加 series 的定义:

    series = pd.read_csv('data/PovStatsSeries.csv')
    
  2. app.layout 之前的任何位置添加 multiline_indicator 函数的定义:

    def multiline_indicator(indicator):
        final = []
        split = indicator.split()
        for i in range(0, len(split), 3):
            final.append(' '.join(split[i:i+3]))
        return '<br>'.join(final)
    
  3. 在应用的顶部,在我们之前放置人口条形图的位置,添加 DropdownGraphMarkdown 组件。以下代码展示了如何添加这些组件,包括组件的 ID 以使其更清晰,但完整的定义已被省略。注意,还添加了一个 Col 组件,并且设置了另一个 Col 组件的宽度,两个都使用了 lg(大)参数。第一个用来在显示内容之前插入一个空白列,第二个用来控制该列中内容的宽度:

    app.layout = html.Div([
        dbc.Col([
            html.Br(),
            html.H1('Poverty And Equity Database'),
            html.H2('The World Bank'),
        ], style={'textAlign': 'center'}),
        html.Br(),
        dbc.Row([
            dbc.Col(lg=2),
            dbc.Col([
                dcc.Dropdown(id='indicator_dropdown', ...),
                dcc.Graph(id='indicator_map_chart', ...),
                dcc.Markdown(id='indicator_map_details_md', ...)
        ], lg=8)
        ]),
        html.Br()
    

在这一章中,我们探讨了几个新的选项,现在让我们总结一下我们所做的工作。

总结

我们从探索如何创建热力图开始,这是一种我们都习惯看到的地图类型。我们还展示了如何为这些地图添加动画效果,如果我们有一个按顺序变化的值,譬如按年进展的某个指标。然后,我们创建了一个回调函数,使地图能够与我们所有可能的指标一起工作,用户可以浏览所有指标,然后决定接下来想要探索的内容。

之后,我们学习了如何使用 Markdown 来生成 HTML 内容,以及如何将其添加到 Dash 应用中。接着,我们探讨了不同的地图或投影显示方式,并了解了如何选择我们想要的投影。

我们了解了另一种类型的地图,即散点地图图表。基于前一章中建立的知识,调整这些知识来适应散点地图非常简单。我们还学习了 Mapbox 提供的丰富选项,并探讨了其他几个可以进一步探索的地图主题。最后,我们将这些新功能集成到我们的应用中,现在应用包含了大量关于几乎所有指标的解释性文本,用户可以更清晰地了解他们正在分析的内容。

在下一章,我们将处理另一种类型的图表,帮助统计数值并展示它们在数据集中的分布情况,即 直方图。我们还将探索一个新的组件,Dash DataTable,它允许我们以丰富的方式展示表格数据,并提供许多筛选、可视化、下载等选项。

第八章:第八章:计算数据频率并构建交互式表格

到目前为止,我们探索的所有图表类型都直接展示了我们的数据。换句话说,每个标记,无论是圆形、条形、地图或其他形状,都对应数据集中的一个数据点。而直方图则显示与数据点相关的统计汇总值。直方图主要用于统计数据集中的值。它通过将数据分组或“分箱”到多个箱中,并显示每个箱中的观测值数量来实现。除了计数,当然也可以进行其他计算,比如计算均值或最大值,但计数是最常见的应用场景。计数结果以条形图的形式呈现,条形的高度对应每个箱中的计数(或其他计算结果)。另一个重要的结果是,我们可以看到数据是如何分布的,以及数据呈现什么样的分布形状/类型。观察值是否集中在某个点或多个点附近?它们是向左还是向右偏斜的?这些都能帮助我们全面了解数据的一个方面。

概率分布是统计学中的基础内容,对于了解我们的数据概况至关重要。了解数据值在我们的样本或数据集中是如何分布的,以及它们的集中位置非常重要。如果一个数据集看起来呈正态分布,我们可能会做出不同的假设,并有不同的预期,而不是假设它呈指数分布。直方图有助于揭示我们数据分布的形状。

在本章中,我们还将探索 Dash 的DataTable组件。这个组件灵活、强大且功能丰富,可以帮助我们完成多项任务,包括显示、过滤和导出数据表。

本章我们将讨论以下内容:

  • 创建直方图

  • 通过修改箱子并使用多个直方图来定制直方图

  • 向直方图添加交互性

  • 创建二维直方图

  • 创建数据表

  • 控制表格的外观和感觉(单元格宽度、高度、文本显示等)

  • 向应用中添加直方图和表格

技术要求

我们将使用与上一章相似的工具,只是增加了一些内容。我们将使用 Plotly Express 和graph_objects模块来创建我们的图表。需要使用的软件包包括 Plotly、Dash、Dash Core Component、Dash HTML Components、Dash Bootstrap Components、pandas 以及新的dash_table包。您无需单独安装这个包(尽管可以),因为它在安装 Dash 时会一同安装。

本章的代码文件可以在 GitHub 上找到,链接地址为:github.com/PacktPublishing/Interactive-Dashboards-and-Data-Apps-with-Plotly-and-Dash/tree/master/chapter_08

查看以下视频,了解代码的实际应用:bit.ly/3sGSCes

创建直方图

我们希望了解如何获得数据样本的分布,并了解值的集中位置以及其变异性/扩展性。我们将通过创建直方图来实现这一目标。

和往常一样,我们从最简单的示例开始:

  1. 我们打开poverty数据框,并创建一个子集,仅包含 2015 年各国的数据:

    import pandas as pd
    poverty = pd.read_csv('data/poverty.csv')
    df = poverty[poverty['is_country'] & poverty['year'].eq(2015)]
    
  2. 导入 Plotly Express,并使用histogram函数,将df作为data_frame参数的参数,并选择我们想要的指标作为x参数:

    import plotly.express as px
    gini = 'GINI index (World Bank estimate)'
    px.histogram(data_frame=df, x=gini)
    

    结果,我们得到了你在图 8.1中看到的基尼指标直方图:

图 8.1 – 基尼指标直方图

图 8.1 – 基尼指标直方图

x轴使用我们选择的指标命名,y轴的标题为count。这是histogram函数的默认功能,从鼠标悬停在任何条形图上的提示框也可以清楚地看到这一点。在这里,我们得知 2015 年有 18 个国家的基尼指数位于区间(35, 39.9)之间。我们之前已经按国家可视化了该指标(逐个国家展示),但这次我们通过直方图了解每个箱子中的数值数量以及这些数值的分布情况。我们可以看到,大多数国家的基尼指数在 25 到 40 之间,而且基尼指数越高,国家数量越少。当然,这仅适用于这一特定年份。

我们使用的是默认的箱子数量,但如果需要,可以修改它。这是你通常想要在交互式设置中修改的内容,直到你获得一个好的视图。在交互式环境中,比如在仪表板上,允许用户修改箱子的数量可能是个好主意,特别是当你不确定他们会选择哪个指标以及该指标的值如何分布时。这正是我们在这个数据集中的情况。

让我们看看改变箱子数量以及其他可用修改的效果。

通过修改箱子数量和使用多个直方图来定制直方图

我们可以通过nbins参数更改箱子的数量。我们将首先看到使用两个极端值作为箱子数量的效果。设置nbins=2会生成图 8.2中的图表:

图 8.2 – 具有两个箱子的基尼指标直方图

图 8.2 – 具有两个箱子的基尼指标直方图

如你所见,值被分为两个相等的箱子,(nbins=500会生成图 8.3中的图表:

图 8.3 – 具有 500 个箱子的基尼指标直方图

图 8.3 – 具有 500 个箱子的基尼指标直方图

现在它更加详细了,可能比实际有用的还要详细。当你设置太多的箱子时,几乎就像是在查看原始数据。

默认的箱子数量导致箱子的大小是五个单位的区间。现在我们知道我们的值范围在 25 到 60 之间(45),我们可能希望看到数据如何在 45 个箱子中分布。这使得每个箱子的大小为 1。图 8.4 显示了设置 nbins=45 的结果:

图 8.4 – 具有 45 个箱子的基尼指数直方图

图 8.4 – 具有 45 个箱子的基尼指数直方图

到目前为止,我们在本章中创建的所有图形都是基于相同的数据集。你可以看到,基于所选的箱子数量,分布看起来有多么不同。你也可以将其视为以不同的分辨率查看数据分布。通常,对于你的用例来说,有一个最优分辨率,你可以手动调整,直到找到最有用/最具洞察力的分辨率。这是使直方图具有交互性的主要优势,你可以让用户根据自己的需求进行探索。

回想一下,我们的数据集中有一些分类列,我们可以使用这些列来给条形图上色,从而更详细地查看数据。让我们看看如何实现这一点。

使用颜色进一步拆分数据

正如你可能猜到的那样,向 Plotly Express 图表添加颜色,实际上就是从我们使用的数据框中选择一列。设置 color='Income Group' 生成了你可以在 图 8.5 中看到的图表:

图 8.5 – 按收入组着色的基尼指数直方图

图 8.5 – 按收入组着色的基尼指数直方图

这就是完全相同的直方图,但通过数据集的另一个维度进行了丰富。每个条形根据 收入组 进行拆分,并相应地上色。我们现在可以看到每个箱子中,来自每个收入组的国家数量。

你还可以看到设置 color='Region', color_discrete_sequence=px.colors.qualitative.Set1 的效果,如 图 8.6 所示:

图 8.6 – 按地区着色的基尼指数直方图

图 8.6 – 按地区着色的基尼指数直方图

再次,我们得到了相同的直方图,但使用不同的列进行了着色,barmode 参数。让我们看看这种方法如何应用于直方图。

提示

你可能已经注意到,直方图中的条形图是连在一起显示的,没有像条形图那样的间隔。这是一个视觉提示,用来表示直方图的连接特性。箱子是将一组观测值相互分开的任意分隔点。正如我们所看到的,这些分隔点可以不同选择,从而产生完全不同的形状。条形图通常用于离散或分类变量,并且通常会在条形之间留一些空隙来表达这一点。

探索在直方图中显示多个条形的其他方式

之前的两个直方图将每个区间的子柱状图堆叠在一起。这是有道理的,因为这些子柱状图代表了各自区间下的数据分组。换句话说,它们展示了每个区间下国家分组的分布。

在某些其他情况下,我们可能想做相同的事情,但针对两年数据。在这种情况下,将柱状图堆叠可能会产生一种错误的印象,即子柱状图对应于同一个区间的不同部分,而实际上它们对应的是同一个区间,但来自不同的年份。通过一个例子可以更容易地理解这一点:

  1. 创建一个包含仅有国家且年份范围为 [2010, 2015]poverty 子集:

    df = poverty[poverty['is_country'] & poverty['year'].isin([2010, 2015])]
    
  2. 对基尼指数运行 histogram 函数,按 year 着色并设置 barmode='group'

    px.histogram(df, x=gini, color='year', barmode='group')
    

    这样就得到了以下输出:

图 8.7 – 按年份着色的基尼指标直方图,barmode 设置为 "group"

图 8.7 – 按年份着色的基尼指标直方图,barmode 设置为 "group"

由于年份代表了相同指标和相同区间的“前后”视角,我认为将它们并排显示更为合理,这样我们可以看到每个区间的值如何在两个或多个选择的年份间增加或减少。

如果我们更关心突出显示分布整体变化的话,还有另一种处理方式。我们可以运行刚才使用的相同函数,但除了颜色外,使用 facets 来将直方图拆分为两部分。代码也很简单,并且包含了一个额外的参数,如下所示:

px.histogram(df, x=gini, color='year', facet_col='year')

这样就得到了以下输出:

图 8.8 – 按年份着色并拆分的基尼指标直方图

图 8.8 – 按年份着色并拆分的基尼指标直方图

同样,最后两个图表以两种不同的方式显示相同的信息。在图 8.7中,非常容易比较每个区间内国家数量如何随着年份变化而变化。但要看清第一年和第二年之间分布的变化就稍微难一些。图 8.8则相反。请注意,我们也可以使用 facet_row,这样可以将图表显示在彼此之上。但我们选择将它们并排显示,因为我们更关注比较柱状图的高度,而并排显示时,比较会更容易。如果我们设置 orientation='h'(横向显示),那么在这种情况下,使用 facet_row 也会更方便。

有时我们可能更关心某个区间内值的百分比,而不是每个区间的绝对数量。得到这个结果也非常简单。我们只需设置 histnorm='percent'。我们首先创建一个 fig 对象并添加新的选项:

fig = px.histogram(df, x=gini, color='year', facet_col='year',

我们还可以通过在 y 轴刻度上添加百分号后缀来更明确地显示百分比。这可以通过以下代码实现:

fig.layout.yaxis.ticksuffix = '%'

我们还可能希望为y轴设置一个更具描述性的标题,这也可以通过以下代码轻松实现:

fig.layout.yaxis.title = 'Percent of total'

运行这段修改后的代码将生成图 8.9中的图表:

图 8.9 – 一个按年份着色并分割的基尼系数直方图,显示百分比

图 8.9 – 一个按年份着色并分割的基尼系数直方图,显示百分比

这张图表看起来与图 8.8中的图表相同。主要的区别是,条形的高度表示的是百分比,而不是绝对数值。通过刻度后缀和y轴标题,这一点也变得更加清晰。

我们已经探索了许多直方图的选项。现在让我们使我们的直方图具备交互性,并添加一些其他选项。

为直方图添加交互性

就像我们在第七章中做的那样,探索地图图表并用 Markdown 丰富你的仪表板,我们也可以对直方图做同样的事情。我们可以允许用户更好地了解某个指标在某一年或多个年份中的分布。不同之处在于,我们希望允许他们自定义区间的数量。既然我们现在已经能够处理多个输入和输出,让我们为用户添加更多选项。我们还可以允许用户选择多个年份,并使用分面显示多个年份的多个子图。图 8.10展示了我们将朝着这个目标努力的方向:

图 8.10 – 一个允许选择指标、年份和区间的直方图应用

图 8.10 – 一个允许选择指标、年份和区间的直方图应用

我们现在就开始构建吧。我们不会讨论布局元素,例如颜色和宽度,但你可以随时参考代码库来获取确切的解决方案。我们将专注于为此添加交互性。稍后我们会将其添加到我们的应用程序中:

  1. 进行必要的导入:

    from jupyter_dash import JupyterDash
    import dash_core_components as dcc
    import dash_html_components as html
    import dash_bootstrap_components as dbc
    from dash.dependencies import Output, Input
    
  2. 创建一个app对象及其layout属性:

    app = JupyterDash(__name__)
    app.layout = html.Div([])
    
  3. LabelDropdown组件作为第一个元素添加到刚刚创建的 div 中。Dropdown组件显示可用的指标,它与我们在第七章中创建的完全相同,探索地图图表并用 Markdown 丰富你的仪表板

    html.Div([
        dbc.Label('Indicator:'),
        dcc.Dropdown(id='hist_indicator_dropdown',
                     index (World Bank estimate)',
                     indicator, 'value': indicator}
    for indicator in poverty.columns[3:54]]),
    ])
    
  4. dbc.Labeldcc.Dropdown组件中添加到 div 的列表中,以表示用户可以选择一个年份和实际要选择的年份,并允许多选。请注意,由于此下拉框允许多选,如果提供了默认值,则需要以列表的形式提供:

    dbc.Label('Years:'), dcc.Dropdown(id='hist_multi_year_selector',
                 value=[2015],
                 one or more years',
                 year, 'value': year}
                          for year in poverty['year'].drop_duplicates().sort_values()]),
    
  5. 再次,在 div 中相同的列表中,我们添加了另一个dbc.Label组件和一个dcc.Slider组件,这将允许用户修改生成的直方图中的 bin 数量。注意,如果不设置默认值,Plotly 将根据所分析的数据提供默认的 bin 数量。滑块中将显示为0。用户可以根据需要进行修改:

    dbc.Label('Modify number of bins:'),
    dcc.Slider(id='hist_bins_slider',
               min=0,
               step=5,
               marks={x: str(x) for x in range(0, 105, 5)}),
    
  6. 最后,我们添加了一个Graph组件,这将完成我们的布局:

    dcc.Graph(id='indicator_year_histogram')
    

运行这些步骤会创建我们应用程序的可视部分(布局),但没有任何功能。默认的外观如图 8.11所示,我将留给你修改颜色、对齐方式和相对位置,使用我们在 第一章中构建的知识,Dash 生态系统概述

图 8.11 – 没有功能的直方图应用的默认视图

图 8.11 – 没有功能的直方图应用的默认视图

现在我们将开始构建交互功能。在这个案例中,我们需要构建一个函数,该函数接受三个输入(指标下拉框、年份下拉框和 bins 滑块)。它将返回一个Figure对象,用于修改图形底部的图表:

  1. 创建回调函数。这里没有什么特别的;我们只需确保设置 ID,以表明它们与直方图相关:

    @app.callback(Output('indicator_year_histogram', 'figure'),
                  Input('hist_multi_year_selector', 'value'),
                  Input('hist_indicator_dropdown', 'value'),
                  Input('hist_bins_slider', 'value'))
    
  2. 创建生成直方图的函数,使用刚刚创建的输入。我们首先检查是否既没有提供year也没有提供indicator,如果是这种情况,我们会raise PreventUpdate

    def display_histogram(years, indicator, nbins):
        if (not years) or (not indicator):
            raise PreventUpdate
    
  3. 通过选择仅包含国家的数据创建一个子集df,并获取年份在提供的years参数中的行:

    df = poverty[poverty['year'].isin(years) & poverty['is_country']]
    
  4. 我们现在准备创建图形,通过调用histogram函数来完成。如我们在本章中所看到的,我们将df提供给data_frame参数,将indicator作为x参数,将year传递给color。图形的标题将通过将指标与Histogram字符串连接来设置。nbins参数将接受用户从滑块中选择的nbins值。对于子图,我们使用year列。由于我们不知道用户将选择多少年份,并且不希望他们最终创建一个难以阅读的图表,因此我们设置facet_col_wrap=4。这将确保每行图表最多包含四个,接下来的图表将添加到下一行中:

    fig = px.histogram(df,
                       color='year',
                       + ' Histogram',
                       facet_col='year',
                       height=700)
    
  5. 一个新的且有趣的选项是我们到目前为止没有涉及的for_each_xaxis属性。注意,这是多个for_each_属性中的一个,你可以单独探索其他属性。这在* x 轴属性的数量未知的情况下非常有用,比如在这种情况,或者当存在多个属性时。默认情况下,每个子图(或子图)都会有自己独立的 x *轴标题。正如你所知,很多指标名称很长,在这种情况下会发生重叠。为了解决这个问题,我们将所有xaxis标题设置为空字符串:

    fig.for_each_xaxis(lambda axis: axis.update(title=''))
    
  6. 为了替换已删除的x轴标题,我们可以创建一个注释。注释是一个简单的字符串,可以通过add_annotation方法轻松添加。因为我们希望注释的X位置位于图形的中央,所以我们将其x值设置为0.5。另外,由于我们希望Y位置略低于绘图区域,因此将y值设置为-0.12。现在,重要的是告诉 Plotly 我们提供的这些数字的含义或其参考。我们可以使用xrefyref参数来表示这些值应该以paper为参考。这意味着将这些点视为图表的分数,而不是数据点,例如散点图中的数据点。这很有用,因为这些注释将作为轴标题,因此我们希望它们的位置是固定的。默认情况下,注释会有指向所选点的箭头。我们可以通过设置showarrow=False来移除它,如下所示:

    fig.add_annotation(text=indicator,
                       y=-0.12,
                       yref='paper',
    

以下是该函数的完整代码,以便更清楚地了解:

@app.callback(Output('indicator_year_histogram', 'figure'),
              Input('hist_multi_year_selector', 'value'),
              Input('hist_indicator_dropdown', 'value'),
              Input('hist_bins_slider', 'value'))
def display_histogram(years, indicator, nbins):
    if (not years) or (not indicator):
        raise PreventUpdate
    df = poverty[poverty['year'].isin(years) & poverty['is_country']]
    fig = px.histogram(df,
                       color='year', 
                       + ' Histogram',
                       facet_col='year',
                       height=700)
    fig.for_each_xaxis(lambda axis: axis.update(title=''))
    fig.add_annotation(text=indicator, 
                       y=-0.12, 
                       yref='paper',
                       fig

通过这个,我们创建了一个独立的应用程序,可以在 JupyterLab 中运行。我鼓励你完全运行它,看看是否会遇到问题,并对它进行定制和修改。

到目前为止,我们已经探索了如何可视化单个观察集的计数和分布。还有一种有趣的方式可以同时探索两个观察集,这可以通过二维直方图来实现。

创建一个二维直方图

在第一个案例中,我们基本上是统计了数据集中每个区间的观察值。在这个例子中,我们将做同样的事情,但对于两个数据集的区间组合。每个变量的区间将最终形成一个矩阵。一个简单的例子可以让这一点变得清晰。让我们创建一个例子并看看:

  1. 创建一个包含仅有 2000 年数据的poverty子集:

    df = poverty[poverty['year'].eq(2000) & poverty['is_country']]
    
  2. 创建一个Figure对象并添加一个histogram2d轨迹(在撰写时,这种图表类型在 Plotly Express 中不可用)。我们只需选择希望一起绘制的任意两个指标,并将它们传递给xy

    fig = go.Figure()
    fig.add_histogram2d(x=df['Income share held by fourth 20%'],
                        y=df['GINI index (World Bank estimate)'],
                        colorscale='cividis')
    
  3. 添加x轴和y轴的标题:

    fig.layout.xaxis.title = 'Income share held by fourth 20%'
    fig.layout.yaxis.title = 'GINI index (World Bank estimate)'
    fig.show()
    

运行上述代码会生成图 8.12中的图表:

图 8.12 – 2D 直方图

图 8.12 – 2D 直方图

这里值的频率表达方式有所不同。在一维直方图中,条形的高度表示各个区间内的值的频率。在二维直方图中,“高度”则通过连续的颜色尺度来表示。我们可以从色标中看到,计数范围从 0 到 10,具有最多值的区间组合是x区间(22, 22.9)和y区间(30, 39.9),对应的z值(高度)为10。通常,z用于指代第三维度,因此这也可以视为这个矩形的高度。

请注意,这与使用散点图展示两个变量不同。在散点图中,我们关注的是两个变量之间的相关性,或者至少是它们各自的变化情况。而在这里,我们尝试识别两个变量之间最常见的观测值,及其所处的箱体组合。

仍然有很多选项可以用来探索直方图,或是可视化分布和计数。我们已经探讨了很多这样的选项,现在我们将转向探索 Dash 中的另一个交互式组件——数据表。

创建数据表

从技术上讲,dash_table是一个独立的包,正如本章开头所提到的,它可以单独安装。它会随 Dash 自动安装,确保使用的是正确且最新的版本,这是推荐的做法。

很多时候,展示表格,特别是如果表格是交互式的,可以为我们的仪表盘用户增加很多价值。此外,如果我们的仪表盘或数据可视化对用户而言不够充分,或者如果用户希望运行他们自己的分析,那么允许他们获取原始数据也许是一个好主意。最后,DataTable组件允许通过自定义颜色、字体、大小等进行数据可视化。因此,我们可以通过表格的方式进一步理解和展示数据。在本章中,我们将探索一些可用的选项,但肯定不会涵盖所有选项。

让我们看看如何在一个简单的应用中使用 DataFrame 创建一个简单的数据表:

  1. 创建一个包含自 2000 年以来的贫困子集的应用,且该子集只包含具有国家名称或包含收入分布中前 10%和后 10%人口的列。我们使用filter方法配合正则表达式来实现这一点:

    df = poverty[poverty['year'].eq(2000)&poverty['is_country']].filter(regex='Country Name|Income share.*10')
    
  2. 在 JupyterLab 中创建一个带有layout属性的应用:

    app = JupyterDash(__name__, external_stylesheets=[dbc.themes.COSMO])
    app.layout = html.Div([])
    
  3. 将一个DataTable对象传递给刚创建的 div。最基本的要求是表格需要提供data参数和columns参数的值。实现这一点的一个简单方法是通过将 DataFrame 转换为字典,使用to_dict('records')方法。columns需要是一个字典列表,每个字典包含nameid键。name是用户看到的内容,id则是实际使用的值:

    DataTable(data=df.to_dict('records'),
              columns=[{'name': col, 'id': col}
                       col in df.columns])
    

使用app.run_server()运行这个简单的应用,会产生一个表格,正如图 8.13所示,显示了前几行数据:

图 8.13 – 一个简单的数据表

图 8.13 – 一个简单的数据表

很多时候,表格或列标题可能无法完美地适应其所在的容器。例如,在我们的案例中,许多指标名称非常长,而它们的列包含的数据数字并不占用太多水平空间。让我们探索一些可以处理这个问题的选项。

控制表格的外观和感觉(单元格宽度、高度、文本显示等)

有许多选项可用于修改表格的外观,始终建议查阅文档以获取想法和解决方案。潜在的棘手之处在于当您有组合选项时。在某些情况下,这些选项可能会相互修改,并且可能不会显示出您想要的样子。因此,在调试时,尽可能隔离选项总是一个好习惯。

图 8.13中,我们仅显示了三列和前几行。现在我们将看到如何显示更多列并让用户探索更多行:

  1. 修改df以包含所有包含Income share的列:

    df = poverty[poverty['year'].eq(2000)&poverty['is_country']].filter(regex='Country Name|Income share')
    
  2. 将 DataTable 放在所需宽度为7dbc.Col组件中。表格会自动采用其所在容器的宽度,因此这将隐式设置其宽度:

    dbc.Col([], lg=7)
    
  3. 现在我们想确定列标题的行为方式,特别是它们的名称相当长。这可以通过style_header参数实现。请注意,对于标题、单元格和表格,都有几个style_参数,它们还有_conditional变体,例如,style_cell_conditional,用于有条件地设置单元格的样式。我们现在使用以下选项指定标题样式,以允许文本在需要时溢出到多行:

    style_header={'whiteSpace': 'normal'}
    
  4. 现在我们希望在滚动时,标题保持固定不动:

    fixed_rows={'headers': True}
    
  5. 为了控制整个表格的高度,我们可以简单地使用以下参数:

    style_table={'height': '400px'}
    
  6. 在我们有数千行的情况下,可能会很重,影响页面的性能,因此我们可以使用virtualization。在我们的情况下,这是一个非常小的表格,但我们可以设置virtualization来演示其用法:

    virtualization=True
    

将代码放在一起,这是生成表格的完整代码:

dbc.Col([
    DataTable(data=df.to_dict('records'),
              columns=[{'name': col, 'id': col}
                       col in df.columns],
              style_header={'whiteSpace': 'normal'},
              fixed_rows={'headers': True},
              virtualization=True,
              style_table={'height': '400px'})
], lg =7),

运行此修改后的代码会生成图 8.14中的表格:

图 8.14 – 具有宽度、高度、滚动和虚拟化自定义选项的 DataTable

图 8.14 – 具有宽度、高度、滚动和虚拟化自定义选项的 DataTable

只有在光标指向那里时,滚动条才可见。它被保留用于演示,并清楚地表明滚动已启用。现在用户可以通过尽可能多地滚动来查看所有可用的行。我们现在将看到如何在表格中加入一些交互性,并将其添加到我们的应用程序中。我们还将利用这个机会演示 DataTable 组件提供的一些其他选项。

将直方图和表格添加到应用程序中

现在我们准备将表格功能整合到我们的应用程序中,并将其添加到我们已经创建的回调函数中。我们将显示用于生成直方图的数据,就在直方图图下方。由于直方图不显示数据点(仅聚合),如果用户愿意,他们可能会对自己看到的数据感兴趣。

让我们立即添加这个功能:

  1. 在直方图图下方添加一个新的 div:

    html.Div(id='table_histogram_output')
    
  2. 将此作为Output添加到回调函数中:

    @app.callback(Output('indicator_year_histogram', 'figure'),
                  Output('table_histogram_output', 'children'),
                  Input('hist_multi_year_selector', 'value'),
                  Input('hist_indicator_dropdown', 'value'),
                  Input('hist_bins_slider', 'value'))
    
  3. 在完成Figure对象的定义后,我们添加了 DataTable 的定义。我们将使用之前相同的选项,并添加一些新的选项。首先,我们添加了对列进行排序的功能:

    sort_action='native'
    
  4. 现在,我们添加了对列进行筛选的功能。这将在每个列标题下方添加一个空框,用户可以输入文本并按Enter键获取筛选后的表格:

    filter_action='native'
    
  5. 添加导出表格为 CSV 格式的功能:

    export_format='csv'
    
  6. 我们为单元格设置了最小宽度,以保持一致性,并避免由于不同列标题导致的格式问题:

    style_cell={'minWidth': '150px'}
    
  7. 最后,将表格添加到函数末尾的return语句中,这样它就会返回两个项而不是一个:

    return fig, table
    

由于添加了此功能,我们更新后的应用将包含用于生成直方图的表格,用户可以导出或与之互动。图 8.15 展示了添加了自定义 DataTable 的应用:

图 8.15 – 一个显示用于生成直方图的数据的 DataTable

图 8.15 – 一个显示用于生成直方图的数据的 DataTable

我们现在有了一个导出按钮,用户一点击就会立即触发浏览器中的下载功能。标题名称现在有了可以让用户进行排序的箭头,支持升序或降序排序。你还可以看到筛选选项,并且有一个**筛选数据...**的占位符文本,用户可以按照此进行筛选。

现在,为了将这个功能集成到我们的应用中,我们只需复制组件并将其放置在想要显示的位置。由于这可以被视为探索性功能(用户仍然不会深入了解指标),所以最好将其放在地图图表下方。

为了添加交互性,我们只需要像往常一样,在应用布局后添加我们创建的回调函数。

我们已经做过很多次了,这对你来说应该很简单。

我们的应用现在变得非常丰富。顶部有两个主要的互动式探索图表。地图允许用户选择一个指标并查看其在不同国家的变化。用户还可以选择年份和/或让它像视频一样播放。所选的指标会触发关于该指标的描述性文本,以便为用户提供更多背景信息。在其下方,我们提供了选择一个或多个年份来查看该指标如何通过直方图分布的选项。用户可以修改柱数以获得最佳视图。这也会更新他们可以与之互动并导出的表格。

在浏览过感兴趣的指标后,用户可以继续使用我们创建的三种专业图表来探索特定指标。

恭喜!我们现在已经完成了第二部分的内容,应该回顾一下本章的内容,以及第二部分,为第三部分做好准备。

总结

在这一章中,我们首先了解了直方图与我们至今所讨论的其他类型图表之间的主要区别。我们看到了创建直方图的简便性,更重要的是,我们看到了它们在使用barmode、颜色、分箱和小面板等方面的高度自定义性。接着,我们探索了如何通过回调函数将直方图与其他组件连接起来,进而为直方图添加交互性。

我们接着探索了二维直方图,并看到了它如何提供两个列之间更加丰富的可视化对比。

我们介绍了一个新的互动组件,DataTable。我们仅仅触及了表格功能的表面。我们使用它们让用户更容易获取、与之交互或仅仅查看我们直方图背后的原始数据。我们还探索了控制表格外观和感觉的不同方式。

最后,我们将表格功能与我们创建的回调函数结合起来,并将互动性添加到我们的应用中。

现在让我们快速回顾一下到目前为止在本书中所学习的内容,并为第三部分做好准备。

到目前为止我们已经覆盖的内容

在本书的第一部分,我们涵盖了 Dash 应用的基础知识。我们首先探索了它们的结构以及如何管理视觉元素。接着,我们探讨了如何创建交互性,主要通过使用回调函数。这样我们就可以创建完全互动的应用。随后,我们学习了Figure对象的结构,并了解了如何修改和操作它以生成我们需要的图表。之后,我们明白了数据处理和准备对于数据可视化的重要性。我们对数据集进行了重塑,使其更加直观易用。这为学习和使用 Plotly Express 铺平了道路。

第二部分主要讲解了熟悉几种类型的图表以及互动组件。我们在第一部分中建立的所有知识都得到了应用,但最重要的是,我们是在一个实际的环境中进行的。我们逐渐将更多的图表、组件和功能添加到一个应用中。在每一步,我们都需要考虑这些变化将如何影响整个应用,并确保从整体的角度去实现。现在你已经非常熟悉如何更改多种功能。尽管我们没有涵盖每种图表和组件,但其通用原理是相似的,你可以轻松地将所学的知识应用到新的情况中。

第三部分将讨论关于应用程序、URL、高级回调和部署等更一般性的主题。但接下来的章节将探讨一些机器学习的选项。我们的数据集包含许多国家、年份和指标,可能的组合数量庞大。因此,我们将探索一些有助于发现数据中趋势或关联的技术。

第三部分:将您的应用提升到新高度

本节进一步探讨了有关微调、改进和扩展应用的选项。它向您展示了几种新的策略和技术,用于修改和将您的应用推向云端。

本节包含以下章节:

  • 第九章*,让数据通过机器学习为自己发声*

  • 第十章*,通过高级回调加速您的应用*

  • 第十一章*,URL 和多页面应用*

  • 第十二章*,部署您的应用*

  • 第十三章*,下一步*

第九章:第九章:让你的数据为自己发声,利用机器学习

在制作直方图时,我们已经看到了一种可视化聚合数据而不是直接可视化数据点的技术。换句话说,我们可视化了关于我们数据的数据。在本章中,我们将进一步拓展这个概念,使用机器学习技术演示一些可用于分类或聚类数据的选项。正如你在本章中所看到的,甚至使用单一技术时,也有许多选项和组合可以探索。这就是交互式仪表板价值所在。如果用户需要通过手动创建图表来探索每一个选项,那将是非常繁琐的。

本章不是机器学习的介绍,也不假设你有任何先前的知识。我们将探索一种叫做sklearn的聚类技术。这将帮助我们将数据分组为相似的观察集合,同时与其他集合中的观察有所不同。我们将用一个简单的一维数据集来构建模型,然后看看如何将其应用于聚类我们贫困数据集中的国家。

如果你熟悉机器学习,那么本章应该能为你提供一些思路,帮助你为用户提供更多的能力,允许他们调节和探索模型的多个方面。如果不熟悉,依然可以完成本章内容,并且希望它能激发你进一步探索更多机器学习的概念和技术。

本章将涵盖以下主题:

  • 理解聚类

  • 找到最佳的聚类数量

  • 按人口对国家进行聚类

  • 使用scikit-learn准备数据

  • 创建一个交互式 K 均值聚类应用

技术要求

我们将探索一些来自sklearnNumPy的选项。此外,我们将继续使用之前所用的工具。为了实现可视化和交互功能,我们将使用 Dash、JupyterDash、Dash 核心组件库、Dash HTML 组件、Dash Bootstrap 组件、Plotly 和 Plotly Express。对于数据处理和准备,我们将使用pandasNumPy。JupyterLab 将用于探索和构建独立功能。最后,sklearn将用于构建机器学习模型,并准备我们的数据。

本章的代码文件可以在 GitHub 上找到,网址为github.com/PacktPublishing/Interactive-Dashboards-and-Data-Apps-with-Plotly-and-Dash/tree/master/chapter_09

查看以下视频,了解代码的实际演示:bit.ly/3x8PAmt

理解聚类

那么,什么是聚类,何时它可能会有帮助呢?让我们从一个非常简单的例子开始。假设你有一群人,我们想为他们制作 T 恤。我们可以为每个人制作一件 T 恤,按需求的尺寸制作。主要的限制是我们只能制作一种尺寸。尺寸如下:[1, 2, 3, 4, 5, 7, 9, 11]。想一想你会如何解决这个问题。我们将使用KMeans算法来解决这个问题,下面我们就开始吧:

  1. 导入所需的包和模型。NumPy将作为包导入,但我们从sklearn只会导入当前将要使用的唯一模型,如下代码片段所示:

    import numpy as np
    from sklearn.cluster import KMeans
    
  2. 创建一个所需格式的尺寸数据集。请注意,每个观察(个人的尺寸)应表示为一个列表,因此我们使用NumPy数组的reshape方法来获取所需格式的数据,如下所示:

    sizes = np.array([1, 2, 3, 4, 5, 7, 9, 11]).reshape(-1, 1)
    sizes
    array([[ 1],
           [ 2],
           [ 3],
           [ 4],
           [ 5],
           [ 7],
           [ 9],
           [11]])
    
  3. 创建一个KMeans模型实例,并指定所需的聚类数量。该模型的一个重要特性是,我们需要为其提供所需的聚类数量。在这个例子中,我们面临一个限制,就是只能制作一种尺寸的 T 恤,所以我们想要找到一个单一的点,它将是我们发现的聚类的中心。之后我们将探索所选聚类数量的效果。运行以下代码:

    kmeans1 = KMeans(n_clusters=1)
    
  4. 使用fit方法将模型拟合到数据。这意味着我们希望刚创建的模型根据这个特定的算法和提供的参数/选项“学习”数据集。这是你需要的代码:

    kmeans1.fit(sizes)
    KMeans(n_clusters=1)
    

现在我们有了一个在该数据集上训练好的模型,可以继续检查它的一些属性。按照惯例,拟合模型的结果属性会带有一个下划线,如我们接下来所看到的。我们现在可以询问我们请求的聚类。cluster_centers_属性给出了答案。聚类中心(在这个例子中是一个中心)基本上是我们数据点聚类的均值。让我们查看结果,如下所示:

kmeans1.cluster_centers_
array([[5.25]])

我们以列表形式接收到特征数据。显然,我们的聚类中心是5.25。你可能会认为这是一种复杂的方式来计算我们数据集的均值,你说得对。看看以下代码片段:

sizes.mean()
5.25

确实,我们的聚类中心恰好是我们数据集的均值,这正是我们所期望的。为了可视化这个结果,以下截图显示了聚类中心相对于数据点的位置:

图 9.1 – 尺寸数据点,KMeans 提供的聚类中心

图 9.1 – 尺寸数据点,KMeans 提供的聚类中心

前面截图中显示的图表非常简单——我们只是将尺寸绘制在X轴上,Y轴则是一个任意的常数值。

为了评估我们模型的表现以及它与数据的拟合程度,有几种方法可以做到这一点——一种方法是检查inertia_属性。这是我们创建的实例的一个属性,在将其拟合到数据后,可以使用点符号来访问,如下所示:

kmeans1.inertia_
85.5

inertia_度量是样本到其最近聚类中心的平方距离的总和。如果模型表现良好,样本到提供的聚类中心的距离应该尽可能短(数据点离聚类中心很近)。一个完美的模型的惯性率将是0。从另一个角度看,我们也知道,要求只有一个聚类会给我们最差的结果,因为它只是一个聚类,要成为平均点,它必须远离极端数据点。

因此,我们可以通过添加更多的聚类来提高模型的性能,因为它们与中心的距离会减少。

现在,假设我给你打电话并分享一些好消息。我们有额外的预算来增加一个新的尺寸,现在我们希望制作两种尺寸的 T 恤。用机器学习语言来说,这意味着我们需要创建一个包含两个聚类的新模型。我们重复相同的步骤并修改n_clusters参数,如下所示:

kmeans2 = KMeans(n_clusters=2)
kmeans2.fit(sizes)
kmeans2.cluster_centers_
array([[3.],
       [9.]])

现在,我们有了两个新的聚类中心,如指定的那样。

仅仅知道聚类中心是不够的。对于每个点,我们需要知道它属于哪个聚类,或者我们想知道给我们组内每个人的 T 恤尺寸。我们还可以对它们进行计数,并检查每个聚类中的数据点数量。

labels_属性包含了这些信息,可以在这里看到:

kmeans2.labels_
array([0, 0, 0, 0, 0, 1, 1, 1], dtype=int32)

注意,标签是从0开始的整数给出的。还要注意,这些数字并不代表任何量化的意义。标签为零的点并不来自第一个聚类;同样,标签为 1 的点在某种意义上也不“比”其他点更多。这些只是标签,例如将它们称为A 组B 组,依此类推。

我们可以通过使用zip函数将标签映射到它们各自的值,如下所示:

list(zip(sizes, kmeans2.labels_))
[(array([1]), 0),
 (array([2]), 0),
 (array([3]), 0),
 (array([4]), 0),
 (array([5]), 0),
 (array([7]), 1),
 (array([9]), 1),
 (array([11]), 1)]

这将在稍后使用这些标签绘制图表时非常重要。

让我们也可视化这两个聚类中心,以便更好地理解这个过程。以下截图显示了聚类中心相对于其他数据点的位置:

图 9.2 – 尺寸数据点,KMeans 提供了两个聚类中心

图 9.2 – 尺寸数据点,KMeans 提供了两个聚类中心

这些聚类中心在视觉上是合理的。我们可以看到前五个点彼此接近,而最后三个点则彼此分离,远离前五个点,且间隙较大。将 3 和 9 作为聚类中心是合理的,因为每个聚类中心都是其所在聚类值的平均值。现在让我们通过检查惯性率来数值验证我们是否提高了模型的性能,如下所示:

kmeans2.inertia_
18.0

的确,性能得到了极大的提升,从 85.5 降至 18.0。这里没有什么令人惊讶的地方。正如你所预期的那样,每增加一个聚类,性能都会改善,直到我们达到惯性为0的完美结果。那么,我们该如何评估选择聚类数的最佳选项呢?

寻找最优的聚类数

我们现在将看到选择最优聚类数的可选项及其含义,但首先让我们看一下下面的截图,以可视化从一个聚类到八个聚类的进展:

图 9.3 – 所有可能的聚类数的 数据点和聚类中心

图 9.3 – 所有可能聚类数的 数据点和聚类中心

我们可以看到所有可能聚类数的完整范围,以及它们与数据点的关系。在最后,当我们指定 8 时,得到了完美的解决方案,每个数据点都是一个聚类中心。

实际上,你可能不想选择完整的解决方案,主要有两个原因。首先,从成本的角度来看,这可能是不可行的。想象一下,如果要制作 1000 件 T 恤,而 T 恤有几百种尺寸。其次,在实际情况下,通常在达成某种适配后,继续增加聚类并不会带来太大的价值。以我们的 T 恤示例为例,假设有两个人的尺寸分别为 5.3 和 5.27,他们可能仍然会穿相同的尺寸。

所以,我们知道最优的聚类数介于 1 和我们拥有的唯一数据点数之间。接下来,我们想要探索如何确定这个最优数量的权衡和选择。我们可以采用的一个策略是检查新增(或增量)聚类的值。当增加一个新聚类时,它是否带来了惯性的显著下降(改善)?一种这样的技术叫做“肘部法”。我们将惯性值与聚类数进行绘制,看看曲线方向发生急剧变化的地方。现在让我们来做一下这个操作。

我们从 1 到 8 进行循环,对于每个数字,我们都经历相同的过程:实例化一个KMeans对象,并获取该聚类数的惯性值。然后,我们将该值添加到我们的inertia列表中,如下面的代码片段所示:

inertia = []
for i in range(1, 9):
    kmeans = KMeans(i)
    kmeans.fit(sizes)
    inertia.append(kmeans.inertia_)
inertia
[85.5, 18.0, 10.5, 4.5, 2.5, 1.0, 0.5, 0.0]

如预期的那样,我们的惯性从 85.5 改善到了最后的零。

我们现在将这些值绘制出来,看看“肘部”位置在哪里,如下所示:

import plotly.graph_objects as go
fig = go.Figure()
fig.add_scatter(x=list(range(1, 9)), y=inertia)
fig.layout.xaxis.title = 'Number of clusters'
fig.layout.yaxis.title = 'Inertia'
fig.show()

运行前面的代码会生成如下截图所示的图表:

图 9.4 – “肘部”方法,显示所有可能聚类数的惯性值

图 9.4 – “肘部”方法,显示所有可能聚类数的惯性值

你可以清楚地看到,当聚类数量从 1 变为 2 时,惯性突然下降,随着聚类数量趋向最终值,惯性继续下降,但下降的速度变慢。所以,三个或许四个聚类可能是我们开始获得递减回报的点,这可能是我们聚类的最佳数量。我们也稍微作弊了一下,包含了一个聚类,因为我们已经知道它将是最差的聚类数量。你可以在以下截图中看到没有第一个值的相同图形:

图 9.5 – “肘部”法则,展示了所有可能的聚类数量的惯性值(不包括 1)

图 9.5 – “肘部”法则,展示了所有可能的聚类数量的惯性值(不包括 1)

这看起来完全不同,并且表明我们不能在不了解数据、使用场景以及可能存在的任何限制条件的情况下,机械地做出决策。

我们探索的这个示例在观察数量和维度数量上非常简单,只有一个维度。KMeans 聚类(以及一般的机器学习)通常处理多个维度,概念基本相同:我们尝试找到聚类的中心,使得它们与数据点之间的距离最小。例如,下面的截图展示了在二维空间中,类似问题的样子:

图 9.6 – 二维空间中的聚类点

图 9.6 – 二维空间中的聚类点

这可能对应于与我们的人群相关的额外测量值。例如,我们可能将他们的身高放在 x 轴上,体重放在 y 轴上。你可以想象 KMeans 在这种情况下会给我们什么结果。当然,现实中数据很少如此整齐地聚集在一起。你也可以看到,如果选择错误的聚类数量,我们可能会损失多少准确性。例如,如果我们指定三个聚类,图中的三个中间块可能会被认为是一个单一的聚类,尽管我们可以看到它们之间有明显的差异,而且它们的点非常接近彼此。此外,如果我们指定七个或八个聚类,我们可能会在聚类之间得到不必要的划分,或者我们已经越过了“肘部”图中的肘部。

我们现在准备好在我们的数据集中使用这一聚类理解了。

按人口对国家进行聚类

我们将首先通过一个我们熟悉的指标(人口)来理解这个问题,然后使其具有互动性。我们将根据各国的人口对其进行聚类。

我们从一个可能的实际情境开始。假设你被要求按人口将国家分组。你需要将国家分成两组:人口高的和人口低的。你该怎么做呢?你会在哪些地方画分界线,人口总数多少才算是“高”?假设你被要求将国家根据人口分成三组或四组。那么你将如何更新你的聚类呢?

我们可以轻松看到,KMeans聚类非常适合这种情况。

现在,我们用一个维度进行KMeans聚类,并将其与我们的地图绘制知识结合,操作如下:

  1. 导入pandas并打开poverty数据集,如下所示:

    import pandas as pd
    poverty = pd.read_csv('data/poverty.csv')
    
  2. 创建年份和所需指标的变量,如下所示:

    year = 2018
    indicators = ['Population, total']
    
  3. 实例化一个KMeans对象,指定所需的簇数,如下所示:

    kmeans = KMeans(n_clusters=2)
    
  4. 创建一个df对象,这是包含所选年份的国家和数据的poverty数据框。运行以下代码来实现这一点:

    df = poverty[poverty['year'].eq(year) & poverty['is_country']]
    
  5. 创建一个data对象,这是我们选择的列的列表(在这种情况下,我们只选择了一列)。请注意,下面的代码片段中我们获取了它的values属性,这会返回底层的NumPy数组:

    data = df[indicators].values
    
  6. 将模型拟合到数据,如下所示:

    kmeans.fit(data)
    

我们现在已经在数据上训练了模型,并准备好可视化结果。记得我们在第七章中讨论过,探索地图绘图并通过 Markdown 丰富你的仪表盘,为了创建一张地图,我们只需要一个包含国家名称(或代码)的列的 DataFrame?这就足够生成一张地图。如果我们想要为国家上色,我们需要另一列(或任何类似列表的对象),其中包含相应的值。

我们刚刚训练的kmeans对象包含了各个国家的标签,并告诉我们哪个国家属于哪个簇。我们将利用这个信息来给国家上色,所以我们通过一次函数调用来完成这一操作。请注意,我们可以将标签转换为字符串,这会导致 Plotly Express 将它们视为分类变量,而非连续变量。代码如下所示:

px.choropleth(df,
              locations='Country Name',
              locationmode='country names',
              color=[str(x) for x in  kmeans.labels_])

这段代码生成了如下截图所示的图表:

图 9.7 – 按人口聚类的国家

图 9.7 – 按人口聚类的国家

由于我们已经开发了地图选项的模板,我们可以复制这个模板并用它来增强此地图,使其与我们应用程序的主题保持一致。让我们使用它,并查看在同一张地图上显示1234个簇的效果,并讨论细节。下方截图显示了四张地图,每张地图的簇数不同:

图 9.8 – 按人口聚类的国家,使用不同数量的簇

图 9.8 – 按人口聚类的国家,使用不同数量的簇

重要提示

如果你正在查看灰度版本,这些地图上的颜色可能不容易区分,我建议你查看在线版本和代码库。

如你所见,将地图着色为一个簇(所有国家使用同一标签)会生成一个单一颜色的地图。当涉及到两个簇时,事情就变得有趣了,这也符合直觉。组成具有较高人口的簇的两个国家(即中国和印度)有着非常庞大的人口,而且彼此非常接近——分别为 13.9 亿和 13.5 亿。第三个国家,美国US),人口为 3.27 亿。这正是 KMeans 应该做的事情。它将我们分成了两个国家群体,在每个簇中的国家彼此非常接近,而与其他簇的国家则相距较远。当然,我们通过选择两个簇数引入了一个重要的偏差,并且我们看到了这种选择可能并不是最优的情况。

当我们选择三个簇时,可以看到我们有一个中等人口的簇,其中美国是其中之一。然后,当我们选择四个簇时,你会看到俄罗斯和日本被移到了第三类,尽管它们在三个簇时属于第二类。

现在我们已经有足够的代码和知识来将其提升到一个新的层次。我们希望为用户提供选择簇的数量和他们想要的指标的选项。我们需要首先解决数据中的一些问题,所以让我们来探讨一下。

使用 scikit-learn 准备数据

scikit-learn是 Python 中最广泛使用且最全面的机器学习库之一。它与数据科学生态系统中的其他库(如NumPypandasmatplotlib)兼容得很好。我们将使用它来对我们的数据进行建模和预处理。

现在我们有两个问题需要首先解决:缺失值和数据缩放。让我们分别看两个简单示例,然后在我们的数据集中解决这些问题。首先从缺失值开始。

处理缺失值

模型需要数据,它们无法处理包含缺失值的一组数字。在这种情况下(我们的数据集中有很多类似情况),我们需要决定如何处理这些缺失值。

有多种选择,正确的选择取决于应用场景以及数据的性质,但我们不会深入讨论这些细节。为了简化,我们将做出一个通用选择,用合适的值替换缺失数据。

让我们通过一个简单的示例来探索如何填补缺失值,如下所示:

  1. 创建一个包含缺失值的简单数据集,格式合适,如以下代码片段所示:

    data = np.array([1, 2, 1, 2, np.nan]).reshape(-1, 1)
    
  2. 导入scikit-learn中的SimpleImputer,如下所示:

    from sklearn.impute import SimpleImputer
    
  3. 使用mean策略创建此类的实例,这是默认策略。正如你可能猜到的,除了这个策略,还有其他策略用于填补缺失值。以下代码片段展示了这一点:

    imp = SimpleImputer(strategy='mean')
    
  4. 将模型拟合到数据。这是模型根据我们在实例化时设置的条件和选项来学习数据的地方。代码如以下片段所示:

    imp.fit(data)
    
  5. 转换数据。现在,模型已经学习了数据,它能够根据我们设置的规则对数据进行转换。transform 方法在许多模型中都有,其含义取决于上下文。在此情况下,转换是指使用 mean 策略填充缺失数据。代码见下列代码片段:

    imp.transform(data)
    array([[1\. ],
           [2\. ],
           [1\. ],
           [2\. ],
           [1.5]])
    

如您所见,模型已通过将缺失值替换为 1.5 来转换数据。如果您查看其他非缺失值 [1, 2, 1, 2],您会发现它们的均值为 1.5,这正是我们得到的结果。我们本可以指定不同的缺失值填充策略,如中位数或最频繁值策略。每种策略都有其优缺点;我们这里只是探索在 Dash 中通过机器学习可以做什么。

接下来,我们将开始对数据进行标准化处理。

使用 scikit-learn 对数据进行标准化

图 9.6 中,我们看到了二维数据聚类的效果。如果我们想根据两个指标对贫困数据进行聚类,一个指标会在 x 轴上,另一个则在 y 轴上。现在,假设我们在一个轴上有人口数据,另一个轴上有百分比指标。人口轴上的数据范围是 0 到 14 亿,而另一个轴上的数据范围是 0 到 1(或 0 到 100)。百分比指标的任何差异对距离的影响可以忽略不计,均值主要通过人口数量的不成比例大小来计算。解决这个问题的一种方法是对数据进行标准化。

有不同的策略来缩放数据,我们将探索其中一种——即标准化缩放。StandardScaler 类为数据点分配 z 分数(或标准分数)并对其进行标准化。z 分数的计算方式是将每个值减去均值,然后除以标准差。虽然有其他计算方法,但我们将专注于一个简单的示例来更好地说明这一概念,如下所示:

  1. 创建一个简单的数据集,如下所示:

    data = np.array([1, 2, 3, 4, 5]).reshape(-1, 1)
    
  2. 导入 StandardScaler 并创建其实例,如下所示:

    from sklearn.preprocessing import StandardScaler
    scaler = StandardScaler()
    
  3. scaler 拟合到数据并进行转换。为了方便,许多具有 fittransform 方法的模型,也有一个 fit_transform 方法,我们将使用这个方法,如下所示:

    scaler.fit_transform(data)
    array([[-1.41421356],
           [-0.70710678],
           [ 0.        ],
           [ 0.70710678],
           [ 1.41421356]])
    

现在,我们已将数据转换为其相应的 z 分数。请注意,均值 3 现在变成了 0。大于 3 的数值为正,小于 3 的为负。这些数值还表示了对应数值与均值的距离(高或低)。

这样,当我们的数据集包含多个特征时,我们可以对它们进行标准化,进行比较,并一起使用。最终,我们关心的是某个值的极端程度以及它与均值的接近度。一个基尼指数为 90 的国家是一个极端案例。这就像一个人口达到 10 亿的国家。如果我们将这两者结合使用,10 亿的人口将主导并扭曲计算。标准化帮助我们以更好的方式处理不同尺度的数据。它仍然不是完美的,但比使用不同尺度的数据要好得多。现在,我们可以在聚类数据时使用多个特征。

创建一个互动式 KMeans 聚类应用程序

现在,让我们将所有内容整合起来,使用我们的数据集创建一个互动式聚类应用程序。我们将让用户选择年份,以及他们想要的指标。用户还可以选择聚类数,并根据发现的聚类以有色分区地图的形式获得聚类的可视化表示。

请注意,使用多个指标时,解读这些结果是具有挑战性的,因为我们将处理多个维度。如果你不是经济学家,并且不清楚哪些指标应该与其他哪些指标进行比较,那么理解这些结果也会很困难。

以下截图展示了我们将要实现的效果:

图 9.9 – 一个互动式 KMeans 聚类应用程序

图 9.9 – 一个互动式 KMeans 聚类应用程序

正如你所看到的,这是一个非常丰富的应用程序,提供了多种选项组合。正如我之前提到的,这并不是一个直接易懂的过程,但正如本章多次提到的,我们只是在探索如何仅使用一种技术和其部分选项来实现目标。

本书中我们已经创建了许多滑块和下拉框,所以我们不会再讲解如何创建它们。我们只需要确保它们具有描述性的 ID,我将留给你填充空白。正如前面的截图所示,我们有两个滑块,一个下拉框和一个图形组件,因此我们需要为它们设置 ID 名称。像往常一样,以下组件应该放置在 app.layout 中你想要的位置:

dcc.Slider(id='year_cluster_slider', …),
dcc.Slider(id='ncluster_cluster_slider', …),
dcc.Dropdown(id='cluster_indicator_dropdown', …),
dcc.Graph(id='clustered_map_chart', …)

接下来,我们将逐步介绍如何创建回调函数,如下所示:

  1. 在回调函数中关联输入和输出,如下所示:

    @app.callback(Output('clustered_map_chart', 'figure'),
                  Input('year_cluster_slider', 'value'),
                  Input('ncluster_cluster_slider', 'value'),
                  Input('cluster_indicator_dropdown', 'value'))
    
  2. 创建具有合适参数名称的函数签名,如下所示:

    def clustered_map(year, n_clusters, indicators):
    
  3. 实例化一个缺失值填充器、一个标准化缩放器和一个 KMeans 对象。请注意,使用 SimpleImputer 时,我们还指定了缺失值的编码方式。在本例中,缺失值编码为 np.nan,但在其他情况下,可能会使用不同的编码方式,比如 N/A0-1 或其他。代码如下所示:

    imp = SimpleImputer(missing_values=np.nan, strategy='mean')
    scaler = StandardScaler()
    kmeans = KMeans(n_clusters=n_clusters)
    
  4. 创建df,它是poverty的一个子集,仅包含国家数据和所选年份的数据,然后选择yearCountry Name列,以及所选指标。代码如下所示:

    df = poverty[poverty['is_country'] & poverty['year'].eq(year)][indicators + ['Country Name', 'year']]
    
  5. 创建data,它是df的一个子集,仅包含所选指标的列。我们有两个不同的对象是因为df将用于绘制地图,并且还会使用年份和国家名称。同时,data仅包含数值,以便我们的模型能够处理它。代码如下所示:

    data = df[indicators]
    
  6. 在某些情况下,正如我们在书中多次看到的,我们可能会遇到某一列完全为空的情况。在这种情况下,我们无法填充任何缺失值,因为没有均值,并且我们完全不知道该如何处理它。在这种情况下,我认为最好的做法是不要生成图表,并告知用户,对于所选的选项组合,数据不足以运行模型。我们首先检查是否存在这种情况。DataFrame 对象有一个isna方法。当我们运行它时,它会返回一个填充了TrueFalse值的 DataFrame,表示相应的值是否缺失。然后,我们对结果 DataFrame 运行all方法。这将告诉我们每列是否所有值都缺失。现在,我们有一个包含TrueFalse值的 pandas Series。我们通过使用any方法检查其中是否有True。在这种情况下,我们创建一个空的图表,并附上说明性标题,如下所示:

    if df.isna().all().any():
        return px.scatter(title='No available data for the selected combination of year/indicators.')
    
  7. 如果一切正常,且我们没有一个列的所有值都缺失,我们继续创建一个没有缺失值的变量(如果有缺失值,则进行填充),如下所示:

    data_no_na = imp.fit_transform(data)
    
  8. 接下来,我们使用StandardScaler实例对data_no_na进行标准化,如下所示:

    scaled_data = scaler.fit_transform(data_no_na)
    
  9. 然后,我们将模型拟合到我们的标准化数据,如下所示:

    kmeans.fit(scaled_data)
    
  10. 我们现在拥有生成图表所需的一切——最重要的是labels_属性——并且我们可以通过一次调用px.choropleth来生成图表。正如你在下面的代码片段中看到的,我们在此函数中使用的选项没有任何新内容:

    fig = px.choropleth(df,
                        locations='Country Name',
                        locationmode='country names',
                        color=[str(x) for x in  kmeans.labels_],
                        labels={'color': 'Cluster'},
                        hover_data=indicators,
                        height=650,
                        title=f'Country clusters - {year}. Number of clusters: {n_clusters}<br>Inertia: {kmeans.inertia_:,.2f}')
    

然后,我们复制已经用于自定义地图的地理属性,并使其与应用程序整体保持一致。

这是完整的函数,包括地理选项,供你参考:

@app.callback(Output('clustered_map_chart', 'figure'),
              Input('year_cluster_slider', 'value'),
              Input('ncluster_cluster_slider', 'value'),
              Input('cluster_indicator_dropdown', 'value'))
def clustered_map(year, n_clusters, indicators):
    imp = SimpleImputer(missing_values=np.nan, strategy='mean')
    scaler = StandardScaler()
    kmeans = KMeans(n_clusters=n_clusters)
    df = poverty[poverty['is_country'] & poverty['year'].eq(year)][indicators + ['Country Name', 'year']]
    data = df[indicators]
    if df.isna().all().any():
        return px.scatter(title='No available data for the selected combination of year/indicators.')
    data_no_na = imp.fit_transform(data)
    scaled_data = scaler.fit_transform(data_no_na)
    kmeans.fit(scaled_data)
    fig = px.choropleth(df,
                        locations='Country Name',
                        locationmode='country names',
                        color=[str(x) for x in  kmeans.labels_],
                        labels={'color': 'Cluster'},
                        hover_data=indicators,
                        height=650,
                        title=f'Country clusters - {year}. Number of clusters: {n_clusters}<br>Inertia: {kmeans.inertia_:,.2f}',
                  color_discrete_sequence=px.colors.qualitative.T10)
    fig.layout.geo.showframe = False
    fig.layout.geo.showcountries = True
    fig.layout.geo.projection.type = 'natural earth'
    fig.layout.geo.lataxis.range = [-53, 76]
    fig.layout.geo.lonaxis.range = [-137, 168]
    fig.layout.geo.landcolor = 'white'
    fig.layout.geo.bgcolor = '#E5ECF6'
    fig.layout.paper_bgcolor = '#E5ECF6'
    fig.layout.geo.countrycolor = 'gray'
    fig.layout.geo.coastlinecolor = 'gray'
    return fig

在这一章中,我们在可视化和交互探索方面取得了很大的进展。我们还简要介绍了一种机器学习技术,用于对我们的数据进行聚类。理想情况下,提供给用户的选项将取决于你所从事的学科。你可能自己是处理领域的专家,或者你可能与这样的专家密切合作。这不仅仅是可视化和统计的问题,领域知识也是分析数据的关键方面,尤其在机器学习中,这一点至关重要。

我鼓励你进一步学习,看看你能取得什么成就。正如我们所看到的,掌握创建互动式仪表板的技能对运行机器学习模型是一个巨大的优势,它让你能够以更快的速度发现趋势并做出决策。最终,你将能够创建自动化解决方案,提供建议或基于特定输入做出决策。

现在,让我们回顾一下本章所学的内容。

总结

我们首先了解了聚类是如何工作的。我们为一个小型数据集构建了最简单的模型。我们多次运行模型,并评估了每次选择不同聚类数量时的性能和结果。

接着,我们探索了肘部法则来评估不同的聚类,并看到了如何发现收益递减点,在这个点之后,增加新的聚类不会带来显著的改进。通过这些知识,我们使用相同的技术根据一个大多数人都熟悉的指标对国家进行聚类,并亲身体验了它如何在真实数据上运作。

之后,我们设计了一个互动式 KMeans 应用,并探索了在运行模型之前准备数据的两种技术。我们主要探讨了填补缺失值和数据标准化。

这为我们提供了足够的知识,将数据整理成适合创建互动应用的格式,正如我们在本章末尾所做的那样。

接下来,我们探讨了 Dash 回调的高级功能——最显著的是模式匹配回调。到目前为止,我们运行的回调都是直接且固定的。许多时候,我们希望为用户创建更动态的界面。例如,基于在下拉框中选择的某个值,我们可能希望显示一种特殊类型的图表或创建另一个下拉框。我们将在下一章探讨这种方式的工作原理。