我最近偶然发现了 Brian Granger 和 Jake VanderPlas 开发的 Altair,一个非常有潜力的新可视化库。Altair 似乎非常适合用来表达 Python 对 ggplot 的羡慕,而它采用了 JavaScript 的 Vega-Lite 语法,这意味着后者开发的新功能(比如提示框和缩放)都能被 Altair 所用,而且看样子是免费的!
不过我随后开始反思自以为更 Pythonic 的可视化习惯,在这相当痛苦的自我反思中,我发现自己错得一塌糊涂:为了应对手头的工作我用了一大堆工具还有乱七八糟的技术,通常是随便选一个第一个能完成工作的库
1
。
ggplot 是出色的声明式 ggplot2 的 Python 实现。它不仅仅「逐一复刻」了 ggplot2 的特性,还有一些共有的强大特性。(对业余的 R 语言用户而言,重要的组件似乎应有尽有。)
(在第一场,我们要处理的是整洁数据集
ts
。它有三列:
dt
(存日期),
value
(存值)和
kind
(有四个不同的
水平
:A,B,C 和 D)。数据长这个样子:)
matplotlib:
哈!哈哈!不能再简单了。虽然我可以用很多复杂的方式搞定这个,不过我明白你们的笨脑子是无法理解其中的精妙的。所以我退而求其次给你们展示两个简单的方法。第一个方法,我循环使用你们虚构的矩阵,我相信你们这些人把它叫做「数据框」,取其子集传给相关的时间序列。然后调用
plot
方法,传入子集中的相关列。
fig, ax = plt.subplots(1, 1,
figsize=(7.5, 5))
for k in ts.kind.unique():
tmp = ts[ts.kind == k]
ax.plot(tmp.dt, tmp.value, label=k)
ax.set(xlabel='Date',
ylabel='Value',
title='Random Timeseries')
ax.legend(loc=2)
fig.autofmt_xdate()复制代码
MPL
: 然后我把它转换成数组(给 pandas 做手势),让他对「数据框」做轴向旋转(pivot),结果是这样的:
dfp = ts.pivot(index='dt', columns='kind', values='value')
dfp.head()复制代码
pandas:
结果看上去完全一样,所以我就不展示了。
Seaborn(抽着烟,调整着贝雷帽):
唔。看上去区区一个折线图就让你们做了这么多数据处理。我是说,for 循环和轴向旋转?这不是九十年代的微软 Excel(译者注:pivot table 即 Excel 的数据透视表)。我在国外学到一个叫做 FacetGrid 的东西。你们大概从来没有听说过……
g = sns.FacetGrid(ts, hue='kind', size=5, aspect=1.5)
g.map(plt.plot, 'dt', 'value').add_legend()
g.ax.set(xlabel='Date',
ylabel='Value',
title='Random Timeseries')
g.fig.autofmt_xdate()复制代码
SB:
看懂了吗?直接给 FacetGrid 传入未处理的整洁数据。在这里,将
kind
赋给
hue
参数的意思是绘出四条不同的线 —— 每条线对应
kind
的一个水平。而真正画出这四条线,得把 FacetGrid 映射到到庸俗的(
示意 matplotlib
) plot 函数,再传入
x
和
y
参数。显然,这些东西得牢记,就像添加图例一样,但是也不会太难。好吧,对有些人来说没有什么东西有挑战性……
ggplot:
哇,赞!我的方法和她差不多,但是我做起来更像我的大哥。你们听过他吗?他超级酷 ——
SB:
谁邀请了这个孩子?
GG:
快来看看!
# GGPLOT
fig, ax = plt.subplots(1, 1, figsize=(7.5, 5))
g = ggplot(ts, aes(x='dt', y='value', color='kind')) + \
geom_line(size=2.0) + \
xlab('Date') + \
ylab('Value') + \
ggtitle('Random Timeseries')
g复制代码
GG (拿起 Hadley Wickham 写的 《ggplot2》读出声来):
每一幅图都由数据(比如
ds
),图形映射(比如
x
,
y
和
color
)和几何图形(比如
geom_line
)组成,而后者将数据和图形映射转换成真正的可视化。
Altair:
没错,我也是这么做的。
c = Chart(ts).mark_line().encode(
x='dt',
y='value',
color='kind'
c复制代码
ALT:
给我的 Chart 类同样的数据,告诉它你要哪种可视化:这里就是
mark_line
。然后指定想要的图形映射:x 轴是
data
,y 轴是
value
;因为我们想要按
kind
分组,所以把
kind
传给
color
。就跟你一样,GG(
拨乱 GG 的头发
)。哦,这样一来,要用你们都用的配色方案也轻而易举了:
c = Chart(ts).mark_line().encode(
x='dt',
y='value',
color=Color('kind', scale=Scale(range=cp.as_hex()))
c复制代码
MPL 害怕又惊讶地盯着
MPL(看上去有点震惊):
我是说,你可以继续用 for 循环,当然了。这样也没什么问题。当然。懂了吗?(
压低声音小声说
)只要记得显式地设定好颜色变量,不然所有的点都是蓝的……
fig, ax = plt.subplots(1, 1, figsize=(7.5, 7.5))
for i, s in enumerate(df.species.unique()):
tmp = df[df.species == s]
ax.scatter(tmp.petalLength, tmp.petalWidth,
label=s, color=cp[i])
ax.set(xlabel='Petal Length',
ylabel='Petal Width',
title='Petal Width v. Length -- by Species')
ax.legend(loc=2)复制代码
MPL:
可是,呃,(
假装充满自信
)我有个更好的主意!看这个:
fig, ax = plt.subplots(1, 1, figsize=(7.5, 7.5))
def scatter(group):
plt.plot(group['petalLength'],
group['petalWidth'],
'o', label=group.name)
df.groupby('species').apply(scatter)
ax.set(xlabel='Petal Length',
ylabel='Petal Width',
title='Petal Width v. Length -- by Species')
ax.legend(loc=2)复制代码
MPL:
我在这定义了
scatter
函数。它用 pandas 的 groupby 对象得到分组,然后在 x 轴上画出花瓣长度,y 轴则是花瓣宽度。每组都如此处理一番!厉害吧!
P:
真不错,Mat!真不错!基本上和我的方法差不多,所以我就坐这里不展示了。
SB (咧嘴笑):
这次怎么没用轴向旋转?
P:
嗯,这个例子里要用轴向旋转的话比较复杂。因为不像处理时序数据一样有一个通用的索引,所以……
MPL:
嘘!我们没必要跟她解释。
SB:
随便你了。不管怎样,在我看来这个问题和上一个没有什么区别。还是构建一个 FacetGrid,只是这次将
plt.plot
换成
plt.scatter
。
g = sns.FacetGrid(df, hue='species', size=7.5)
g.map(plt.scatter, 'petalLength', 'petalWidth').add_legend()
g.ax.set_title('Petal Width v. Length -- by Species')复制代码
GG:
对!对!就是这样!我的写法就是把
geom_line
换成
geom_point
!
g = ggplot(df, aes(x='petalLength',
y='petalWidth',
color='species')) + \
geom_point(size=40.0
) + \
ggtitle('Petal Width v. Length -- by Species')
g复制代码
ALT (一脸茫然):
是的,只要把
mark_line
换成
mark_point
。
c = Chart(df).mark_point(filled=True).encode(
x='petalLength',
y='petalWidth',
color='species'
c复制代码
MPL:
那么,嗯,一旦你掌握了 for 循环 —— 显然我就掌握了 —— 只需要简单调整一下之前的代码就行了。我用
subplot
方法画了三个轴,而不是一个。接下来就跟以前一样遍历一遍,用类似取数据子集的方法来取相关的 Axes 对象的子集。
(
重拾自信)我敢打赌你们各位没有更简单的方法!(举起双臂,差点打到了 pandas)
fig, ax = plt.subplots(1, 3, figsize=(15, 5))
for i, s in enumerate(df.species.unique()):
tmp = df[df.species == s]
ax[i].scatter(tmp.petalLength, tmp.petalWidth, c=cp[i])
ax[i].set(xlabel='Petal Length',
ylabel='Petal Width',
title=s)
fig.tight_layout()复制代码
SB 和笑起来的 ALT 交换了目光;GG 仿佛听到笑话了笑了起来
MPL:
怎么啦?!
Altair:
老兄,看看你的 x 轴和 y 轴。所有图像的坐标轴范围都不一样。
MPL (脸红了):
呃,是,当然啊。我就是想看看你们有没有注意听我说话。你当然可以在
subplot
函数中指定坐标轴范围,保证所有的子图坐标轴范围是统一的。
fig, ax = plt.subplots(1, 3, figsize=(15, 5),
sharex=True, sharey=True)
for i, s in enumerate(df.species.unique()):
tmp = df[df.species == s]
ax[i].scatter(tmp.petalLength,
tmp.petalWidth,
c=cp[i])
ax[i].set(xlabel='Petal Length',
ylabel='Petal Width',
title=s)
fig.tight_layout()复制代码
P(叹气):
我也是这么做的。跳过我吧。
SB:
改写 FacetGrid 然后用在这个例子上很简单。就像使用
hue
变量一样,我们可以简单加一个
col
变量(比如 colum)。这会告诉 FacetGrid 不仅给每个种类一个唯一的颜色,还把每个种类都画在唯一的子图上,按列排列。(只要将
col
变量换成
row
就可以按行排列。)
g = sns.FacetGrid(df, col='species', hue='species', size=5)
g.map(plt.scatter, 'petalLength', 'petalWidth')复制代码
GG:
哦,这和我的做法不同(
再一次拿起《ggplot2》开始读
)。看,分面和图形映射本质上是两个不同的步骤,我们不应该一时疏忽把它们混为一谈。因此,我们接着用之前的代码这次加上
facet_grid
层,也就是显式地用类别进行分面。(
开心地合上书
)至少我大哥是这么说的!你们听到他了吗?在书里。他真酷啊
4
。
g = ggplot(df, aes(x='petalLength',
y='petalWidth',
color='species')) + \
facet_grid(y='species') + \
geom_point(size=40.0)
g复制代码
ALT
:
我这里采用更具 Seaborn 风格的方法。具体地说,我给编码函数加了一个
column
参数。也就是说我也做了一些新工作:第一,虽然
column
参数可以接受一个简单的字符串变量,实际上我传给它的是 Column 对象,如此我可以自定义标题了。第二,我用了自定义的
configure_cell
方法,如果不用的话子图会变得特别巨大。
c = Chart(df).mark_point().encode(
x='petalLength',
y='petalWidth',
color='species',
column=Column('species',
title='Petal Width v. Length by Species')
c.configure_cell(height=300, width=300)复制代码
matplotlib 说得很清楚:这个例子中,他的代码根据分类对数据进行分面的思路和上面的其他方案是一样的;假如你的脑袋可以搞清楚那些 for 循环的话,你可以再试试下面这段代码。但是我可没有让他再搞出更复杂的东西出来,比如 2 x 3 的网格。不然他就得像下面这样干:
fig, ax = plt.subplots(2, 3, figsize=(15, 10), sharex=True, sharey=True)
for i, s in enumerate(df.species.unique()):
for j, r in enumerate(df.random_factor.sort_values().unique()):
tmp = df[(df.species == s) & (df.random_factor == r)]
ax[j][i].scatter(tmp.petalLength,
tmp.petalWidth,
c=cp[i+j])
ax[j][i].set(xlabel='Petal Length',
ylabel='Petal Width',
title=s + '--' + r)
fig.tight_layout()复制代码
color
=
'species'
,
column
=Column(
'species'
,
title
=
'Petal Width v. Length by Species'
),
row
=
'random_factor'
c.configure_cell(
height
=
200
, width=
200
)
复制代码
MPL (信心明显不足了):
好吧,如果我们要画箱线图——我们真的要箱线图吗?——我知道怎么画。不过非常愚蠢;你肯定不会喜欢。不过我给
boxplot
方法传入一个数组组成的数组,每个数组就都会得到一个箱线图。你可能需要手动标注 X 轴的刻度。
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
ax.boxplot([df[df.species == s]['petalWidth'].values
for s in df.species.unique()])
ax.set(xticklabels=df.species.unique(),
xlabel='Species',
ylabel='Petal Width',
title='Distribution of Petal Width by Species')复制代码
MPL:
如果要画柱状图 —— 我们真的要画柱状图吗? —— 我也有个方法可以用,你可以用之前提到的 for 循环或者
group by
。
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
for i, s in enumerate(df.species.unique()):
tmp = df[df.species == s]
ax.hist(tmp.petalWidth, label=s, alpha=.8)
ax.set(xlabel='Petal Width',
ylabel='Frequency',
title='Distribution of Petal Width by Species')
ax.legend(loc=1)复制代码
P (看上去不同寻常的骄傲):
哈!哈哈哈哈!该我大显身手了!你们都觉得我一无是处,只是
matplotlib
的替罪羊。虽然我目前都只是套用他的
plot
方法,但我也拥有一些特殊的函数可以处理箱线图
和
柱状图。用他们来可视化分布简直就是小菜一碟!你只需要提供两个列名:第一,用来分组的列名;第二,待分布统计的列名。分别把它们传给
by
和
column
参数,图马上就画好了!
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
df.boxplot(column='petalWidth', by='species', ax=ax)复制代码
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
df.hist(column='petalWidth', by='species', grid=None, ax=ax)复制代码
GG和ALT举手击掌然后祝贺P;高呼「棒极了!」,「就该这样!」,「就这么干!」
SB (假装很热情):
喔喔喔。很赞。同时呢,分布对我非常重要,所以我为它准备了一些特殊方法。比如,我的
boxplot
方法只需要 x 变量、y 变量和数据就可以得到这个:
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
g = sns.boxplot('species', 'petalWidth', data=df, ax=ax)
g.set(title='Distribution of Petal Width by Species')复制代码
SB:
这个图不错吧,我是说有人这么说过…… 不管了。我还有个特殊的分布方法叫
distplot
远不止条形图那么简单(傲慢的看了眼 pandas)。你可以用来画条形图,KDEs 和轴须图(rugplots) —— 甚至画在一起。比如把
displot
和 FacedGrid 结合起来,我就可以为每一种鸢尾花都画出直方轴须图:
g = sns.FacetGrid(df, hue='species', size=7.5)
g.map(sns.distplot, 'petalWidth', bins=10,
kde=False, rug=True).add_legend()
g.set(xlabel='Petal Width',
ylabel='Frequency',
title='Distribution of Petal Width by Species')复制代码
SB:
不过…… 管他呢。
GG:
这些只不过是新的几何对象!
GEOM_BOXPLOT
来画箱线图,
GEOM_HISTOGRAM
来画直方图!换用它俩就行了!(
绕着餐桌跑了起来
)
g = ggplot(df, aes(x='species',
y='petalWidth',
fill='species')) + \
geom_boxplot() + \
ggtitle('Distribution of Petal Width by Species')
g复制代码
# GGPLOT
g = ggplot(df, aes(x='petalWidth',
fill='species')) + \
geom_histogram() + \
ylab('Frequency') + \
ggtitle('Distribution of Petal Width by Species')
g复制代码
ALT (看上去坚定又自信):
我要忏悔……
四周安静了下来 —— GG停了下来,把盘子撞到了地上。
ALT:(沉重地喘气)
我……我……我不会画箱线图。从来没学过怎么画,不过我相信我的源语言 JavaScript 的语法不支持箱线图肯定是有原因的。不过我会画直方图……
c = Chart(df).mark_bar(opacity=.75).encode(
x=X('petalWidth', bin=Bin(maxbins=30)),
y='count(*)',
color=Color('species', scale=Scale(range=cp.as_hex()))
c复制代码
ALT:
乍一看代码会觉得有点怪,但是不要担心。这里实际是在说:「嘿,直方图事实上就是条形图」。X 轴对应着
bin
,我们可以用
Bin
类来定义;同时 y 轴对应到数据集里落到对应 Bin 的数据的数量。用 SQL 语言来说 y 就是
count(*)
。
在工作中,我的确发现 pandas 的便利函数很方便,但是我得承认,脑子里总要惦记着 pandas 给箱线图和直方图提供了
by
参数,却没有给折线图提供该参数。
我把第一场和第二场分开是有原因的,其中最重要的原因是:从第二场开始 matplotlib 变得比较吓人。比如说要画箱线图还得记着用一个完全独立的界面,这根本不适合我。
说起第一场和第二场,有一个有趣的小细节:其实我一开始是因为 Seaborn 有丰富的「专利级」可视化函数(比如,distplot,小提琴图,回归图等等)而从 matplotlib/pandas 转移阵营的。但我后来喜欢上了 FacetGrid,我必须说这些第二场中的函数是 Seaborn 的杀手级应用。只要我还在画图我就离不开它们。
(此外,我需要说明:Seaborn 提供了许多被小型库所忽略的优秀可视化函数;如果你碰巧需要一二,那么 Seaborn 是你唯一的选择。)
这些例子真的可以让你领会到 ggplot 图形对象系统的力量。用基本相同的代码(更重要的是连思路都基本相同),就可以画出截然不同的图来。还不用调用不同的函数,只是改变图形映射呈现给视图的方式就行了,比如换一下几何对象。
类似的,哪怕在第二场,Altair 的 API 也有非同寻常的一致性。哪怕对于那些看上去非常另类的操作,Altair 的 API 也非常简单、优雅,令人印象深刻。
(在最后一幕,我们会处理「泰坦尼克」,另一个著名的整洁数据集(代码中仍然用
df
来表示)。下面是预览……)
survived
pclass
class
7.2500
female
71.2833
female
7.9250
female
53.1000
8.0500
这个例子中,我们感兴趣的是看看每个客舱等级的平均费用是否和逃生率相关。显然,在 pandas 中我们可以这样写:
dfg = df.groupby(['survived', 'pclass']).agg({'fare': 'mean'})
dfg复制代码
fig,
ax
= plt.subplots(
1
,
1
, figsize=(
12.5
,
7
))
N
=
3
ind
= np.arange(N)
width
=
0.35
rects1
= ax.bar(ind, died.fare, width, color=
'r'
)
rects2
= ax.bar(ind + width, survived.fare, width, color=
'y'
)
ax.set_ylabel('Fare')
ax.set_title('Fare by survival and class')
ax.set_xticks(ind + width)
ax.set_xticklabels(('First', 'Second', 'Third'))
ax.legend((rects1
[0]
, rects2
[0]
), ('Died', 'Survived'))
def autolabel(rects):
for rect in rects:
height
= rect.get_height()
ax.text(rect.get_x() + rect.get_width()/2., 1.05*height,
'%d' % int(height),
ha
=
'center'
, va=
'bottom'
)
ax.set_ylim(0, 110)
autolabel(rects1)
autolabel(rects2)
plt.show()
复制代码
其他人都开始摇头
P:
我得先对数据进行一些处理 —— 也就是
group by
和
pivot
—— 处理完就可以用非常帅气的条形图方法了,比上面这些简单得多!哇,我现在自信多了,我把其他人都比下去了!
5
fig, ax = plt.subplots(1, 1, figsize=(12.5, 7))
dfg.reset_index().\
pivot(index='pclass',
columns='survived',
values='fare').plot.bar(ax=ax)
ax.set(xlabel='Class',
ylabel='Fare',
title='Fare by survival and class')复制代码
SB:
我恰好又认为这类工作非常重要。鉴于此,我使用了特殊的
factorplot
函数来帮助我:
g = sns.factorplot(x='class', y='fare', hue='survived',
data=df, kind='bar',
order=['First', 'Second', 'Third'],
size=7.5, aspect=1.5)
g.ax.set_title('Fare by survival and class')复制代码
SB:
跟之前一样,先将未处理过的数据传给数据框,再搞明白自己要按照什么进行分组,这里就是
class
和
survived
,它们对应
x
和
hue
变量。然后搞明白要对哪个数据列进行摘要统计,这里就是
fare
,对应到
y
变量。默认的摘要统计方法是求平均数,不过
factorplot
提供了
estimator
参数,可以通过它指定想要的函数,比如求和,标准差,中位数等等。而选择的函数会决定每个柱的高度。
当然,有很多方法可以可视化这个信息,条形图只有一种。同样我还提供了
kind
参数用来指定不同的可视化方法。
最后,
还有人
比较在意统计确定性,所以我会默认给你加上误差线,这样可以看出不同等级舱位的平均费用和生存率是否有关系。
(
压低声音说
)希望你们做得比我还好
ggplot2 停下兰博基尼,走了进来
ggplo2:
嘿,你们看到 ——
GG:
嘿,大哥。
GG2:
嘿,小家伙。我们得走了。
GG:
等一下,我得马上把这个条形图画好,不过遇到麻烦了。你会怎么做呢?
GG2 (阅读手册)
_: 哦,就像这样:
ggplot(df, aes(x=factor(survived), y=fare)) +
stat_summary_bin(aes(fill=factor(survived)),
fun.y=mean) +
facet_wrap(~class)
复制代码
GG2:
看懂了吗?你要像我之前说的一样定义好图形映射,不过得把
y
映射到平均费用上。这就得叫我的好兄弟
stat_summary_bin
帮忙了,我只要把
mean
传给
fun.y
参数就行了。
GG (惊讶地睁大眼睛):
哦,呃…… 我发现我还没有
stat_summary_bin
呢。我想想 —— pandas 你能帮帮我吗?
P:
呃,当然可以。
GG:
好诶!
g = ggplot(df.groupby(['class', 'survived']).\
agg({'fare': 'mean'}).\
reset_index(), aes(x='class',
fill='factor(survived)',
weight='fare',
y='fare')) + \
geom_bar() + \
ylab('Avg. Fare') + \
xlab('Class') + \
ggtitle('Fare by survival and class')
g复制代码
GG2:
噢,不完全是图形式语法,不过我觉得只要 Hadley 还没有发现,这样也能用…… 特别是你不应该在可视化之前就对数据进行汇总。我也不是特别懂这个上下文中
weight
是什么意思……
GG:
是这样,我的条形图形对象默认会使用简单计数,所以如果没有
weight
的话所有柱子的高度都是
1
。
GG2:
噢,我懂了…… 我们以后再讨论吧。
GG 和 GG2 道别并离开了晚宴
ALT:
噢,现在
这
可是我的安身立命之道。非常简单。
c = Chart(df).mark_bar().encode(
x='survived:N',
y='mean(fare)',
color='survived:N',
column='class')
c.configure_facet_cell(strokeWidth=0, height=250)复制代码
ALT:
我希望下面的解释可以让所有的变量都非常直观:我想按幸存数来画平均船费,按舱位等级进行分面。写在代码里就是
survived
是 x 变量,
mean(fare)
是 y 变量,而
class
是 column 变量。(我还指定了 color 变量这样画面可以热闹点。)
但是,这里也有一些新东西值得注意。注意,我在 x 和 color 中的
survivde
字符串后面加了
:N
。这是我给自己加的注释,意思就是「这是个名义上的变量。」我需要加这个注释是因为
survived
看上去像个定量变量,而定量变量有可能让绘的图变得有点丑。也不要太担心啦,这问题也不是每次都能碰到,只有个别情况下会有影响。比如,在上面的时间序列图中,如果我不知道
dt
是时间变量,我可能会假设它们只是名义变量,这样子就尴尬了(还好我在后面加上了
:T
,这样就好了)。
另外我还用了
configure_facet_cell
协议让三个子图看上去更加统一。
接着说:如果你想要一些更偏向统计的东西,用 Seaborn 吧(她的确在国外学到了很多很酷的东西)。学习她的 API —— factorplot, regplot, displot 等等等等 —— 然后爱上她。这时间花得值。至于 faceting,我觉得 FacetGrid 是个很有用的共犯(wtf!);但是要不是我使用 Seaborn 已久,我可能更喜欢 ggplot 或 Altair。
说到声明式的优雅,我一直深爱着 ggplot2 ,而且对 Python 的 ggplot 留下了深刻印象。我肯定会持续关注这个项目。(更自私地说,我希望它可以阻止那些使用 R 语言同事取笑我。)
最后,如果你要做的事可以用 Altair 完成(抱歉了,箱线图使用者),用它吧!它提供的 API 异常简单又非常好用。如果还需要其他动力,想想这些:Altair 一个令人激动的特性是(除了即将到来的针对其底层 Vega-Lite 语法的改进之外),从技术的角度来说,它并不是可视化库。它输出符合 Vega-Lite 标准的 JSON 对象,可以用 IPython Vega 渲染得非常好。
的确,看上去没什么好激动的,但是想想它的影响:如果其他的库对此感兴趣,他们可以直接开发新方法将这些 Vega-Lite JSON 对象转换成可视化结果。这就意味着可以用 Altair 搞定基本工作,然后深入底层用 matplotlib 获得更多控制。
说完这一切,再说几句告别的话:Python 可视化可比一个男人,女人或者尼斯湖水怪大多了。所以你得有选择地接受我刚才说的一切,不论是代码还是意见。记得:互联网上的一切都是谎言,该死的谎言和统计。