使用Perl 6进行绘图

(本章最初发表在Moritz Lenz所著的《Perl 6基础:示例、项目和案例研究入门》中,由Apress Media, LLC于2017年出版。经许可转载。)

偶尔我会遇到一些git仓库,我想知道它们的活跃程度以及主要开发者是谁。

让我们开发一个脚本来绘制提交历史,并探索如何在Perl 6中使用Python模块。

提取统计信息

我们想要按作者和日期绘制提交次数。我们可以通过传递一些选项给git log轻松获取这些信息。

my $proc = run :out, <git log --date=short --pretty=format:%ad!%an>;
my (%total, %by-author, %dates);
for $proc.out.lines -> $line {
    my ( $date, $author ) = $line.split: '!', 2;
    %total{$author}++;
    %by-author{$author}{$date}++;
    %dates{$date}++;
}

run执行外部命令,:out告诉它捕获命令的输出,使其作为$proc.out可用。命令是一个列表,第一个元素是实际的可执行文件,其余元素是该可执行文件的命令行参数。

在这里,git log获取选项--date short --pretty=format:%ad!%an,它指示它生成类似2017-03-01!John Doe的行。这行可以通过简单的调用$line.split: '!', 2进行解析,它按!分割,并将结果限制为两个元素。将其分配给两个元素的列表( $date, $author )解包它。然后我们使用散列按作者(在%total中)计数提交,按作者和日期(在%by-author中)计数,最后按日期计数。在第二种情况下,%by-author{$author}甚至不是一个散列,我们仍然可以对其进行散列索引。这要归功于一个名为自动激活的功能,它会在需要的地方自动创建(激活)对象。使用++创建整数,使用{...}索引创建散列,使用[...]索引,使用.push创建数组,依此类推。

要从这些散列中获得按提交次数排名前几位的主要贡献者,我们可以按值对%total进行排序。由于它是按升序排序的,所以按负值排序会按降序返回列表。列表包含Pair对象,我们只需要前五个,以及它们的键。

my @top-authors = %total.sort(-*.value).head(5).map(*.key);

对于每个作者,我们可以像这样提取他们的活动日期和提交次数

my @dates  = %by-author{$author}.keys.sort;
my @counts = %by-author{$author}{@dates};

最后一行使用切片,即用列表索引散列以返回元素列表。

使用Python进行绘图

Matplotlib是一个非常通用的库,适用于各种绘图和可视化任务。它基于NumPy,这是一个用于科学和数值计算的Python库。

Matplotlib是用Python编写的,用于Python程序,但这不会阻止我们在Perl 6程序中使用它。

但是,首先,让我们看一下使用x轴上的日期的基本绘图示例

import datetime
import matplotlib.pyplot as plt

fig, subplots = plt.subplots()
subplots.plot(
    [datetime.date(2017, 1, 5), datetime.date(2017, 3, 5), datetime.date(2017, 5, 5)],
    [ 42, 23, 42 ],
    label='An example',
)
subplots.legend(loc='upper center', shadow=True)
fig.autofmt_xdate()
plt.show()

要使此脚本运行,您必须安装Python 2.7和matplotlib1。您可以在基于Debian的Linux系统上使用apt-get install -y python-matplotlib来执行此操作。在基于RPM的发行版(如CentOS或SUSE Linux)上,软件包名称相同。建议MacOS用户通过homebrew和macports安装Python 2.7,然后使用pip2 install matplotlibpip2.7 install matplotlib来获取库。Windows安装可能通过conda包管理器最容易,它提供了Python和matplotlib的预构建二进制文件。

当您使用python2.7 dates.py运行此脚本时,它将打开一个GUI窗口,显示图表和一些控件,允许您缩放、滚动并将图表图形保存到文件。

Basic matplotlib plotting window

填补差距

Rakudo Perl 6编译器附带了一个方便的用于调用外部函数的库——称为NativeCall——允许您调用用C编写的函数或任何具有兼容二进制接口的函数。

《Inline::Python》库利用本地调用功能与Python的C API进行通信,并提供了Perl 6与Python代码之间的互操作性。在撰写本文时,这种互操作性在某些地方仍然很脆弱,但对于Python提供的许多优秀库来说,使用价值很高。

要安装《Inline::Python》,您必须有可用的C编译器,然后运行

$ zef install Inline::Python

现在您可以在Perl 6程序中运行Python 2代码了

use Inline::Python;

my $py = Inline::Python.new;
$py.run: 'print("Hello, Perl 6")';

除了可以执行字符串形式的Python代码的run方法之外,您还可以使用call方法通过指定命名空间、要调用的例程和参数列表来调用Python例程。

use Inline::Python;

