「翻译」如何用 Python 画出像 FiveThirtyEight 那么棒的图表
Oct 15, 2017
7 minute read

如果你经常读数据科学领域的文章的话,你可能会偶然发现 FiveThirtyEight 上的内容,然后被他们惊艳的图表迷住。于是你自己也想制作如此出色的可视化作品,于是去 Quora 和 Reddit 上问怎么做。你收到了几个回答,但是这些回答都很模糊。你还是不知道怎么搞定这样的图表。

在这篇博文中,我会手把手地帮你。通过使用 Python 的 matplotlib 和 pandas 库,我们会发现复制出 FTE 可视化作品的核心部分是多么轻松写意。

这是我们最初的图:

最初图片

在这篇文章的结束,我们会做到这样:

最后图片

为了跟上,你需要至少了解一些 Python 的基础知识。如果你知道方法和属性之间的区别,那我们就可以开始了。

介绍数据集

我们将要处理的数据集展现的事从 1970 年到 2011 年在美国授予女性的学位比例。我们使用的数据集是数据科学家 Randal Olson 从国家教育统计中心采集的。

如果你想通过自己写代码来学习,你可以从 Randal 的博客下载数据集。如果想节省时间的话,你可以跳过下载文件,直接把链接甩给 pandas 的 read_csv() 函数。在下面的代码中,我们做了:

  • 导入 pandas 模块
  • 把数据集的链接通过字符串保存在变量 direct_link
  • 通过 read_csv() 读取数据,并把内容保存在 women_majors
  • 使用 info() 方法展示数据集的基本信息,了解行数和列数,同时找一找有没有缺失的值
  • 使用 head() 方法显示出数据集的前 5 行可以帮助我们更好地理解数据集的结构
import pandas as pd

direct_link = 'http://www.randalolson.com/wp-content/uploads/percent-bachelors-degrees-women-usa.csv'
women_majors = pd.read_csv(direct_link)

print(women_majors.info())
women_majors.head()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 42 entries, 0 to 41
Data columns (total 18 columns):
Year                             42 non-null int64
Agriculture                      42 non-null float64
Architecture                     42 non-null float64
Art and Performance              42 non-null float64
Biology                          42 non-null float64
Business                         42 non-null float64
Communications and Journalism    42 non-null float64
Computer Science                 42 non-null float64
Education                        42 non-null float64
Engineering                      42 non-null float64
English                          42 non-null float64
Foreign Languages                42 non-null float64
Health Professions               42 non-null float64
Math and Statistics              42 non-null float64
Physical Sciences                42 non-null float64
Psychology                       42 non-null float64
Public Administration            42 non-null float64
Social Sciences and History      42 non-null float64
dtypes: float64(17), int64(1)
memory usage: 6.0 KB
None

除了 Year 这一列之外,其他每一列都指明了学士学位的具体科目。而在这些列之下的数据点代表了授予的学士学位中女性所占的百分比。当然每一行就表示的是在该行特指的年份之下,授予的学士学位中女性所占的百分比。

正如之前讲过的,我们的数据是从 1970 年到 2011 年的。为了确认这个时间段无误,我们通过 tail() 方法来看看数据集的最后 5 行:

women_majors.tail()

FiveThirtyEight 插图的上下文

几乎每一个 FTE 图表都是一篇文章的一部分。这些图表通过展示一个小故事或者一个新观点来补足文字内容。我们在复制我们的图表的时候需要牢记这一点。

为了避免这篇教程跑题,我们就假装我们已经写了一篇关于在美国教育中性别地位演变的文章的绝大部分了。我们现在需要制作一张图标来让读者对美国教育中的学士学位授予关于性别差异的演变有一个直观的感受,并且告诉读者在 1970 年,女性的地位确实很低。我们把最低阈值设定在 20%,现在我们想画出每一个在 1970 年毕业学士中女性比例低于 20% 的那些学科。

让我们先找到那些学科。在下面的代码中,我们要:

  • 使用 .loc 一个基于标签的索引利器来:
    • 选择第一行(就是 1970 年对应的那行)
    • 在第一行之中找到值小于 20 的那些列;Year 的部分也应该检查,但是显然不用和 20 相比较
  • 把选择出的部分保存在 under_20
under_20 = women_majors.loc[0, women_majors.loc[0] < 20]
under_20

输出为:

Agriculture           4.229798
Architecture         11.921005
Business              9.064439
Computer Science     13.600000
Engineering           0.800000
Physical Sciences    13.800000
Name: 0, dtype: float64

使用 matplotlib 的默认样式

让我们开始制作曲线图。我们先看一看我们使用默认样式做出来是什么样子,在下面的代码中,我们会:

  • 在 Jupyter 中用 %matplotlib 来让 Jupyter 和 matplotlib 能够正常工作,在后面加上 inline 能让画出的图直接在 Jupyter notebook 中显示。
  • 使用 plot() 方法来画出 women_majors 的初始版本。我们将传入这些参数:
    • x - 指明 women_majors 中用来做 x 轴的那一列;
    • y - 指明 women_majors 中用来做 y 轴的那一列;我们将使用 under_20 下储存在 .index 中的下标索引;
    • figsize - 设定图片的尺寸大小(设定格式是(width, height),单位是英寸)。
  • 把画出来的图形对象存储进 under_20_graph,然后打印出这个对象的类型,可以发现 pandas 实际上使用的是 matplotlib 的对象。
%matplotlib inline
under_20_graph = women_majors.plot(x = 'Year', y = under_20.index, figsize = (12,8))
print('Type:', type(under_20_graph))

pic1

使用 matplotlib 的 FTE 样式

上面的图具有一定的特征,比如宽度和线条的颜色,字体大小和 y 轴的标签,不显示网格等等。matplotlib 的默认样式包括了所有这些特征。

稍稍插入一段话,在这篇博文中我们将会使用一点术语。如果你有任何疑惑,可以去下面的网站找到解答。

matplotlib Source: Matplotlib.org

除了默认样式之外,matplotlib 也提供了几种我们可以直接来用的内建样式。让我们看看可用的样式列表:

  • 导入 matplotlib.style 模块
  • 探索 matplotlib.style.available 的内容,里面包括了所有可用的内建样式
import matplotlib.style as style
style.available
['seaborn-deep',
 'seaborn-muted',
 'bmh',
 'seaborn-white',
 'dark_background',
 'seaborn-notebook',
 'seaborn-darkgrid',
 'grayscale',
 'seaborn-paper',
 'seaborn-talk',
 'seaborn-bright',
 'classic',
 'seaborn-colorblind',
 'seaborn-ticks',
 'ggplot',
 'seaborn',
 '_classic_test',
 'fivethirtyeight',
 'seaborn-dark-palette',
 'seaborn-dark',
 'seaborn-whitegrid',
 'seaborn-pastel',
 'seaborn-poster']

你可能已经发现有一个样式名字叫「fivethirtyeight」。让我们使用它看看会得到什么效果。只需要从 matplotlib.style 模块下使用函数 use(),我们就可以生成我们想要的相同曲线图了。

style.use('fivethirtyeight')
women_majors.plot(x = 'Year', y = under_20.index, figsize = (12,8))

fte01

确实改变了许多!对比我们的第一张图,我们可以发现这张曲线图的背景颜色不同,出现了网格线,没有明显的毛刺,字体大小和坐标轴刻度等等也有了很大的不同。

你可以在 这里 读到 FTE 样式的更多介绍。里面会有使用这个样式时的具体代码讲解。在 这里 可以看到这个样式作者 Cameron David-Pilon 关于它的更多讨论。

matplotlib 的 FTE 样式的限制

总而言之,使用 matplotlib 提供的 FTE 样式让我们离目标近了一步。然而,还有许多事需要我们完成。让我们检查一下一个简单的 FTE 图表示例,看看我们还需要补充什么。

FTE sample

Source: FiveThirtyEight