my $py = Inline::Python.new;
$py.run('import datetime');
my $date = $py.call('datetime', 'date', 2017, 1, 31);
$py.call('__builtin__', 'print', $date);    # 2017-01-31

传递给call的参数是Perl 6对象,例如本例中的三个Int对象。《Inline::Python》自动将它们转换为相应的Python内置数据结构。它可以将数字、字符串、数组和哈希转换为Python数据结构。返回值也会在相反方向上转换,但由于Python 2没有正确地区分字节和Unicode字符串,Python字符串在Perl 6中最终成为缓冲区。

《Inline::Python》无法转换的对象在Perl 6侧被视为不可见对象。您可以将它们传递回Python例程(如上例中的print调用所示),并且可以调用它们的方法。

say $date.isoformat().decode;               # 2017-01-31

Perl 6通过方法暴露属性,因此Perl 6没有直接从外部对象访问属性的语法。例如,如果您尝试通过正常的方法调用语法访问datetime.dateyear属性,您将收到错误。

say $date.year;

错误信息为:

'int' object is not callable

相反,您必须使用内置的getattr

say $py.call('__builtin__', 'getattr', $date, 'year');

使用桥接进行绘图

我们需要访问Python中的两个命名空间,即datetimematplotlib.pyplot,因此让我们首先导入它们并编写一些简短的辅助函数。

my $py = Inline::Python.new;
$py.run('import datetime');
$py.run('import matplotlib.pyplot');
sub plot(Str $name, |c) {
    $py.call('matplotlib.pyplot', $name, |c);
}

sub pydate(Str $d) {
    $py.call('datetime', 'date', $d.split('-').map(*.Int));
}

现在我们可以调用pydate('2017-03-01')从ISO格式化的字符串创建一个Python datetime.date对象,并调用plot函数以访问matplotlib的功能。

my ($figure, $subplots) = plot('subplots');
$figure.autofmt_xdate();

my @dates = %dates.keys.sort;
$subplots.plot:
    $[@dates.map(&pydate)],
    $[ %dates{@dates} ],
    label     => 'Total',
    marker    => '.',
    linestyle => '';

Perl 6的plot('subplots')调用对应于Python代码fig, subplots = plt.subplots()。将数组传递给Python函数需要一些额外的工作,因为《Inline::Python》会扁平化数组。在数组前使用额外的$符号将数组放入额外的标量中,从而防止扁平化。

现在我们可以实际绘制作者提交次数的图表,添加图例,并显示结果。

for @top-authors -> $author {
    my @dates = %by-author{$author}.keys.sort;
    my @counts = %by-author{$author}{@dates};
    $subplots.plot:
        $[ @dates.map(&pydate) ],
        $@counts,
        label     => $author,
        marker    =>'.',
        linestyle => '';
}


$subplots.legend(loc=>'upper center', shadow=>True);

plot('title', 'Contributions per day');
plot('show');

当在zef git仓库中运行时,它产生这个图表

Contributions to zef, a Perl 6 module installer

堆叠图表

我对这个图表还不满意,因此我想探索使用堆叠图表来呈现相同信息的方法。在普通图表中,每个绘图值的y坐标与它的值成比例。在堆叠图表中,它与前一个值的距离成比例。这对于总和也是一个有趣的总值的值很有用。

Matplotlib提供了一种名为stackplot的方法来执行此任务。与对subplot对象上的多个plot调用不同,它要求所有数据系列具有共享的x轴。因此,我们必须为每个git提交的作者构建一个数组,其中没有值的日期设置为零。

这次我们必须构建一个数组数组的数组,其中每个内部数组包含一个作者的价值。

my @dates = %dates.keys.sort;
my @stack = $[] xx @top-authors;

for @dates -> $d {
    for @top-authors.kv -> $idx, $author {
        @stack[$idx].push: %by-author{$author}{$d} // 0;
    }
}

现在绘制只是一个方法调用的问题,然后是添加标题和显示图表的常规命令。

$subplots.stackplot($[@dates.map(&pydate)], @stack);
plot('title', 'Contributions per day');
plot('show');

结果是(再次在zef源代码库上运行),如下所示

Stacked plot of zef contributions over time

将此与之前的可视化进行比较,可以发现差异:2014年没有提交,但是堆叠图却呈现出这种效果。事实上,如果我们选择线条而不是点,之前的图表也会显示出相同的“替代事实”。实际上,matplotlib(就像几乎所有绘图库一样)会在数据点之间进行线性插值。但在我们的情况下,没有数据点的日期意味着该日期没有发生任何提交。

为了将此信息传达给matplotlib,我们必须明确插入缺失日期的零值。这可以通过替换以下代码来实现