通过比较上面的图示和我们所完成的那张图,我们还需要:

  • 增加一个标题和副标题
  • 移除那个方块型的图例,而在相关的曲线旁边增加标签,并且这些标签旁边的网格线变成透明的
  • 在图的底部增加一个签名,包括这张图的作者和数据源
  • 其他一些小调整:
    • 增加刻度标记的字体大小
    • 在 y 轴的某个主要刻度标记上增加「%」标志
    • 移除 x 轴的标签
    • 加粗 y = 0 的水平线
    • 在 y 轴的刻度标记旁边增加一条额外的网格线
    • 增加图的侧边距

为了节省我们制图的时间,避免在开始的时候添加标题,副标题或者其他文字非常重要。在 matplotlib 中,一段文字是通过给定的 x 和 y 坐标放置的,我们会在之后的内容里看到。为了从细节上复制 FTE 的制图风格,我们需要把 y 轴刻度标记和标题与副标题竖直对齐。我们不希望看到当我们设定好对齐之后,却通过增加刻度标签字体大小的时候错乱了,到时候还得重新调整标题和副标题的位置。

定制刻度标记

我们先从增加刻度标记的字体大小开始,在下面的代码中,我们会:

  • 使用之前的代码画图,把图像对象保存在 fte_graph 中。这样可以帮助我们探知它的一些属性,并且重复使用和修改这个对象。
  • 使用 tick_params() 方法增加所有刻度标记的字体大小,各种参数如下:
    • axis - 指明我们想要修改的坐标轴是哪条,在这里我们想要两条都改;
    • which - 指明需要改哪里的刻度标记(主要的/次要的),如果不理解可以看上文给出的链接;
    • labelsize - 设定刻度标记的字体大小
fte_graph = women_majors.plot(x = 'Year', y = under_20.index, figsize = (12,8))
fte_graph.tick_params(axis = 'both', which = 'major', labelsize = 18)

fte02

你可能已经发现我们这次没有使用 style.use('fivethirtyeight')。这是因为 matplotlib 样式的首选项已经在上文第一次提到 style 的时候全局更改为 fivethirtyeight 了。之后的所有图都会继承这个样式。如果你想要回到默认的状态,可以使用 style.use('default')

我们现在在原有的基础上继续调整 y 轴的刻度标记:

  • 我们对 y 轴可以看到的最高标记 50 增加一个「%」标记
  • 我们还需要对其他的几个刻度标记增加一个空格字符,保证它们可以和「50%」完美对齐

为了改变 y 轴的刻度标记,我们使用了 set_yticklabels() 这个方法。从下面的代码中,你可以看到 label 这个参数接受一个多种数据类型混合的列表,而并不要求固定数量或形式的标记。

# Customizing the tick labels of the y-axis
fte_graph.set_yticklabels(labels = [-10, '0   ', '10   ', '20   ', '30   ', '40   ', '50%'])
print('The tick labels of the y-axis:', fte_graph.get_yticks()) # -10 and 60 are not visible on the graph

输出结果:

The tick labels of the y-axis: [-10.   0.  10.  20.  30.  40.  50.  60.]

对 y = 0 处的水平线加粗

我们现在需要加粗 y 轴坐标为 0 时的水平线。使用 axhline() 方法可以增加一条新的水平网格线,覆盖住原有的水平轴。axhline() 的参数有:

  • y - 指明水平线的 y 坐标;
  • color - 指明线的颜色;
  • linewidth - 设定线的宽度;
  • alpha - 控制线的透明度,但我们在这里是用来控制黑色的强度;alpha 值的范围从 0 到 1(完全透明到完全不透明)
# Generate a bolded horizontal line at y = 0
fte_graph.axhline(y = 0, color = 'black', linewidth = 1.3, alpha = .7)

fte03

增加一个额外的垂直线

如我们之前所讲,我们需要在紧靠 y 轴刻度标记旁边增加一条竖直的网格线。要加一条线的话,就需要稍微修改一下 x 轴的数据范围,把区间稍微往左边扩大一点,这样才有空间添加我们想要的新线。

在下面,我们使用 set_xlim() 方法,两个参数 leftright 的意义不言自明。

# Add an extra vertical line by tweaking the range of the x-axis
fte_graph.set_xlim(left = 1969, right = 2011)

生成一条签名栏

FTE 图像示例的签名栏有着很明显的几个特征:

  • 位置位于图片底部
  • 作者的姓名位于签名栏的左边
  • 数据源位于签名栏的右边
  • 文字的颜色是浅灰色(和图片背景的颜色一样),背景是深灰色
  • 作者姓名和数据源之间的空间背景也是深灰色

ftesample

增加这样一个签名栏好像很难,但我们稍微动动脑筋,就可以轻松地完成。

我们增加一个单独的文字段,将其文字颜色和背景颜色分别设定为浅灰色和深灰色。我们可以把作者姓名和数据源放在一个文字段中,然后把它们间隔开,一个放在左边,一个放在右边。中间的空格是不会显示出来的,所以保持着背景颜色。

我们还需要一些刻个来让作者姓名和数据源对齐,这些都会在下个代码块里面看到。

也是时候移除 x 轴的刻度标记了!这样,我们可以看到签名栏在整体的曲线图中达到了什么样的视觉效果。在下一段代码中,我们要:

  • fte_graph.xaxis.label 使用 set_visible() 方法移除 x 轴的刻度标记,把这个属性的值改成 False
  • 通过我们上面讨论过的方法增加一个文字片段。我们所使用的 text() 方法有以下几个参数:
    • x - 指明文字段的 x 坐标;
    • y - 指明文字段的 y 坐标;
    • s - 指明要添加的文字;
    • fontsize - 设定文字的字体大小;
    • color - 指明文字的颜色;下面我们使用这个值的格式是 16 进制的,这个颜色和整张图的背景颜色完全一致;
    • backgroundcolor - 设定文字段的背景颜色
# Remove the label of the x-axis
fte_graph.xaxis.label.set_visible(False)

# The signature bar
fte_graph.text(x = 1965.8, y = -7,
    s = '   ©DATAQUEST                                                                                 Source: National Center for Education Statistics   ',
    fontsize = 14, color = '#f0f0f0', backgroundcolor = 'grey')

fte05

这块文字段的 x 和 y 坐标是通过了一段漫长的试验和错误之后才确定的。把坐标设定为浮点数能够让你更加精准地控制文字的位置。

值得一提的是,我们通过调整签名栏的位置的同时,也在视觉上增加了侧边距。降低文字段的 x 坐标增加左边距,而在作者名字和数据源之间增加空格增加了右边距。

另一种签名栏

还有另一种类型的签名栏:

ftesample02

Source: FiveThirtyEight

这种签名栏也可以简单地复制出来,我们只需要改变一些文字颜色和背景颜色就行了。

我们通过增加一块包括许多下划线的「_」文字段来达到一条线的视觉效果。你可能想问为什么我们不使用 axhline() 来画一条我们想要的水平线。那是因为添加一条新的线会让整体的网格也向下扩张,这不是我们想要的效果。

我们还可以试着加一个箭头,然后把指针去掉获得一条线,但是明显,「下划线」方案更加简单。

在下一段代码中,我们将实现这些元素。这里用到的方法和参数,相信读者朋友们已经很熟悉了。

# The other signature bar
fte_graph.text(x = 1967.1, y = -6.5,
    s = '________________________________________________________________________________________________________________',
    color = 'grey', alpha = .7)

fte_graph.text(x = 1966.1, y = -9,
    s = '   ©DATAQUEST                                                                               Source: National Center for Education Statistics   ',
    fontsize = 14, color = 'grey', alpha = .7)

fte06

增加一个标题和副标题