my @dates = %dates.keys.sort;

使用以下行

my @dates = %dates.keys.minmax;

minmax方法找到最小和最大值,并返回一个范围。将范围分配给数组会将它转换为一个包含最小和最大值之间所有值的数组。组装@stack变量的逻辑已经将缺失的值映射为零。

结果看起来好一些,但仍然远非完美

Stacked plot of zef contributions over time, with missing dates mapped to zero

关于这个问题,我们需要更深入地思考,来自不同日期的贡献不应该合并在一起,因为这会产生误导性的结果。matplotlib不支持自动将图例添加到堆叠图中,这似乎是一个死胡同。

点图效果不佳,让我们尝试一种不同的图表类型来单独表示每个数据点:柱状图,更具体地说,是堆叠柱状图。matplotlib提供了bar绘图方法,其中可以使用的命名参数bottom用于生成堆叠

my @dates = %dates.keys.sort;
my @stack = $[] xx @top-authors;
my @bottom = $[] xx @top-authors;

for @dates -> $d {
    my $bottom = 0;
    for @top-authors.kv -> $idx, $author {
        @bottom[$idx].push: $bottom;
        my $value = %by-author{$author}{$d} // 0;
        @stack[$idx].push: $value;
        $bottom += $value;
    }
}

我们需要自己提供颜色名称,并将柱子的边缘颜色设置为相同的颜色,否则黑色边缘颜色会主导结果

my $width = 1.0;
my @colors = <red green blue yellow black>;
my @plots;

for @top-authors.kv -> $idx, $author {
    @plots.push: plot(
        'bar',
        $[@dates.map(&pydate)],
        @stack[$idx],
        $width,
        bottom => @bottom[$idx],
        color => @colors[$idx],
        edgecolor => @colors[$idx],
    );
}
plot('legend', $@plots, $@top-authors);

plot('title', 'Contributions per day');
plot('show');

这产生了第一个真正具有信息性和非误导性的图表(假设你不是色盲)

Stacked bar plot of zef contributions over time

如果你想要进一步改进结果,可以尝试通过将每周或每月(或可能是$n天)的贡献合并来限制柱子的数量。

内联Python的惯用用法

现在图表看起来既具有信息性又正确,是时候探索如何通过Inline::Python更好地模拟典型的Python API了。

Python API的类型

Python是一种面向对象的编程语言,因此许多API涉及方法调用,而Inline::Python会自动为我们翻译这些方法调用。

但对象必须来自某个地方,通常是通过调用返回对象的函数或通过实例化一个类来实现的。在Python中,这两者实际上在底层是相同的,因为实例化一个类与调用类作为函数是相同的。

以下是一个(Python中的)示例

from matplotlib.pyplot import subplots
result = subplots()

但matplotlib文档倾向于使用另一种等效的语法

import matplotlib.pyplot as plt
result = plt.subplots()

这使用subplots符号(类或函数)作为模块matplotlib.pyplot上的方法,该模块通过导入语句别名为plt。这是相同API的面向对象语法。

映射函数API

前面的代码示例使用了以下Perl 6代码来调用subplots符号

my $py = Inline::Python.new;
$py.run('import matplotlib.pyplot');
sub plot(Str $name, |c) {
    $py.call('matplotlib.pyplot', $name, |c);
}

my ($figure, $subplots) = plot('subplots');

如果我们想调用subplots()而不是plot('subplots'),以及bar(args)而不是plot('bar', args),我们可以使用一个函数来生成包装函数

my $py = Inline::Python.new;

sub gen(Str $namespace, *@names) {
    $py.run("import $namespace");

    return @names.map: -> $name {
        sub (|args) {
            $py.call($namespace, $name, |args);
        }
    }
}

my (&subplots, &bar, &legend, &title, &show)
    = gen('matplotlib.pyplot', <subplots bar legend title show>);

my ($figure, $subplots) = subplots();

# more code here

legend($@plots, $@top-authors);
title('Contributions per day');
show();

这使得函数的使用非常方便,但代价是重复它们的名称。可以将其视为一个特性,因为它允许创建不同的别名,或者当顺序搞错或拼写错误时,可能导致错误。

如果我们选择创建包装函数,我们如何避免重复呢?

这正是Perl 6的灵活性和内省能力发挥作用的时刻。有两个关键组件允许更优雅的解决方案:声明是表达式的特性,以及变量名称可以内省。

第一部分意味着你可以写 mysub my ($a, $b),这会声明变量 $a$b,并以这些变量作为参数调用一个函数。第二部分意味着 $a.VAR.name 返回一个字符串 '$a',即变量的名称。

让我们将它们结合起来创建一个包装器,用于初始化为我们提供的子例程