如果你检查许多 FTE 的图表,你会发现它们的标题和副标题遵循着这样的模式:

  • 标题文字几乎都需要一个副标题补充
  • 一副特定图示的标题需要考虑到上下文的角度。它不会太复杂,太技术性,而是简单地表达出一个清晰的观点。标题也从来不会出现一个中立的情绪。就如同上文中「Fandango」的那幅图,我们可以看到一个简单的,饱含情绪的标题:「Fandango LOVES Movies」,而不是一个苍白的「电影评分类型的分布情况」这样的标题
  • 副标题提供的事这幅图的技术性信息,它常常会使坐标轴的标签显得多余。我们在完成副标题时需要注意,因为我们已经把 x 轴的标签删掉了
  • 从视觉上讲,标题和副标题有不一样的字重,而且是左对齐的(不像大多数标题是居中的),并且和 y 轴的主要刻度标记左对齐

让我们牢记以上几点,为这幅图增添一个标题和副标题。在下面的代码块中,我们将要:

  • 使用 text() 方法增添一个标题和副标题。如果你已经有使用 matplotlib 的经验的话,你可能好奇为什么我们不使用 title()suptitle() 这两个方法。这是因为这两个方法在精确移动文字位置上显得无比笨拙。这次唯一的新参数是 weight,我们使用它来加粗标题。
# Adding a title and a subtitle
fte_graph.text(x = 1966.65, y = 62.7, s = "The gender gap is transitory - even for extreme cases",
               fontsize = 26, weight = 'bold', alpha = .75)
fte_graph.text(x = 1966.65, y = 57,
               s = 'Percentage of Bachelors conferred to women from 1970 to 2011 in the US for\nextreme cases where the percentage was less than 20% in 1970',
              fontsize = 19, alpha = .85)

如果你好奇的话,原本的 FTE 图示中使用的字体是一个付费字体「Decima Mono」。所以我们还是使用 matplotlib 的默认字体,看起来也不错。

增加对色盲友好的颜色

现在,那块笨重的方形图例还在。我们需要彻底丢掉它,在每条曲线旁边增加相应的标签。每条曲线有一种特定的颜色,并且有一个对应于每条曲线的解释标签。

首先,我们改变一下曲线的颜色,添加一些对色盲友好的颜色:

colorblind

我们所编写的面向色盲友好的颜色的 RGB 值出自上面这张图。作为边注,我们要避免使用黄色,因为这种颜色在灰色背景下的可读性非常差。

完成之后,我们把 RGB 传入 plot() 方法中的颜色参数。注意 matplotlib 要求 RGB 参数在 0-1 的范围之间,所以我们把每个值除以 255(最大的 RGB 值)。

# Colorblind-friendly colors
colors = [[0,0,0], [230/255,159/255,0], [86/255,180/255,233/255], [0,158/255,115/255],
          [213/255,94/255,0], [0,114/255,178/255]]

# The previous code we modify
fte_graph = women_majors.plot(x = 'Year', y = under_20.index, figsize = (12,8), color = colors)

# The previous code that remains the same
fte_graph.tick_params(axis = 'both', which = 'major', labelsize = 18)
fte_graph.set_yticklabels(labels = [-10, '0   ', '10   ', '20   ', '30   ', '40   ', '50%'])
fte_graph.axhline(y = 0, color = 'black', linewidth = 1.3, alpha = .7)
fte_graph.xaxis.label.set_visible(False)
fte_graph.set_xlim(left = 1969, right = 2011)
fte_graph.text(x = 1965.8, y = -7,
    s = '   ©DATAQUEST                                                                                 Source: National Center for Education Statistics   ',
    fontsize = 14, color = '#f0f0f0', backgroundcolor = 'grey')
fte_graph.text(x = 1966.65, y = 62.7, s = "The gender gap is transitory - even for extreme cases",
               fontsize = 26, weight = 'bold', alpha = .75)
fte_graph.text(x = 1966.65, y = 57,
               s = 'Percentage of Bachelors conferred to women from 1970 to 2011 in the US for\nextreme cases where the percentage was less than 20% in 1970',
              fontsize = 19, alpha = .85)

fte08

把图例样式变成线条边的标签

最后,我们通过 text() 方法在每条曲线旁边增加一个颜色一致的标签。唯一的新参数是 rotation,我们用它旋转每个标签,这样看起来更加优雅。

这里还需要一点小技巧,只需要给每个图例标签设定好背景颜色,就可以让它们旁边的网格线变得透明了。

我们只需要把先前代码 plot() 方法中的 legend 参数改为 False 就可以让默认的图例消失不见了。而由于已经存储过了颜色列表,我们也不用再费心了。

# The previous code we modify
fte_graph = women_majors.plot(x = 'Year', y = under_20.index, figsize = (12,8), color = colors, legend = False)

# The previous code that remains unchanged
fte_graph.tick_params(axis = 'both', which = 'major', labelsize = 18)
fte_graph.set_yticklabels(labels = [-10, '0   ', '10   ', '20   ', '30   ', '40   ', '50%'])
fte_graph.axhline(y = 0, color = 'black', linewidth = 1.3, alpha = .7)
fte_graph.xaxis.label.set_visible(False)
fte_graph.set_xlim(left = 1969, right = 2011)
fte_graph.text(x = 1965.8, y = -7,
    s = '   ©DATAQUEST                                                                                 Source: National Center for Education Statistics   ',
    fontsize = 14, color = '#f0f0f0', backgroundcolor = 'grey')
fte_graph.text(x = 1966.65, y = 62.7, s = "The gender gap is transitory - even for extreme cases",
               fontsize = 26, weight = 'bold', alpha = .75)
fte_graph.text(x = 1966.65, y = 57,
               s = 'Percentage of Bachelors conferred to women from 1970 to 2011 in the US for\nextreme cases where the percentage was less than 20% in 1970',
              fontsize = 19, alpha = .85)

# Add colored labels
fte_graph.text(x = 1994, y = 44, s = 'Agriculture', color = colors[0], weight = 'bold', rotation = 33,
              backgroundcolor = '#f0f0f0')
fte_graph.text(x = 1985, y = 42.2, s = 'Architecture', color = colors[1], weight = 'bold', rotation = 18,
              backgroundcolor = '#f0f0f0')
fte_graph.text(x = 2004, y = 51, s = 'Business', color = colors[2], weight = 'bold', rotation = -5,
               backgroundcolor = '#f0f0f0')
fte_graph.text(x = 2001, y = 30, s = 'Computer Science', color = colors[3], weight = 'bold', rotation = -42.5,
              backgroundcolor = '#f0f0f0')
fte_graph.text(x = 1987, y = 11.5, s = 'Engineering', color = colors[4], weight = 'bold',
              backgroundcolor = '#f0f0f0')
fte_graph.text(x = 1976, y = 25, s = 'Physical Sciences', color = colors[5], weight = 'bold', rotation = 27,
              backgroundcolor = '#f0f0f0')

fte09

下一步

这样,我们的图就可以发表了! That’s it, our graph is now ready for publication!

做一个小总结,我们开始的时候使用 matplotlib 的默认样式,然后我们一步步把这张图修改成了 FTE 的官方水平:

  • 使用了 matplotlib 内建的 fivethirtyeight 样式
  • 增加了个性化的标题和副标题
  • 增加了一个标题栏
  • 去掉了默认的图例,增加了曲线旁边的图例
  • 其他的一些小调整:定制了刻度标记,加粗了 y = 0 的水平线,在刻度标记旁边增加了竖直线,去除了 x 轴的标签,同时增加了 y 轴的边距。

你可以把下面这几件事作为学习之后的复习:

  • 使用其他科目学士数据生成一个相似的图表
  • 生成一个其他类型的 FTE 图表:直方图,散点图等等
  • 探索matplotlib gallery,找到一些新的元素来丰富你的 FTE 图表(比如插入图片,增加箭头等等)。插入图片能让你的图表到达一个新的层次:

ftesample03

原作者:Alexandru Olteanu 原文链接:How to Generate FiveThirtyEight Graphs in Python