sub pysub(Str $namespace, |args) {
    $py.run("import $namespace");

    for args[0] <-> $sub {
        my $name = $sub.VAR.name.substr(1);
        $sub = sub (|args) {
            $py.call($namespace, $name, |args);
        }
    }
}

pysub 'matplotlib.pyplot',
    my (&subplots, &bar, &legend, &title, &show);

这避免了重复名称,但迫使我们必须在 pysub 子例程中使用一些较低级别的 Perl 6 功能。使用普通变量意味着访问它们的 .VAR.name 结果是变量的名称,而不是调用方使用的变量名称。因此,我们不能像下面这样使用可变参数

sub pysub(Str $namespace, *@subs)

相反,我们必须使用 |args 来获取传递给函数的其余参数,这是一个 Capture。这不会展开传递给函数的变量列表,因此当我们迭代它们时,我们必须通过访问 args[0] 来完成。默认情况下,循环变量是只读的,我们可以通过使用 <-> 而不是 -> 来引入签名来避免这一点。幸运的是,这也保留了调用方变量的名称。

面向对象的接口

我们不仅可以公开函数,还可以创建模拟 Python 模块方法调用的类型。为此,我们可以实现一个带有 FALLBACK 方法的类,当调用类中未实现的方法时,Perl 6 会为我们调用该方法

class PyPlot is Mu {
    has $.py;
    submethod TWEAK {
        $!py.run('import matplotlib.pyplot');
    }
    method FALLBACK($name, |args) {
        $!py.call('matplotlib.pyplot', $name, |args);
    }
}

my $pyplot = PyPlot.new(:$py);
my ($figure, $subplots) = $pyplot.subplots;
# plotting code goes here
$pyplot.legend($@plots, $@top-authors);

$pyplot.title('Contributions per day');
$pyplot.show;

PyPlot 直接从 Mu 继承,它是 Perl 6 类型层次结构的根,而不是从默认的父类 Any 继承(它反过来又从 Mu 继承)。Any 为 Perl 6 对象引入了大量默认方法,而 FALLBACK 只在方法不存在时被调用,因此这是要避免的。

TWEAK 是另一个 Perl 6 自动为我们调用的方法,在对象完全实例化之后。大写字母命名的方法名称被保留用于此类特殊目的。它被标记为 submethod,这意味着它不会继承到子类中。由于 TWEAK 在每个类的级别上被调用,如果它是一个常规方法,子类会隐式地调用它两次。请注意,TWEAK 仅在 Rakudo 版本 2016.11 及以后版本中得到支持。

PyPlot 中没有特定于 Python 包 matplotlib.pyplot 的内容,除了命名空间名称。我们可以轻松地将它推广到任何命名空间

class PythonModule is Mu {
    has $.py;
    has $.namespace;
    submethod TWEAK {
        $!py.run("import $!namespace");
    }
    method FALLBACK($name, |args) {
        $!py.call($!namespace, $name, |args);
    }
}

my $pyplot = PythonModule.new(:$py, :namespace<matplotlib.pyplot>);

这是可以表示任何 Python 模块的 Perl 6 类型之一。如果我们想为每个 Python 模块创建一个单独的 Perl 6 类型,我们可以使用角色,这些角色是可选参数化的

role PythonModule[Str $namespace] is Mu {
    has $.py;
    submethod TWEAK {
        $!py.run("import $namespace");
    }
    method FALLBACK($name, |args) {
        $!py.call($namespace, $name, |args);
    }
}

my $pyplot = PythonModule['matplotlib.pyplot'].new(:$py);

使用这种方法,我们可以在 Perl 6 空间中创建 Python 模块的类型约束

sub plot-histogram(PythonModule['matplotlib.pyplot'], @data) {
    # implementation here
}

传递除 matplotlib.pyplot 之外的其他任何包装 Python 模块会导致类型错误。

总结

我们探讨了在图中表示提交发生的方法,并使用 Inline::Python 与基于 Python 的绘图库进行接口

一些 Perl 6 元编程允许我们非常直接地在 Perl 6 代码中模拟不同类型的 Python API,允许我们将原始库的文档直接转换为 Perl 6 代码。


1:必须使用 Python 2.7 的原因是,在撰写本文时,Inline::Python 还不支持 Python 3。


本文最初发表在 PerlTricks.com 上。

标签

Moritz Lenz

Moritz Lenz 是一名软件开发人员、架构师、问题解决者和作家。他是 Perl 6 项目的核心贡献者,并且已经与该语言打发了十年。

浏览他们的文章

反馈

这篇文章有什么问题吗?请通过在 GitHub 上打开一个问题或拉取请求来帮助我们。