命令行上的数据科学第二版:八、并行管道

原文:https://datascienceatthecommandline.com/2e/chapter-7-exploring-data.html

在所有这些艰苦的工作之后(除非你已经有了干净的数据),是时候享受一些乐趣了。现在您已经获得并清理了数据,您可以继续进行 OSEMN 模型的第三步,即探索数据。

探索是你熟悉数据的步骤。当您想要从中提取任何价值时,熟悉数据是必不可少的。例如,知道数据具有哪种特征,意味着您知道哪些特征值得进一步探索,哪些特征可以用来回答您的任何问题。

可以从三个角度探索您的数据。第一个视角是检查数据及其属性。在这里,您希望了解诸如原始数据的样子、数据集有多少个数据点以及数据集有哪些特征之类的信息。

第二是计算描述性统计。这种视角有助于了解更多关于单个特性的信息。输出通常是简短的文本,因此可以在命令行上打印。

第三个视角是创建数据的可视化。从这个角度,您可以深入了解多个功能是如何交互的。我将讨论一种创建可以在命令行上打印的可视化效果的方法。然而,可视化最适合在图形用户界面上显示。数据可视化优于描述性统计的一个优点是,它们更灵活,可以传达更多的信息。

7.1 概述

在本章中,您将学习如何:

  • 检查数据及其属性
  • 计算描述性统计量
  • 在命令行内外创建数据可视化

本章从以下文件开始:

$ cd /data/ch07

$ l
total 104K
-rw-r--r-- 1 dst dst  125 Mar  3 10:46 datatypes.csv
-rw-r--r-- 1 dst dst 7.8K Mar  3 10:46 tips.csv
-rw-r--r-- 1 dst dst  83K Mar  3 10:46 venture.csv
-rw-r--r-- 1 dst dst 4.6K Mar  3 10:46 venture-wide.csv

获取这些文件的说明在第二章中。任何其他文件都是使用命令行工具下载或生成的。

7.2 检查数据及其属性

在本节中,我将演示如何检查数据集及其属性。因为即将到来的可视化和建模技术期望数据是矩形的,所以我假设数据是 CSV 格式的。如有必要,您可以使用第五章中描述的技术将您的数据转换成 CSV 格式。

为了简单起见,我还假设您的数据有一个头。在第一小节中,我将展示一种方法来确定是否是这样。一旦你知道你有一个标题,你可以继续回答下列问题:

  • 数据集有多少个数据点和特征?
  • 原始数据是什么样的?
  • 数据集有什么样的特征?
  • 这些特征中的一些可以被视为绝对的吗?

7.2.1 不管有没有head,我来了

您可以通过使用head打印前几行来检查您的文件是否有标题:

$ head -n 5 venture.csv
FREQ,TIME_FORMAT,TIME_PERIOD,EXPEND,UNIT,GEO,OBS_STATUS,OBS_VALUE,FREQ_DESC,TIME
_FORMAT_DESC,TIME_PERIOD_DESC,OBS_STATUS_DESC,EXPEND_DESC,UNIT_DESC,GEO_DESC
A,P1Y,2015,INV_VEN,PC_GDP,CZ,,0.002,Annual,Annual,Year 2015,No data,"Venture cap
ital investment (seed, start-up and later stage) ",Percentage of GDP,Czechia
A,P1Y,2007,INV_VEN,PC_GDP,DE,,0.034,Annual,Annual,Year 2007,No data,"Venture cap
ital investment (seed, start-up and later stage) ",Percentage of GDP,Germany
A,P1Y,2008,INV_VEN,PC_GDP,DE,,0.039,Annual,Annual,Year 2008,No data,"Venture cap
ital investment (seed, start-up and later stage) ",Percentage of GDP,Germany
A,P1Y,2009,INV_VEN,PC_GDP,DE,,0.029,Annual,Annual,Year 2009,No data,"Venture cap
ital investment (seed, start-up and later stage) ",Percentage of GDP,Germany

如果这些行换行,使用nl添加行号:

$ head -n 3 venture.csv | nl
     1  FREQ,TIME_FORMAT,TIME_PERIOD,EXPEND,UNIT,GEO,OBS_STATUS,OBS_VALUE,FREQ_D
ESC,TIME_FORMAT_DESC,TIME_PERIOD_DESC,OBS_STATUS_DESC,EXPEND_DESC,UNIT_DESC,GEO_
DESC
     2  A,P1Y,2015,INV_VEN,PC_GDP,CZ,,0.002,Annual,Annual,Year 2015,No data,"Ven
ture capital investment (seed, start-up and later stage) ",Percentage of GDP,Cze
chia
     3  A,P1Y,2007,INV_VEN,PC_GDP,DE,,0.034,Annual,Annual,Year 2007,No data,"Ven
ture capital investment (seed, start-up and later stage) ",Percentage of GDP,Ger
many

或者,您可以使用trim:

$ < venture.csv trim 5
FREQ,TIME_FORMAT,TIME_PERIOD,EXPEND,UNIT,GEO,OBS_STATUS,OBS_VALUE,FREQ_DESC,TIM…
A,P1Y,2015,INV_VEN,PC_GDP,CZ,,0.002,Annual,Annual,Year 2015,No data,"Venture ca…
A,P1Y,2007,INV_VEN,PC_GDP,DE,,0.034,Annual,Annual,Year 2007,No data,"Venture ca…
A,P1Y,2008,INV_VEN,PC_GDP,DE,,0.039,Annual,Annual,Year 2008,No data,"Venture ca…
A,P1Y,2009,INV_VEN,PC_GDP,DE,,0.029,Annual,Annual,Year 2009,No data,"Venture ca…
… with 536 more lines

在这种情况下,很明显,第一行是一个标题,因为它只包含大写的名称,后面的行包含数字。这确实是一个相当主观的过程,由您决定第一行是标题还是已经是第一个数据点。当数据集不包含标题时,你最好使用header工具(在第五章中讨论)来纠正它。

7.2.2 检查所有数据

如果你想按照自己的节奏检查原始数据,那么使用cat可能不是一个好主意,因为那样所有的数据都会被一次性打印出来。我推荐使用less,它允许您在命令行中交互式地检查您的数据。您可以通过指定-S选项来防止长行(如venture.csv)换行:

$ less -S venture.csv
,GEO,OBS_STATUS,OBS_VALUE,FREQ_DESC,TIME_FORMAT_DESC,TIME_PERIOD_DESC,OBS_STATU> al,Annual,Year 2015,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2007,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2008,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2009,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2010,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2011,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2012,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2013,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2014,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2015,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2007,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2008,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2009,No data,"Venture capital investment (seed, start-up and lat> al,Annual,Year 2010,No data,"Venture capital investment (seed, start-up and lat> :                   

右边的大于号表示您可以水平滚动。按UpDown可以上下滚动。按下Space向下滚动整个屏幕。水平滚动通过按LeftRight完成。按下gG分别转到文件的开始和结束。按下q即可退出less。手册页列出了所有可用的键绑定。

less的一个优点是它不会将整个文件加载到内存中,这意味着它即使在查看大文件时也很快。

7.2.3 特征名称和数据类型

列(或特征)名称可以指示特征的含义。为此,您可以使用以下headtr组合:

$ < venture.csv head -n 1 | tr , '\n'
FREQ
TIME_FORMAT
TIME_PERIOD
EXPEND
UNIT
GEO
OBS_STATUS
OBS_VALUE
FREQ_DESC
TIME_FORMAT_DESC
TIME_PERIOD_DESC
OBS_STATUS_DESC
EXPEND_DESC
UNIT_DESC
GEO_DESC

这个基本命令假设文件由逗号分隔。更健壮的方法是使用csvcut:

$ csvcut -n venture.csv
  1: FREQ
  2: TIME_FORMAT
  3: TIME_PERIOD
  4: EXPEND
  5: UNIT
  6: GEO
  7: OBS_STATUS
  8: OBS_VALUE
  9: FREQ_DESC
 10: TIME_FORMAT_DESC
 11: TIME_PERIOD_DESC
 12: OBS_STATUS_DESC
 13: EXPEND_DESC
 14: UNIT_DESC
 15: GEO_DESC

除了打印列名之外,您还可以更进一步。除了列名之外,了解每列包含什么类型的值也非常有用,比如字符串、数值或日期。假设您有以下玩具数据集:

$ bat -A datatypes.csv
───────┬────────────────────────────────────────────────────────────────────────
       │ File: datatypes.csv
───────┼────────────────────────────────────────────────────────────────────────
   1   │ a,b,c,d,e,f␊
   2   │ 1,0.0,FALSE,"""Yes!""",2011-11-11·11:00,2012-09-08␊
   3   │ 42,3.1415,TRUE,"OK,·good",2014-09-15,12/6/70␊
   4   │ 66,,False,2198,,␊
───────┴────────────────────────────────────────────────────────────────────────

csvlook解释如下:

$ csvlook datatypes.csv
│  a │      b │     c │ d        │                   e │          f │
├────┼────────┼───────┼──────────┼─────────────────────┼────────────┤
│  1 │ 0.000… │ False │ "Yes!"   │ 2011-11-11 11:00:00 │ 2012-09-08 │
│ 42 │ 3.142… │  True │ OK, good │ 2014-09-15 00:00:00 │ 1970-12-06 │
│ 66 │        │ False │ 2198     │                     │            │

我已经在第五章中使用了csvsql来直接对 CSV 数据执行 SQL 查询。当没有传递命令行参数时,它会生成必要的 SQL 语句,如果要将这些数据插入到实际的数据库中,就需要用到这些语句。您还可以使用输出来检查推断的列类型。如果一列在数据类型后打印了NOT NULL字符串,那么该列不包含缺失值。

$ csvsql datatypes.csv
CREATE TABLE datatypes (
        a DECIMAL NOT NULL,
        b DECIMAL,
        c BOOLEAN NOT NULL,
        d VARCHAR NOT NULL,
        e TIMESTAMP,
        f DATE
);

当您使用csvkit套件中的其他工具时,例如csvgrepcsvsortcsvsql,这个输出特别有用。对于venture.csv,各列推断如下:

$ csvsql venture.csv CREATE TABLE venture (
        "FREQ" VARCHAR NOT NULL,
        "TIME_FORMAT" VARCHAR NOT NULL,
        "TIME_PERIOD" DECIMAL NOT NULL,
        "EXPEND" VARCHAR NOT NULL,
        "UNIT" VARCHAR NOT NULL,
        "GEO" VARCHAR NOT NULL,
        "OBS_STATUS" BOOLEAN,
        "OBS_VALUE" DECIMAL NOT NULL,
        "FREQ_DESC" VARCHAR NOT NULL,
        "TIME_FORMAT_DESC" VARCHAR NOT NULL,
        "TIME_PERIOD_DESC" VARCHAR NOT NULL,
        "OBS_STATUS_DESC" VARCHAR NOT NULL,
        "EXPEND_DESC" VARCHAR NOT NULL,
        "UNIT_DESC" VARCHAR NOT NULL,
        "GEO_DESC" VARCHAR NOT NULL
);

7.2.4 唯一标识符、连续变量和因子

仅仅知道每个特性的数据类型是不够的。了解每个特性代表什么也很重要。了解这个领域非常有用,但是我们也可以通过查看数据本身来获得一些上下文。

字符串和整数都可以是唯一的标识符,也可以代表一个类别。在后一种情况下,这可以用来为您的可视化指定一种颜色。但是如果一个整数代表一个邮政编码,那么计算平均值就没有意义了。

要确定某个特征是否应被视为唯一标识符或分类变量,您可以计算特定列的唯一值的数量:

$ wc -l tips.csv
245 tips.csv

$ < tips.csv csvcut -c day | header -d | sort | uniq | wc -l
4

您可以使用csvstat (它是csvkit的一部分)来获取每列的唯一值的数量:

$ csvstat tips.csv --unique
  1\. bill: 229
  2\. tip: 123
  3\. sex: 2
  4\. smoker: 2
  5\. day: 4
  6\. time: 2
  7\. size: 6

$ csvstat venture.csv --unique
  1\. FREQ: 1
  2\. TIME_FORMAT: 1
  3\. TIME_PERIOD: 9
  4\. EXPEND: 1
  5\. UNIT: 3
  6\. GEO: 20
  7\. OBS_STATUS: 1
  8\. OBS_VALUE: 286
  9\. FREQ_DESC: 1
 10\. TIME_FORMAT_DESC: 1
 11\. TIME_PERIOD_DESC: 9
 12\. OBS_STATUS_DESC: 1
 13\. EXPEND_DESC: 1
 14\. UNIT_DESC: 3
 15\. GEO_DESC: 20

如果只有一个惟一的值(比如用OBS_STATUS,那么有可能您可以丢弃该列,因为它不提供任何值。如果您想自动丢弃所有这样的列,那么您可以使用以下管道:

$ < venture.csv csvcut -C $( # ➊
>   csvstat venture.csv --unique | # ➋
>   grep ': 1$' | # ➌
>   cut -d. -f 1 | # ➍
>   tr -d ' ' | # ➎
>   paste -sd, # ➏
> ) | trim # ➐
TIME_PERIOD,UNIT,GEO,OBS_VALUE,TIME_PERIOD_DESC,UNIT_DESC,GEO_DESC
2015,PC_GDP,CZ,0.002,Year 2015,Percentage of GDP,Czechia
2007,PC_GDP,DE,0.034,Year 2007,Percentage of GDP,Germany
2008,PC_GDP,DE,0.039,Year 2008,Percentage of GDP,Germany
2009,PC_GDP,DE,0.029,Year 2009,Percentage of GDP,Germany
2010,PC_GDP,DE,0.029,Year 2010,Percentage of GDP,Germany
2011,PC_GDP,DE,0.029,Year 2011,Percentage of GDP,Germany
2012,PC_GDP,DE,0.021,Year 2012,Percentage of GDP,Germany
2013,PC_GDP,DE,0.023,Year 2013,Percentage of GDP,Germany
2014,PC_GDP,DE,0.021,Year 2014,Percentage of GDP,Germany
… with 531 more lines

-C选项取消选择给定位置(或名称)的列,该选项提供了命令替换

➋ 获取venture.csv

➌ 仅保留包含一个唯一值的列

➍ 提取列位置

➎ 修剪任何空白区域

➏ 放置所有列位置

说到这里,我打算暂时保留这些专栏。

一般来说,如果唯一值的数量与总行数相比较少,那么该特征可能会被视为分类特征(例如在venture.csv中的GEO)。如果数字等于行数,它可能是唯一标识符,但也可能是数值。只有一个方法可以找到答案:我们需要更深入。

7.3 计算描述性统计量

7.3.1 列的统计量

命令行工具csvstat给出了很多信息。对于每个特征(列),它显示:

  • 数据类型
  • 它是否有任何缺失值(空值)
  • 唯一值的数量
  • 适用于这些特征的各种描述性统计数据(最小值、最大值、总和、平均值、标准差和中值)

如下调用csvstat:

$ csvstat venture.csv | trim 32
  1\. "FREQ"

        Type of data:          Text
        Contains null values:  False
        Unique values:         1
        Longest value:         1 characters
        Most common values:    A (540x)

  2\. "TIME_FORMAT"

        Type of data:          Text
        Contains null values:  False
        Unique values:         1
        Longest value:         3 characters
        Most common values:    P1Y (540x)

  3\. "TIME_PERIOD"

        Type of data:          Number
        Contains null values:  False
        Unique values:         9
        Smallest value:        2,007
        Largest value:         2,015
        Sum:                   1,085,940
        Mean:                  2,011
        Median:                2,011
        StDev:                 2.584
        Most common values:    2,015 (60x)
                               2,007 (60x)
                               2,008 (60x)
                               2,009 (60x)
                               2,010 (60x)
… with 122 more lines

我只显示了前 32 行,因为这会产生大量输出。您可能想通过less来处理这个问题。如果您只对特定的统计数据感兴趣,也可以使用以下选项之一:

  • --max(最大)
  • --min(最小值)
  • --sum(总和)
  • --mean(均值)
  • --median(中值)
  • --stdev(标准差)
  • --nulls(列是否包含空值)
  • --unique(唯一值)
  • --freq(频繁值)
  • --len(最大值长度)

例如:

$ csvstat venture.csv --freq | trim
  1\. FREQ: { "A": 540 }
  2\. TIME_FORMAT: { "P1Y": 540 }
  3\. TIME_PERIOD: { "2015": 60, "2007": 60, "2008": 60, "2009": 60, "2010": 60 }
  4\. EXPEND: { "INV_VEN": 540 }
  5\. UNIT: { "PC_GDP": 180, "NR_COMP": 180, "MIO_EUR": 180 }
  6\. GEO: { "CZ": 27, "DE": 27, "DK": 27, "EL": 27, "ES": 27 }
  7\. OBS_STATUS: { "None": 540 }
  8\. OBS_VALUE: { "0": 28, "1": 19, "2": 14, "0.002": 10, "0.034": 7 }
  9\. FREQ_DESC: { "Annual": 540 }
 10\. TIME_FORMAT_DESC: { "Annual": 540 }
… with 5 more lines

您可以使用-c选项选择一个特征子集,该选项接受整数和列名:

$ csvstat venture.csv -c 3,GEO
  3\. "TIME_PERIOD"

        Type of data:          Number
        Contains null values:  False
        Unique values:         9
        Smallest value:        2,007
        Largest value:         2,015
        Sum:                   1,085,940
        Mean:                  2,011
        Median:                2,011
        StDev:                 2.584
        Most common values:    2,015 (60x)
                               2,007 (60x)
                               2,008 (60x)
                               2,009 (60x)
                               2,010 (60x)

  6\. "GEO"

        Type of data:          Text
        Contains null values:  False
        Unique values:         20
        Longest value:         2 characters
        Most common values:    CZ (27x)
                               DE (27x)
                               DK (27x)
                               EL (27x)
                               ES (27x)

Row count: 540

记住csvstat, 就像csvsql, 采用启发式的方法去决定数据类型, 并且不一定毁正确. 我鼓励你在采用了前述的分组后,保持采取人工的检查. 进一步, 即使数据类型是一个字符串或者整型, 也没有指明应该如何应用它.

作为一个很好的附加功能,csvstat在最后输出数据点(行)的数量。正确处理值中的换行符和逗号。要只看到最后一行,您可以使用tail。或者,您可以使用xsv,它只返回实际的行数。

$ csvstat venture.csv | tail -n 1
Row count: 540

$ xsv count venture.csv
540

注意,这两个选项不同于使用wc -l,它计算新行的数量(因此也计算标题)。

7.3.2 Shell 上的 R 单行代码

在本节中,我将向您介绍一个名为rush的命令行工具,它使您能够直接从命令行利用统计编程环境R。在我解释rush做什么以及它为什么存在之前,让我们先谈谈R本身。

R是一个非常强大的做数据科学的统计软件包。它是一种解释型编程语言,有大量的软件包,并提供自己的 REPL,类似于命令行,允许您处理数据。注意,一旦启动 R,您就处于一个独立于 Unix 命令行的交互式会话中。

假设您有一个名为tips.csv的 CSV 文件,您想要计算小费的百分比,并保存结果。为了在R中完成这一点,您将首先运行R:

$ R --quiet # ➊
>

➊ 我在这里使用--quiet选项来抑制相当长的启动消息

然后运行下面的代码:

> library(tidyverse)                            # ➊
── Attaching packages ─────────────────────────────────────── tidyverse 1.3.0 ──
✔ ggplot2 3.3.3     ✔ purrr   0.3.4
✔ tibble  3.0.6     ✔ dplyr   1.0.4
✔ tidyr   1.1.2     ✔ stringr 1.4.0
✔ readr   1.4.0     ✔ forcats 0.5.1
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
> df <- read_csv("tips.csv")                    # ➋

── Column specification ──────────────────────────────────────────────────────── cols(
  bill = col_double(),
  tip = col_double(),
  sex = col_character(),
  smoker = col_character(),
  day = col_character(),
  time = col_character(),
  size = col_double()
)

> df <- mutate(df, percent = tip / bill * 100)  # ➌
> write_csv(df, "percent.csv")                  # ➍
> q("no")                                       # ➎

$

➊ 加载任何需要的包

➋ 读入 CSV 文件并将其赋给变量

➌ 计算新列percent

➍ 将结果保存到磁盘

➎ 退出R

之后,您可以在命令行上继续使用保存的文件percent.csv

$ < percent.csv trim 5
bill,tip,sex,smoker,day,time,size,percent
16.99,1.01,Female,No,Sun,Dinner,2,5.9446733372572105
10.34,1.66,Male,No,Sun,Dinner,3,16.054158607350097
21.01,3.5,Male,No,Sun,Dinner,3,16.658733936220845
23.68,3.31,Male,No,Sun,Dinner,2,13.97804054054054
… with 240 more lines

请注意,只有第三行与您具体想要完成的任务相关联。其他行是必要的样板。为了完成某件简单的事情而输入这种样板文件是很麻烦的,而且会破坏你的工作流程。有时,您只想一次对数据做一两件事。如果您能驾驭R的力量并从命令行使用它,那不是很好吗?

这就是rush的用武之地。让我们像以前一样执行相同的任务,但是现在使用rush:

$ rm percent.csv

$ rush run -t 'mutate(df, percent = tip / bill * 100)' tips.csv > percent.csv

$ < percent.csv trim 5
bill,tip,sex,smoker,day,time,size,percent
16.99,1.01,Female,No,Sun,Dinner,2,5.9446733372572105
10.34,1.66,Male,No,Sun,Dinner,3,16.054158607350097
21.01,3.5,Male,No,Sun,Dinner,3,16.658733936220845
23.68,3.31,Male,No,Sun,Dinner,2,13.97804054054054
… with 240 more lines

这些小的一行程序是可能的,因为rush处理所有的样板文件。在这种情况下,我使用的是run子命令。还有plot子命令,我将在下一节中使用它来快速生成数据可视化。如果您正在传递任何输入数据,那么默认情况下,rush假设它是 CSV 格式的,带有一个头和一个逗号作为分隔符。此外,对列名进行了清理,以便更容易使用。您可以分别使用--no-header(或-H)、--delimiter(或-d)和--no-clean-names(或-C)选项来覆盖这些默认值。该帮助很好地概述了run子命令的可用选项:

$ rush run --help
rush: Run an R expression

Usage:
  rush run [options] <expression> [--] [<file>...]

Reading options:
  -d, --delimiter <str>    Delimiter [default: ,].
  -C, --no-clean-names     No clean names.
  -H, --no-header          No header.

Setup options:
  -l, --library <name>     Libraries to load.
  -t, --tidyverse          Enter the Tidyverse.

Saving options:
      --dpi <str|int>      Plot resolution [default: 300].
      --height <int>       Plot height.
  -o, --output <str>       Output file.
      --units <str>        Plot size units [default: in].
  -w, --width <int>        Plot width.

General options:
  -n, --dry-run            Only print generated script.
  -h, --help               Show this help.
  -q, --quiet              Be quiet.
      --seed <int>         Seed random number generator.
  -v, --verbose            Be verbose.
      --version            Show version.

在幕后,rush生成一个R脚本并随后执行它。您可以通过指定--dry-run(或-n)选项来查看这个生成的脚本:

$ rush run -n --tidyverse 'mutate(df, percent = tip / bill * 100)' tips.csv
#!/usr/bin/env Rscript
library(tidyverse)
library(glue)
df <- janitor::clean_names(readr::read_delim("tips.csv", delim = ",", col_names
= TRUE))
mutate(df, percent = tip/bill * 100)

这个生成的脚本:

  • 写出了 Shebang(#!);参见从命令行运行R脚本所需的第四章。
  • 导入tidyverseglue包。
  • 加载tips.csv作为数据帧,清除列名,并将其赋给变量df
  • 运行指定的表达式。
  • 将结果打印到标准输出。

您可以将这个生成的脚本重定向到一个文件,并通过 Shebang 轻松地将它变成一个新的命令行工具。

rush的输出本身不一定是 CSV 格式的。在这里,我计算平均小费百分比、最大聚会规模、时间列的唯一值、账单和小费之间的相关性。最后,我提取整个列(但只显示前 10 个值)。

$ < percent.csv rush run 'mean(df$percent)' -
16.0802581722505

$ < percent.csv rush run 'max(df$size)' -
6

$ < percent.csv rush run 'unique(df$time)' -
Dinner
Lunch

$ < percent.csv rush run 'cor(df$bill, df$tip)' -
0.675734109211365

$ < percent.csv rush run 'df$tip' - | trim
1.01
1.66
3.5
3.31
3.61
4.71
2
3.12
1.96
3.23
… with 234 more lines

最后一个破折号意味着rush应该从标准输入中读取。

所以现在,如果你想用R对你的数据集做一两件事,你可以把它指定为一行程序,然后继续在命令行上工作。您已经掌握的关于R的所有知识现在都可以从命令行使用了。使用rush,你甚至可以创建复杂的可视化效果,我将在下一节向你展示。

7.4 创建可视化效果

在这一节中,我将向您展示如何在命令行创建数据可视化。我将使用rush plot创建条形图、散点图和箱线图。不过,在我们开始之前,我想先解释一下如何显示可视化效果。

7.4.1 从命令行显示图像

让我们以tips.png的图像为例。看一下图 7.1,这是使用rushtips.csv数据集创建的数据可视化。(一会儿我会解释一下rush的语法。)我使用display工具将图片插入书中,但是如果你运行display你会发现它不起作用。这是因为从命令行显示图像实际上相当棘手。

<img/fa1ea8441dd0652dbc80fe793c6eb0ba.png>

图 7.1:自己显示这个图像可能有些棘手

根据您的设置,有不同的选项可用于显示图像。我知道有四种选择,每种都有自己的优缺点:(1)作为文本表示,(2)作为内嵌图像,(3)使用图像查看器,以及(4)使用浏览器。让我们快速浏览一遍。

<img/2301df33aca57b942191225d57a186c0.png>

图 7.2:通过 ASCII 字符和 ANSI 转义序列(上图)和 iTerm2 内嵌图像协议(下图)在终端显示图像

选项 1 是显示终端内的图像,如图 7.2 顶部所示。当标准输出没有重定向到文件时,此输出由rush生成。它基于 ASCII 字符和 ANSI 转义序列,因此在每个终端中都可用。根据你阅读本书的方式,当你运行这段代码时,你得到的输出可能与图 7.2 中的截图相符,也可能不相符。

$ rush plot --x bill --y tip --color size --facets '~day' tips.csv              
 Fri  Sat  10.0  * # 7.5  * # * 5.0  # * ### * # # # ### ## ## #####*####+ * ** # 2.5  # %### % #########*#*## # # t  size  i  Sun  Thur  6  p  10.0  1  7.5  = * ** * # * 5.0  ## # +#*# +* * # = ##* = = # +* 2.5  ######## # * ### # # ## #####* # ## ####* * #+ ###### # 10  20  30  40  50  10  20  30  40  50  bill 

如果你只看到 ASCII 字符,这意味着你阅读这本书的媒介不支持负责颜色的 ANSI 转义序列。幸运的是,如果您自己运行上面的命令,它看起来就像截图一样。

如图 7.2 底部所示,选项 2 也显示终端内的图像。这是 iTerm2 终端,仅适用于 macOS,通过一个小脚本(我将其命名为display)使用内嵌图像协议。Docker 映像不包含该脚本,但是您可以轻松地安装它:

$ curl -s "https://iterm2.com/utilities/imgcat" > display && chmod u+x display

如果您没有在 macOS 上使用 iTerm2,可能有其他选项可以内联显示图像。请咨询你喜欢的搜索引擎。

<img/311bba20cbdd91fabe19001aefa82e20.png>

图 7.3:通过文件浏览器和图像浏览器(左)以及通过网络服务器和浏览器(右)在外部显示图像

选项 3 是在图像查看器中手动打开图像(本例中为tips.csv)。图 7.3 在左边显示了 macOS 上的文件浏览器(Finder)和图像浏览器(Preview)。当你在本地工作时,这个选项总是有效的。当您在 Docker 容器中工作时,只有当您使用-v选项映射了一个本地目录时,才能从您的操作系统访问生成的映像。参见第二章了解如何操作的说明。此选项的一个优点是,当图像发生变化时,大多数图像查看器会自动更新显示,这允许您在微调可视化时进行快速迭代。

选项 4 是在浏览器中打开图像。图 7.3 右侧是火狐显示http://localhost:8000/tips.png的截图。任何浏览器都可以,但是您需要另外两个先决条件才能使用这个选项。首先,您需要使用-p选项在 Docker 容器上创建一个可访问的端口(本例中为端口 8000)。(同样,参见第二章了解如何操作的说明。)其次,你需要启动一个 Webserver。为此,Docker 容器有一个名为servewd的小工具,使用 Python 服务于当前工作目录:

$ bat $(which servewd)
───────┬────────────────────────────────────────────────────────────────────────
       │ File: /usr/bin/dsutils/servewd
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash
   2   │ ARGS="$@"
   3   │ python3 -m http.server ${ARGS} 2>/dev/null &
───────┴────────────────────────────────────────────────────────────────────────

你只需要从一个目录运行servewd一次(比如/data/),它就会愉快地在后台运行。一旦你绘制了一些东西,你就可以在你的浏览器中访问localhost:8000并访问该目录及其所有子目录的内容。默认端口是 8000,但是您可以通过将它指定为servewd的参数来更改它:

$ servewd 9999

只要确保这个端口是可访问的。因为servewd在后台运行,所以需要按如下方式停止它:

$ pkill -f http.server

选项 4 也可以在远程机器上工作。

既然我们已经介绍了显示图像的四个选项,让我们继续实际创建一些。

7.4.2 使用rush绘图

当谈到创建数据可视化时,有太多的选择。就我个人而言,我是一个坚定的支持者ggplot2,这是一个 R 的可视化包。图形的底层语法伴随着一个一致的 API,允许您快速迭代地创建不同类型的漂亮数据可视化,而很少需要查阅文档。探索数据时一组受欢迎的属性。

我们并不真的着急,但我们也不想过多地摆弄任何单一的可视化。此外,我们希望尽可能多地使用命令行。幸运的是,我们还有rush,它允许我们从命令行ggplot2。图 7.1 中的数据可视化可以创建如下:

$ rush run --library ggplot2 'ggplot(df, aes(x = bill, y = tip, color = size)) +
 geom_point() + facet_wrap(~day)' tips.csv > tips.png

然而,你可能已经注意到了,我使用了一个非常不同的命令来创建tips.png:

$ rush plot --x bill --y tip --color size --facets '~day' tips.csv > tips.png

虽然ggplot2的语法相对简洁,特别是考虑到它所提供的灵活性,但是有一个快速创建基本绘图的捷径。该快捷方式可通过rushplot子命令获得。这允许你创建漂亮的基本绘图,而不需要学习 R 和图形的语法。

在引擎盖下,rush plot使用ggplot2包中的功能qplot。这是文件的第一部分:

$ R -q -e '?ggplot2::qplot' | trim 14
> ?ggplot2::qplot
qplot                 package:ggplot2                  R Documentation

Quick plot

Description:

     ‘qplot()’ is a shortcut designed to be familiar if you're used to
     base ‘plot()’. It's a convenient wrapper for creating a number of
     different types of plots using a consistent calling scheme. It's
     great for allowing you to produce plots quickly, but I highly
     recommend learning ‘ggplot()’ as it makes it easier to create
     complex graphics.

… with 108 more lines

我同意这个建议;一旦你读完这本书,学习ggplot2将是值得的,尤其是如果你想将任何探索性的数据可视化升级为适合交流的可视化。现在,当我们在命令行时,让我们走捷径。

如图 7.2 所示,rush plot可以用相同的语法创建图形可视化(由像素组成)和文本可视化(由 ASCII 字符和 ANSI 转义序列组成)。当rush检测到其输出通过管道传输到另一个命令(如display或重定向到一个文件,如tips.png时,它将产生一个图形可视化;否则它将产生文本可视化。

让我们花点时间通读一下rush plot的绘图和保存选项:

$ rush plot --help
rush: Quick plot

Usage:
  rush plot [options] [--] [<file>|-]

Reading options:
  -d, --delimiter <str>    Delimiter [default: ,].
  -C, --no-clean-names     No clean names.
  -H, --no-header          No header.

Setup options:
  -l, --library <name>     Libraries to load.
  -t, --tidyverse          Enter the Tidyverse.

Plotting options:
      --aes <key=value>    Additional aesthetics.
  -a, --alpha <name>       Alpha column.
  -c, --color <name>       Color column.
      --facets <formula>   Facet specification.
  -f, --fill <name>        Fill column.
  -g, --geom <geom>        Geometry [default: auto].
      --group <name>       Group column.
      --log <x|y|xy>       Variables to log transform.
      --margins            Display marginal facets.
      --post <code>        Code to run after plotting.
      --pre <code>         Code to run before plotting.
      --shape <name>       Shape column.
      --size <name>        Size column.
      --title <str>        Plot title.
  -x, --x <name>           X column.
      --xlab <str>         X axis label.
  -y, --y <name>           Y column.
      --ylab <str>         Y axis label.
  -z, --z <name>           Z column.

Saving options:
      --dpi <str|int>      Plot resolution [default: 300].
      --height <int>       Plot height.
  -o, --output <str>       Output file.
      --units <str>        Plot size units [default: in].
  -w, --width <int>        Plot width.

General options:
  -n, --dry-run            Only print generated script.
  -h, --help               Show this help.
  -q, --quiet              Be quiet.
      --seed <int>         Seed random number generator.
  -v, --verbose            Be verbose.
      --version            Show version.

最重要的选项是以<name>作为参数的绘图选项。例如,--x选项允许您指定应该使用哪一列来确定对象应该沿 x 轴放置在哪里。这同样适用于--y选项。--color--fill选项用于指定您想要使用哪一列进行着色。你大概能猜到--size--alpha选项是关于什么的。在我创建各种可视化效果时,其他常见选项将在各节中解释。注意,对于每个可视化,我首先显示其文本表示(ASCII 和 ANSI 字符),然后显示其视觉表示(像素)。

7.4.3 创建条形图

条形图对于显示分类特征的值计数特别有用。以下是tips数据集中的time特征的文本可视化:

$ rush plot --x time tips.csv           
 ******************************** ******************************** 150  ******************************** ******************************** ******************************** ******************************** 100  ******************************** ******************************** ******************************** ******************************** ******************************** ******************************** 50  ******************************** ******************************** ******************************** ******************************** ******************************** ******************************** ******************************** ******************************** 0  ******************************** ******************************** Dinner  Lunch  time 

图 7.4 显示了图形可视化,当输出被重定向到一个文件时,由rush plot创建。

$ rush plot --x time tips.csv > plot-bar.png

$ display plot-bar.png

<img/9b38b591063ff448114c38d1ba9b145a.png>

图 7.4:条形图

从这个柱状图我们可以得出的结论很简单:晚餐的数据点是午餐的两倍多。

7.4.4 创建直方图

连续变量的计数可以用直方图显示。这里,我使用了时间特性来设置填充颜色。因此,rush plot方便地创建了一个堆叠直方图。

$ rush plot --x tip --fill time tips.csv
 === === === 40  === === === === === === 30  === === === === time  ===== === Dinner  20  ==+++ === ===== Lunch  ==+++==== ===== === + ==+++==== ===== === === 10  +++++========== === === ====+++++++++=+++====+++== ===== ==+++++++++++++++++==+++++=+++====== ====== 0  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2.5  5.0  7.5  10.0  tip 

图 7.5 显示了可视化图形。

允许我展现你可以会觉得有用的两种快捷语法. 两个惊叹号(!!)把前述的命令行代替. 惊叹号和美元符号(!$)把前面命令的最后部分代替, 就是文件名plot-histogram.png. 你可以看到, 更新后的命令首先被 ZShell 打印,所以你可以知道它到底执行了什么. 这两种快捷语法可以节省打字时间, 但它们不太容易被记住.

$ !! > plot-histogram.png
rush plot --x tip --fill time tips.csv > plot-histogram.png

$ display !$
display plot-histogram.png

<img/1e57a7240ec784d951b85f296082006f.png>

图 7.5:直方图

该直方图显示,大多数小费在 2.5 美元左右。因为晚餐组和午餐组这两个组是相互叠加的,并且显示绝对计数,所以很难对它们进行比较。也许密度图可以对此有所帮助。

7.4.5 创建密度图

密度图对于可视化连续变量的分布非常有用。rush plot使用试探法来确定合适的几何图形,但您可以用geom选项覆盖它:

$ rush plot --x tip --fill time --geom density tips.csv                         
 0.5  @@@ @@+@@ 0.4  @+++@@ @@++++@ @+++++@@ @@ 0.3  @++++++@@@@=@@ @++++++++@@===@@ time  @+++++++++@@====@ Dinner  0.2  @+++++++++++@@@===@@ Lunch  @+++++++++++++@@@==@@ @ @++++++++++++++++@@@@@@@ 0.1  @@+++++++++++++++++++++@@@@@@@ ++++++++++++++++++++++++++@++@@@@ ++++++++++++++++++++++++++++++++@@@@@@@@@@@ 0.0  ++++++++++++++++++++++++++++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@ 2.5  5.0  7.5  10.0  tip 

在这种情况下,与图 7.6 中的视觉表示相比,文字表示确实显示了其局限性。

$ rush plot --x tip --fill time --geom density tips.csv > plot-density.png

$ display plot-density.png

<img/de01e532d12b86bf101e7c7b101974ab.png>

图 7.6:密度图

7.4.6 快乐的小意外

你已经看到了三种类型的可视化。在ggplot2中,这些对应于功能geom_bargeom_histogramgeom_densitygeomgeometry的缩写,表示实际绘制的内容。这个ggplot2的备忘单很好地概述了可用的几何类型。可以使用的几何图形类型取决于您指定的柱(及其类型)。不是每个组合都有意义。以这个线图为例。

$ rush plot --x tip --y bill --color size --size day --geom path tips.csv       
 50  #* * ##### # == #*** ****##### # ### =**+ #*** *****#### 40  # ##** ####***+====== *****### #*# ###******###+#** ======****### day  ###################*****##*+*##*****####=# Fri  b  30  # *+++########**##==****+****####### Sat  i  # #** *##++####**#===*%=====####*##**# Sun  l  # # ######***#=####==########## ** Thur  l  20  # #########**#####****#####++ ########*########*###### # * size  ###################### ## * 5  10  ########### #### ##* %%## ## # % 2.5  5.0  7.5  10.0  tip 

这个快乐的小意外在图 7.7 的视觉表现中变得更加清晰。

$ rush plot --x tip --y bill --color size --size day --geom path tips.csv > plot
-accident.png

$ display plot-accident.png

<img/4f637d8cbb167fff419fda1a7d3a8a0f.png>

图 7.7:一个快乐的小意外

tips.csv中的行是独立的观察值,而在数据点之间画一条线假设它们是相连的。最好用散点图来形象化tipbill之间的关系。

7.4.7 创建散点图

在指定两个连续特征时,几何图形为点的散点图恰好是默认设置:

$ rush plot --x bill --y tip --color time tips.csv                              
 10.0  = = 7.5  = = = + = t  + = = time  i  = = = == + Dinner  p  5.0  = = = + =+ = =+ + = Lunch  = = =+==+++= = = += + = = += =++= ======== = = = = == =====+=====+=++ === = == = = 2.5  ++=++++=+=+==== == === = = == =+ ===+ + == =++ = = = = = = = 10  20  30  40  50  bill 

注意每个点的颜色是用--color选项指定的(而不是用--fill选项)。直观表示见图 7.8。

$ rush plot --x bill --y tip --color time tips.csv > plot-scatter.png

$ display plot-scatter.png

<img/0050a13df8c9b680af671b6ee9126a2b.png>

图 7.8:散点图

从这个散点图中我们可以得出结论,账单金额和小费之间有关系。也许我们可以通过绘制趋势线从更高的层面来审视这些数据。

7.4.8 创建趋势线

如果您用smooth覆盖默认几何图形,您可以可视化趋势线。这些对于看到更大的画面是有用的。

$ rush plot --x bill --y tip --color time --geom smooth tips.csv                
 == ==== 7.5  ====== ======== ================== =======+++++++++====== t  5.0  ====+++++++++========== time  i  ====++++++================= Dinner  p  ===+++++++============= Lunch  == ==+++++++===== = 2.5  ==============++++==== ======++++++++=== ========== ===== 0.0  == 10  20  30  40  50  bill 

rush plot不能处理透明,所以在这种情况下,可视化表示(见图 7.9)要好得多。

$ rush plot --x bill --y tip --color time --geom smooth tips.csv > plot-trend.pn
g

$ display plot-trend.png

<img/893d7b6c6449535820ac91063915f8e5.png>

图 7.9:趋势线

如果你喜欢将原始点与趋势线一起可视化,你需要借助于用rush run编写ggplot2代码(见图 7.10)。

$ rush run --library ggplot2 'ggplot(df, aes(x = bill, y = tip, color = time)) +
 geom_point() + geom_smooth()' tips.csv > plot-trend-points.png

$ display plot-trend-points.png

<img/695d8458c26b8c0863bf6292da535886.png>

图 7.10:趋势线和原始点的组合

7.4.9 创建箱线图

对于一个或多个特征,箱线图显示五个数字的摘要:最小值、最大值、样本中值以及第一个和第三个四分位数。在这种情况下,我们需要使用factor()函数将size特征转换为分类特征,否则bill特征的所有值将被集中在一起。

$ rush plot --x 'factor(size)' --y bill --geom boxplot tips.csv                 
 50  % % % % % % % 40  % % % % % %%%%%%%%%% %%%%%%%%%% % % % % %%%%%%%%%% b  30  % % % %%%%%%%%%% %%%%%%%%%% i  % %%%%%%%%%%% %%%%%%%%%% l  % % % %%%%%%%%%% l  20  %%%%%%%%%% %%%%%%%%%%% % % %%%%%%%%%% %%%%%%%%%%% %%%%%%%%%% % 10  %%%%%%%%%% % %%%%%%%%%% 1  2  3  4  5  6  factor(size) 

虽然文字表现不算太差,但视觉表现要清晰得多(见图 7.11)。

$ rush plot --x 'factor(size)' --y bill --geom boxplot tips.csv > plot-boxplot.p
ng

$ display plot-boxplot.png

<img/e2658436898c65bdb8a08f87e6da66ed.png>

图 7.11:箱形图

不出所料,这个方框图显示,平均而言,聚会规模越大,费用越高。

7.4.10 添加标签

默认标签基于列名(或规范)。在之前的图片中,标签factor(size)应该有所改进。使用--xlab--ylab选项,您可以覆盖 x 轴和 y 轴的标签。可以使用--title选项添加标题。这里有一个小提琴图(这是一个盒子图和密度图的混搭)演示了这一点(另见图 7.12)。

$ rush plot --x 'factor(size)' --y bill --geom violin --title 'Distribution of b
ill amount per party size' --xlab 'Party size' --ylab 'Bill (USD)' tips.csv     
 Distribution  of  bill  amount  per  party  size  50  % % %% %% % %% %% 40  % % %%% %%%%%% %% B  % % % % % %%%% i  % %%% % % %%%%%%%%%%%% % %% l  30  %% % % % %% %%%%% %%%%%% %%%%% l  %%% % % % % %% %% % %% %% % % %%%%%% (  20  %% %% % % %%%% U  % %% %% %% S  10  %%%%%%%%%%%% %%% %% %%% D  %%%%% %%%%% %%% )  %%%%%% 1  2  3  4  5  6  Party  size 
$ rush plot --x 'factor(size)' --y bill --geom violin --title 'Distribution of b
ill amount per party size' --xlab 'Party size' --ylab 'Bill (USD)' tips.csv > pl
ot-labels.png

$ display plot-labels.png

<img/3e5500141ccbad83478425ed3f1ca211.png>

图 7.12:带有标题和标签的小提琴图

如果你想与他人(或你未来的自己)分享,用适当的标签和标题来注释你的可视化特别有用,以便更容易理解正在显示的内容。

7.4.11 超越基本绘图

原文:https://datascienceatthecommandline.com/2e/chapter-8-parallel-pipelines.html

在前面的章节中,我们一直在处理一次性处理整个任务的命令和管道。然而,在实践中,您可能会发现自己面临一个需要多次运行相同命令或管道的任务。例如,您可能需要:

  • 抓取数百个网页
  • 进行几十次 API 调用并转换它们的输出
  • 为一系列参数值训练分类器
  • 为数据集中的每对特征生成散点图

在上述任何一个例子中,都包含了某种形式的重复。使用您最喜欢的脚本或编程语言,您可以使用for循环或while循环来处理这个问题。在命令行上,您可能倾向于做的第一件事是按下Up来恢复之前的命令,如果需要的话对其进行修改,然后按下Enter来再次运行该命令。这样做两三次没问题,但是想象一下这样做几十次。这种方法很快变得繁琐、低效,并且容易出错。好消息是,您也可以在命令行上编写这样的循环。这就是本章的全部内容。

有时候,一个接一个地重复快速命令(以序列的方式)就足够了。当您拥有多个内核(甚至可能是多台机器)时,如果您能够利用这些内核就好了,尤其是当您面临数据密集型任务时。使用多个内核或机器时,总运行时间可能会显著减少。在这一章中,我将介绍一个非常强大的工具,叫做parallel,它可以处理好这一切。它使您能够对一系列参数(如数字、行和文件)应用命令或管道。另外,顾名思义,它允许您在并行中运行命令。

8.1 概述

本章讨论了几种加速需要多次运行命令和管道的任务的方法。我的主要目标是向你展示parallel的灵活性和力量。因为该工具可以与本书中讨论的任何其他工具相结合,所以它将积极地改变您使用命令行进行数据科学的方式。在本章中,您将了解:

  • 对一系列数字、行和文件串行运行命令
  • 将一个大任务分成几个小任务
  • 并行运行管道
  • 将管道分发到多台机器

本章从以下文件开始:

$ cd /data/ch08

$ l
total 20K
-rw-r--r-- 1 dst dst  126 Mar  3 10:51 emails.txt
-rw-r--r-- 1 dst dst   61 Mar  3 10:51 movies.txt
-rwxr-xr-x 1 dst dst  125 Mar  3 10:51 slow.sh*
-rw-r--r-- 1 dst dst 5.1K Mar  3 10:51 users.json

获取这些文件的说明在第二章中。任何其他文件都是使用命令行工具下载或生成的。

8.2 串行处理

在深入研究并行化之前,我将简要讨论串行循环。知道如何做到这一点是值得的,因为这个功能总是可用的,语法非常类似于其他编程语言中的循环,并且它将真正使您欣赏parallel

从本章介绍中提供的例子中,我们可以提取三种类型的项目进行循环:数字、行和文件。这三种类型的项目将在接下来的三个小节中分别讨论。

8.2.1 数字上的循环

假设您需要计算 0 到 100 之间的每个偶数的平方。有一个叫做bc的工具,这是一个基本计算器,你可以用管道把一个方程。计算 4 的平方的命令如下所示:

$ echo "4^2" | bc
16

对于一次性计算,这就可以了。但是,正如介绍中提到的,你需要疯狂地按下Up,改变数字,并按下Enter 50 次!在这种情况下,最好让 Shell 通过使用for循环来为您完成困难的工作:

$ for i in {0..100..2}  # ➊
> do
> echo "$i^2" | bc      # ➋
> done | trim
0
4
16
36
64
100
144
196
256
324
… with 41 more lines

➊ ZShell 有一个特性叫做大括号扩展,将{0..100..2}转换成一个由空格分隔的列表: 0 2 4 … 98 100。变量i在第一次迭代中赋值0,在第二次迭代中赋值1,依此类推。

➌ 这个变量的值可以通过在它前面加一个美元符号($)来使用。Shell 将在执行echo之前用它的值替换$i。注意在dodone之间可以有多个命令。

虽然与您最喜欢的编程语言相比,语法可能显得有点奇怪,但是值得记住这一点,因为它在 Shell 中总是可用的。稍后我将介绍一种更好、更灵活的重复命令的方式。

8.2.2 行上的循环

第二种可以循环的项目是行。这些行可以来自文件或标准输入。这是一种非常通用的方法,因为这些行可以包含任何内容,包括:数字、日期和电子邮件地址。

假设你想给你所有的联系人发一封电子邮件。让我们首先使用免费的随机用户生成器 API 生成一些假用户:

$ curl -s "https://randomuser.me/api/1.2/?results=5&seed=dsatcl2e" > users.json

$ < users.json jq -r '.results[].email' > emails

$ bat emails
───────┬────────────────────────────────────────────────────────────────────────
       │ File: emails
───────┼────────────────────────────────────────────────────────────────────────
   1   │ selma.andersen@example.com
   2   │ kent.clark@example.com
   3   │ ditmar.niehaus@example.com
   4   │ benjamin.robinson@example.com
   5   │ paulo.muller@example.com
───────┴────────────────────────────────────────────────────────────────────────

你可以用while循环遍历来自emails的行:

$ while read line                         # ➊
> do
> echo "Sending invitation to ${line}."   # ➋
> done < emails                           # ➌
Sending invitation to selma.andersen@example.com.
Sending invitation to kent.clark@example.com.
Sending invitation to ditmar.niehaus@example.com.
Sending invitation to benjamin.robinson@example.com.
Sending invitation to paulo.muller@example.com.

➊ 在这种情况下,您需要使用while循环,因为 ZShell 事先不知道输入包含多少行。
尽管在这种情况下line变量周围的花括号是不必要的(因为变量名不能包含句点),但这仍然是一个好的做法。

➌ 这个重定向也可以放在while之前。

您还可以通过指定特殊的文件标准输入/dev/stdin,以交互方式向while循环提供输入。完成后按Ctrl-D

$ while read line; do echo "You typed: ${line}."; done < /dev/stdin
one
You typed: one.
two
You typed: two.
three
You typed: three.

但是这种方法有一个缺点,就是一旦你按下Enter,那一行输入的dodone之间的命令会立即运行。没有回头路了。

8.2.3 文件上的循环

在这一节中,我将讨论我们经常需要循环的第三种类型的项目:文件。

为了处理特殊字符,使用globbing(即路径名扩展)代替ls :

$ for chapter in /data/*
> do
> echo "Processing Chapter ${chapter}."
> done
Processing Chapter /data/ch02.
Processing Chapter /data/ch03.
Processing Chapter /data/ch04.
Processing Chapter /data/ch05.
Processing Chapter /data/ch06.
Processing Chapter /data/ch07.
Processing Chapter /data/ch08.
Processing Chapter /data/ch09.
Processing Chapter /data/ch10.
Processing Chapter /data/csvconf.

就像大括号展开一样,表达式/data/在被for循环处理之前,首先被 ZShell 展开成一个列表。

清单文件的一个更详细的替代是find,其中:

  • 可以向下遍历目录
  • 允许对诸如大小、访问时间和权限等属性进行详细搜索
  • 处理特殊字符,如空格和换行符

例如,下面的find调用列出了目录/data下扩展名为csv且小于 2kb 的所有文件:

$ find /data -type f -name '*.csv' -size -2k
/data/ch03/tmnt-basic.csv
/data/ch03/tmnt-missing-newline.csv
/data/ch03/tmnt-with-header.csv
/data/ch05/irismeta.csv
/data/ch05/names-comma.csv
/data/ch05/names.csv
/data/ch07/datatypes.csv

8.3 并行处理

假设您有一个运行时间很长的工具,如下所示:

$ bat slow.sh
───────┬────────────────────────────────────────────────────────────────────────
       │ File: slow.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/bin/bash
   2   │ echo "Starting job $1" | ts # ➊
   3   │ duration=$((1+RANDOM%5)) # ➋
   4   │ sleep $duration # ➌
   5   │ echo "Job $1 took ${duration} seconds" | ts
───────┴────────────────────────────────────────────────────────────────────────

ts增加一个时间戳。

➋ 魔法变量RANDOM调用一个内部 Bash 函数,返回一个 0 到 32767 之间的伪随机整数。将该整数除以 5 的余数加上 1 确保了duration在 1 和 5 之间。

sleep暂停执行给定的秒数。

这个过程可能不会占用所有可用的资源。碰巧你需要运行这个命令很多次。例如,您需要下载一系列文件。

一种简单的并行化方法是在后台运行命令。让我们运行slow.sh三次:

$ for i in {A..C}; do
> ./slow.sh $i & # ➊
> done
[2] 385 # ➋
[3] 387
[4] 390

$ Mar 03 10:52:01 Starting job A
Mar 03 10:52:01 Starting job B
Mar 03 10:52:01 Starting job C
Mar 03 10:52:02 Job A took 1 seconds

[2]    done       ./slow.sh $i
$ Mar 03 10:52:04 Job C took 3 seconds

[4]  + done       ./slow.sh $i
$ Mar 03 10:52:05 Job B took 4 seconds

[3]  + done       ./slow.sh $i
$

➊ “与”号(&)将命令发送到后台,允许for循环立即继续下一次迭代。

➋ 这一行显示了 ZShell 给定的作业号和进程 ID,可以用于更细粒度的作业控制。这个话题虽然强大,但超出了本书的范围。

记住并不是所有的东西都可以并行化. API 函数可能只有一个特定的数字, 或者一些命令,只可能有 1 个实例。.

图 8.1 从概念层面上说明了串行处理、简单并行处理和使用 GNU Parallel 的并行处理在并发进程数量和运行所有事务所花费的总时间方面的区别。

<img/030dcbf30b757579bf9115a13b9132b9.png>

图 8.1:串行处理、简单并行处理和使用 GNU Parallel 的并行处理

这种幼稚的方法有两个问题。首先,没有办法控制您同时运行多少个进程。如果您一次启动太多的作业,它们可能会竞争相同的资源,如 CPU、内存、磁盘访问和网络带宽。这可能会导致运行所有程序需要更长的时间。第二,很难区分哪个输出属于哪个输入。让我们看看更好的方法。

8.3.1 GNU Parallel 简介

请允许我介绍一下parallel,这是一个命令行工具,允许您并行化和分发命令和管道。这个工具的美妙之处在于,现有的工具可以原样使用;它们不需要修改。

有 2 个命令行工具有相同的名字parallel. 如果你使用 Docker 镜像那么你已经安装了正确的命令行工具了. 否则, 你可能要运行parallel --version检查下是否安装了正确的版本. 结果应该为GNU parallel

在我深入讨论parallel的细节之前,这里有一个小笑话向你展示替换之前的for循环是多么容易:

$ seq 0 2 100 | parallel "echo {}^2 | bc" | trim
0
4
16
36
64
100
144
196
256
324
… with 41 more lines

这是parallel最简单的形式:要循环的项目通过标准输入传递,除了parallel需要运行的命令之外,没有任何参数。参见图 8.2 了解parallel如何在进程间并发分配输入并收集它们的输出。

<img/d9c2c9e6061706d6cec95f0426a05e89.png>

图 8.2: GNU Parallel 同时在进程间分配输入并收集它们的输出

正如你所看到的,它基本上是一个for循环。这是另一个笑话,它取代了上一节中的for循环。

$ parallel --jobs 2 ./slow.sh ::: {A..C}
Mar 03 10:52:12 Starting job A
Mar 03 10:52:13 Job A took 1 seconds
Mar 03 10:52:12 Starting job B
Mar 03 10:52:16 Job B took 4 seconds
Mar 03 10:52:13 Starting job C
Mar 03 10:52:18 Job C took 4 seconds

这里,使用--jobs选项,我指定parallel最多可以同时运行两个作业。slow.sh的参数被指定为一个参数,而不是通过标准输入。

凭借多达 159 种不同的选项,parallel提供了大量的功能。(也许太多了。幸运的是,你只需要知道一小部分就能有效。如果您需要使用一个不常用的选项,手册页提供了很多信息。

8.3.2 指定输入

parallel最重要的参数是您希望为每个输入运行的命令或管道。问题是:输入项应该插入命令行的什么位置?如果不指定任何内容,那么输入项将被追加到管道的末尾。

$ seq 3 | parallel cowsay

 ___
< 1 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
 ___
< 2 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
 ___
< 3 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

以上与跑步相同:

$ cowsay 1 > /dev/null # ➊

$ cowsay 2 > /dev/null

$ cowsay 3 > /dev/null

➊ 因为输出和之前一样,所以我把它重定向到/dev/null来抑制它。

虽然这通常是可行的,但我建议您通过使用占位符来明确输入项应该插入到命令中的什么位置。在这种情况下,因为您想一次使用整个输入行(一个数字),所以您只需要一个占位符。您用一对花括号({})指定占位符,换句话说,指定输入项的位置:

$ seq 3 | parallel cowsay {} > /dev/null

有其他的方法提供parallel的输入. 我提倡用管道(就像我在整章中做的那样)因为那是大多数命令行工具串联在一起的工具. 另外一个方法是用不常见的语法. 不得不说的是, 它们确实增加了新的功能, 比如遍历多个数组的所有组合, 所以如果想了解更多,读下parallel的帮助手册

当输入项是文件名时,有几个修饰符可以只使用文件名的一部分。例如,使用{/},将只使用文件名的基本名称:

$ find /data/ch03 -type f | parallel echo '{#}\) \"{}\" has basename \"{/}\"' # ➊
1) "/data/ch03/tmnt-basic.csv" has basename "tmnt-basic.csv"
2) "/data/ch03/logs.tar.gz" has basename "logs.tar.gz"
3) "/data/ch03/tmnt-missing-newline.csv" has basename "tmnt-missing-newline.csv"
4) "/data/ch03/r-datasets.db" has basename "r-datasets.db"
5) "/data/ch03/top2000.xlsx" has basename "top2000.xlsx"
6) "/data/ch03/tmnt-with-header.csv" has basename "tmnt-with-header.csv"

➊ 括号())和引号(")等字符在 Shell 中有特殊的含义。要按字面意思使用它们,你要在它们前面加一个反斜杠\。这叫转义

如果输入行有多个由分隔符分隔的部分,您可以向占位符添加数字。例如:

$ < input.csv parallel --colsep , "mv {2} {1}" > /dev/null

在这里,您可以应用相同的占位符修饰符。也可以重用相同的输入项。如果parallel的输入是一个带标题的 CSV 文件,那么您可以使用列名作为占位符:

$ < input.csv parallel -C, --header : "invite {name} {email}"

如果你想知道你的占位符是否设置正确, 你可以加上--dryrun选项. parallel将会打印出所有它将要执行的命令而不是真正的执行它们.

8.3.3 控制并发作业的数量

默认情况下,parallel在每个 CPU 内核上运行一个作业。您可以使用--jobs-j选项控制同时运行的任务数量。指定一个数字意味着许多作业将同时运行。如果你在数字前面加一个加号,那么parallel将运行N个任务加上 CPU 核心的数量。如果你在数字前面加一个减号,那么parallel将运行N-M个任务。其中N是 CPU 内核的数量。您还可以指定一个百分比,默认值为 CPU 核心数的 100%。并发运行的作业的最佳数量取决于您正在运行的实际命令。

$ seq 5 | parallel -j0 "echo Hi {}"
Hi 1
Hi 3
Hi 2
Hi 4
Hi 5
$ seq 5 | parallel -j200% "echo Hi {}"
Hi 1
Hi 2
Hi 3
Hi 4
Hi 5

如果您指定-j1,那么命令将串行运行。即使这不做正义的工具的名称,它仍然有它的用途。例如,当您需要访问一个一次只允许一个连接的 API 时。如果您指定了-j0,那么parallel将会并行运行尽可能多的作业。这可以与您的带&符号的循环相比较。这是不可取的。

8.3.4 日志和输出

为了保存每个命令的输出,您可能会尝试以下操作:

$ seq 5 | parallel "echo \"Hi {}\" > hi-{}.txt"

这将把输出保存到单独的文件中。或者,如果您想将所有内容保存到一个大文件中,您可以执行以下操作:

$ seq 5 | parallel "echo Hi {}" >> one-big-file.txt

然而,parallel提供了--results选项,它将输出存储在单独的文件中。对于每个作业,parallel创建三个文件: seq,保存作业编号,stdout,包含作业产生的输出,stderr,包含作业产生的任何错误。这三个文件根据输入值放在子目录中。

parallel仍然打印所有的输出,在这种情况下是多余的。您可以将标准输入和标准输出重定向到/dev/null,如下所示:

$ seq 10 | parallel --results outdir "curl 'https://anapioficeandfire.com/api/ch
aracters/{}' | jq -r '.aliases[0]'" 2>/dev/null 1>&2

$ tree outdir | trim outdir
└── 1
    ├── 1
    │   ├── seq
    │   ├── stderr
    │   └── stdout
    ├── 10
    │   ├── seq
    │   ├── stderr
    │   └── stdout
… with 34 more lines

参见图 8.3 了解--results选项如何工作的图示概述。

<img/3a5264c5b07ac5121dfc608641e462ff.png>

图 8.3: GNU Parallel 使用--results选项将输出存储在单独的文件中

当您并行运行多个作业时,作业运行的顺序可能与输入的顺序不一致。因此,工作的产出也是混杂的。要保持相同的顺序,请指定--keep-order选项或-k选项。

有时,记录哪个输入生成了哪个输出是很有用的。parallel允许您用--tag选项标记输出,这将为每一行添加输入项。

$ seq 5 | parallel --tag "echo 'sqrt({})' | bc -l"
1       1
2       1.41421356237309504880
3       1.73205080756887729352
4       2.00000000000000000000
5       2.23606797749978969640

$ parallel --tag --keep-order "echo '{1}*{2}' | bc -l" ::: 3 4 ::: 5 6 7
3 5     15
3 6     18
3 7     21
4 5     20
4 6     24
4 7     28

8.3.5 创建并行工具

我在本章开始时使用的bc工具本身并不是并行的。但是,您可以使用parallel将其并行化。Docker 图像包含一个名为pbc的工具。它的代码如下所示:

$ bat $(which pbc)
───────┬────────────────────────────────────────────────────────────────────────
       │ File: /usr/bin/dsutils/pbc
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/bin/bash
   2   │ # pbc: parallel bc. First column of input CSV is mapped to {1}, second
       │ to {2}, and so forth.
   3   │ #
   4   │ # Example usage: paste -d, <(seq 100) <(seq 100 -1 1) | ./pbc 'sqrt({1}
       │ *{2})'
   5   │ #
   6   │ # Dependency: GNU parallel
   7   │ #
   8   │ # Author: http://jeroenjanssens.com
   9   │
  10   │ parallel -C, -k -j100% "echo '$1' | bc -l"
───────┴────────────────────────────────────────────────────────────────────────

这个工具也允许我们简化本章开头使用的代码。它可以同时处理逗号分隔的值:

$ seq 100 | pbc '{1}^2' | trim
1
4
9
16
25
36
49
64
81
100
… with 90 more lines

$ paste -d, <(seq 4) <(seq 4) <(seq 4) | pbc 'sqrt({1}+{2})^{3}'
1.41421356237309504880
4.00000000000000000000
14.69693845669906858905
63.99999999999999999969

8.4 分布式处理

有时你需要比你的本地机器更多的能量,即使它有所有的核心。幸运的是,parallel还可以利用远程机器的能力,这真的可以让你加快流水线的速度。

最棒的是parallel不必安装在远程机器上。所需要的就是你可以用安全 Shell 协议(或 SSH)连接到远程机器,这也是parallel用来分发你的管道的。(安装parallel很有帮助,因为它可以决定在每台远程机器上使用多少内核;稍后将详细介绍。)

首先,我将获得正在运行的 AWS EC2 实例的列表。如果您没有任何远程机器,也不用担心,您可以用--sshlogin :替换任何出现的--slf hostnames,它告诉parallel使用哪个远程机器。这样,您仍然可以遵循本节中的示例。

一旦您知道要接管哪些远程机器,我们将考虑三种类型的分布式处理:

  • 在远程机器上运行普通命令
  • 在远程机器之间直接分发本地数据
  • 将文件发送到远程机器,处理它们,并检索结果

8.4.1 获取正在运行的 AWS EC2 实例列表

在本节中,我们将创建一个名为hostnames的文件,其中每行包含一个远程机器的主机名。我以亚马逊网络服务(AWS)为例。我假设您有一个 AWS 帐户,并且知道如何启动实例。如果你正在使用不同的云计算服务(比如谷歌云平台或微软 Azure),或者如果你有自己的服务器,请确保在继续下一部分之前,你自己创建了一个hostnames文件。

您可以使用 AWS API 的命令行接口aws获得正在运行的 AWS EC2 实例的列表。有了aws,你几乎可以用在线 AWS 管理控制台做所有你能做的事情。

命令aws ec2 describe-instances以 JSON 格式返回关于所有 EC2 实例的大量信息(更多信息请参见在线文档)。您可以使用jq提取相关字段:

$ aws ec2 describe-instances | jq '.Reservations[].Instances[] | {public_dns: .P
ublicDnsName, state: .State.Name}'

EC2 实例的可能状态有: pendingrunningshutting-downterminatedstoppingstopped。因为您只能将管道分发到正在运行的实例,所以您可以按如下方式过滤掉未运行的实例:

> aws ec2 describe-instances | jq -r '.Reservations[].Instances[] | select(.Stat
e.Name=="running") | .PublicDnsName' | tee hostnames
ec2-54-88-122-140.compute-1.amazonaws.com
ec2-54-88-89-208.compute-1.amazonaws.com

(如果没有-r--raw-output选项,主机名就会被双引号括起来。)输出被保存到hostnames,以便我稍后可以将它传递给parallel

如上所述,parallel采用了ssh来连接到远程机器。如果您想连接到 EC2 实例,而不是每次都键入凭证,那么您可以将类似下面的文本添加到文件~/.ssh/config中。

$ bat ~/.ssh/config
───────┬────────────────────────────────────────────────────────────────────────
       │ File: /home/dst/.ssh/config
───────┼────────────────────────────────────────────────────────────────────────
   1   │ Host *.amazonaws.com
   2   │         IdentityFile ~/.ssh/MyKeyFile.pem
   3   │         User ubuntu
───────┴────────────────────────────────────────────────────────────────────────

根据您运行的发行版,您的用户名可能不同于ubuntu

8.4.2 在远程机器上运行命令

分布式处理的第一种风格是在远程机器上运行普通命令。让我们首先通过在每个 EC2 实例上运行工具hostname来仔细检查一下parallel是否在工作:

$ parallel --nonall --sshloginfile hostnames hostname
ip-172-31-23-204
ip-172-31-23-205

这里,--sshloginfile--slf选项用于引用文件hostnames--nonall选项指示parallel在不使用任何参数的情况下,在hostnames文件中的每台远程机器上执行相同的命令。记住,如果您没有任何远程机器可以利用,您可以用--sshlogin :替换--slf hostnames,这样命令就可以在您的本地机器上运行:

$ parallel --nonall --sshlogin : hostname
data-science-toolbox

在每台远程机器上运行相同的命令一次,每台机器只需要一个内核。如果您想将传入的参数列表分发给parallel,那么它可能会使用多个内核。如果没有明确指定核心的数量,parallel将尝试确定这一点。

$ seq 2 | parallel --slf hostnames echo 2>&1
bash: parallel: command not found
parallel: Warning: Could not figure out number of cpus on ec2-54-88-122-140.comp
ute-1.amazonaws.com (). Using 1.
1
2

在本例中,我在两台远程机器中的一台上安装了parallel。我收到一条警告消息,指出在其中一个上找不到parallel。因此,parallel无法确定核心的数量,将默认使用一个核心。当您收到此警告消息时,您可以执行以下四项操作之一:

  • 不要担心,每台机器使用一个内核会让您很开心
  • 通过--jobs-j选项指定每台机器的工作数量
  • 指定每台机器要使用的内核数量,例如,如果您想要两个内核,可以在hostnames文件中的每个主机名前面加上2/
  • 使用软件包管理器安装parallel。例如,如果远程机器都运行 Ubuntu:
$ parallel --nonall --slf hostnames "sudo apt-get install -y parallel"

8.4.3 在远程机器间分发本地数据

分布式处理的第二种风格是在远程机器之间直接分发本地数据。假设您有一个非常大的数据集,您想使用多台远程机器来处理它。为了简单起见,让我们对 1 到 1000 之间的所有整数求和。首先,让我们通过使用wc打印远程机器的主机名和它接收到的输入的长度,来仔细检查您的输入实际上是被分发的:

$ seq 1000 | parallel -N100 --pipe --slf hostnames "(hostname; wc -l) | paste -s
d:"
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100

非常好。您可以看到您的 1000 个数字平均分布在 100 个子集上(由-N100指定)。现在,您可以对所有这些数字求和了:

$ seq 1000 | parallel -N100 --pipe --slf hostnames "paste -sd+ | bc" | paste -sd

500500

在这里,您还可以立即对从远程机器上获得的 10 笔金额进行求和。让我们通过在没有parallel的情况下进行相同的计算来检查答案是否正确:

$ seq 1000 | paste -sd+ | bc
500500

很好,这很有效。如果您有一个想要在远程机器上执行的更大的管道,您也可以将它放在一个单独的脚本中,并用parallel上传。我将通过创建一个名为add的非常简单的命令行工具来演示这一点:

$ echo '#!/usr/bin/env bash' > add

$ echo 'paste -sd+ | bc' >> add

$ bat add
───────┬────────────────────────────────────────────────────────────────────────
       │ File: add
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash
   2   │ paste -sd+ | bc
───────┴────────────────────────────────────────────────────────────────────────

$ chmod u+x add

$ seq 1000 | ./add
500500

使用--basefile选项,parallel首先将文件上传到所有远程机器,然后运行作业:

$ seq 1000 |
> parallel -N100 --basefile add --pipe --slf hostnames './add' |
> ./add
500500

对 1000 个数求和当然只是一个玩具例子。另外,在本地进行会快得多。尽管如此,我还是希望从这里可以清楚地看到parallel可以变得无比强大。

8.4.4 在远程机器上处理文件

分布式处理的第三种风格是将文件发送到远程机器,处理它们,并检索结果。假设您想统计纽约市每个区接到 311 服务电话的频率。您的本地机器上还没有这些数据,所以让我们首先从免费的 NYC 开放数据 API 中获取这些数据:

$ seq 0 100 900 | parallel  "curl -sL 'http://data.cityofnewyork.us/resource/erm
2-nwe9.json?\$limit=100&\$offset={}' | jq -c '.[]' | gzip > nyc-{#}.json.gz"

现在有 10 个包含压缩 JSON 数据的文件:

$ l nyc*json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:55 nyc-10.json.gz
-rw-r--r-- 1 dst dst 14K Mar  3 10:53 nyc-1.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:53 nyc-2.json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:54 nyc-3.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-4.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:53 nyc-5.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-6.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-7.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-8.json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:54 nyc-9.json.gz

注意,jq -c '.[]'用于展平 JSON 对象的数组,这样每行有一个对象,每个文件总共有 100 行。使用zcat,你直接打印一个压缩文件的内容:

$ zcat nyc-1.json.gz | trim
{"unique_key":"53497809","created_date":"2022-03-02T01:59:41.000","agency":"EDC…
{"unique_key":"53496727","created_date":"2022-03-02T01:59:28.000","agency":"NYP…
{"unique_key":"53501332","created_date":"2022-03-02T01:58:14.000","agency":"NYP…
{"unique_key":"53502331","created_date":"2022-03-02T01:58:12.000","agency":"NYP…
{"unique_key":"53496515","created_date":"2022-03-02T01:56:51.000","agency":"NYP…
{"unique_key":"53501441","created_date":"2022-03-02T01:56:44.000","agency":"NYP…
{"unique_key":"53502239","created_date":"2022-03-02T01:54:11.000","agency":"NYP…
{"unique_key":"53495487","created_date":"2022-03-02T01:54:07.000","agency":"NYP…
{"unique_key":"53497370","created_date":"2022-03-02T01:53:59.000","agency":"NYP…
{"unique_key":"53502342","created_date":"2022-03-02T01:53:01.000","agency":"NYP…
… with 90 more lines

让我们看看一行 JSON 看起来像什么:

$ zcat nyc-1.json.gz | head -n 1
{"unique_key":"53497809","created_date":"2022-03-02T01:59:41.000","agency":"EDC"
,"agency_name":"Economic Development Corporation","complaint_type":"Noise - Heli
copter","descriptor":"Other","location_type":"Above Address","incident_zip":"100
03","incident_address":"103 2 AVENUE","street_name":"2 AVENUE","cross_street_1":
"EAST    6 STREET","cross_street_2":"NICHOLAS FIGUEROA WAY","intersection_street
_1":"EAST    6 STREET","intersection_street_2":"NICHOLAS FIGUEROA WAY","address_
type":"ADDRESS","city":"NEW YORK","landmark":"2 AVENUE","status":"In Progress","
community_board":"03 MANHATTAN","bbl":"1004620030","borough":"MANHATTAN","x_coor
dinate_state_plane":"987442","y_coordinate_state_plane":"204322","open_data_chan
nel_type":"ONLINE","park_facility_name":"Unspecified","park_borough":"MANHATTAN"
,"latitude":"40.7274928080516","longitude":"-73.98848345588063","location":{"lat
itude":"40.7274928080516","longitude":"-73.98848345588063","human_address":"{\"a
ddress\": \"\", \"city\": \"\", \"state\": \"\", \"zip\": \"\"}"},":@computed_re
gion_efsh_h5xi":"11724",":@computed_region_f5dn_yrer":"70",":@computed_region_ye
ji_bk3q":"4",":@computed_region_92fq_4b7q":"50",":@computed_region_sbqj_enih":"5
"}

如果您要获得本地机器上每个区的服务呼叫总数,您可以运行以下命令:

$ zcat nyc*json.gz | # ➊
> jq -r '.borough' | # ➋
> tr '[A-Z] ' '[a-z]_' | # ➌
> sort | uniq -c | sort -nr | # ➍
> awk '{print $2","$1}' | # ➎
> header -a borough,count | # ➏
> csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ queens        │   235 │
│ manhattan     │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

➊ 使用zcat展开所有压缩文件。

➋ 对于每个呼叫,使用jq提取行政区的名称。

➌ 将区名转换成小写,并用下划线替换空格(因为awk默认情况下会在空格上拆分)。

➍ 用sortuniq统计每个区的出现次数。

➎ 反转两列,用逗号分隔,用awk分隔。

➏ 使用header添加表头。

想象一下,您自己的机器非常慢,您根本无法在本地执行这个管道。您可以使用parallel在远程机器之间分发本地文件,让它们进行处理,并检索结果:

$ ls *.json.gz | # ➊
> parallel -v --basefile jq \ # ➋
> --trc {.}.csv \ # ➌
> --slf hostnames \ # ➍
> "zcat {} | ./jq -r '.borough' | tr '[A-Z] ' '[a-z]_' | sort | uniq -c | awk '{
print \$2\",\"\$1}' > {.}.csv" # ➎

➊ 打印文件列表,并通过管道将其输入parallel

➋ 将jq二进制传输到每个远程机器。幸运的是,jq没有附属国。这个文件随后将从远程机器上删除,因为我指定了--trc选项(这意味着--cleanup选项)。注意流水线用的是./jq而不仅仅是jq。这是因为管道需要使用上传的版本,而不是可能在或可能不在搜索路径上的版本。

➌ 命令行参数--trc {.}.csv--transfer --return {.}.csv --cleanup的简称。(替换字符串{.}被没有最后扩展名的输入文件名替换。)在这里,这意味着 JSON 文件被传输到远程机器,CSV 文件被返回到本地机器,并且这两个文件都将在远程机器的每个作业之后被删除

➍ 指定一个主机名列表。记住,如果你想在本地尝试一下,你可以指定--sshlogin :而不是--slf hostnames

➎ 注意awk表达式中的转义。引用有时会很棘手。在这里,美元符号和双引号被转义。如果引用变得太混乱,记得你把管道放到一个单独的命令行工具中,就像我用add做的那样

在这个过程中,如果您在一台远程机器上运行ls,您会看到parallel确实传输(并清理)了二进制文件jq、JSON 文件和 CSV 文件:

$ ssh $(head -n 1 hostnames) ls

每个 CSV 文件看起来都像这样:

> cat nyc-1.json.csv
bronx,3
brooklyn,5
manhattan,24
queens,3
staten_island,2

您可以使用rush和 tidyverse 对每个 CSV 文件中的计数求和:

$ cat nyc*csv | header -a borough,count |
> rush run -t 'group_by(df, borough) %>% summarize(count = sum(count))' - |
> csvsort -rc count | csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ manhattan     │   235 │
│ queens        │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

或者,如果您喜欢使用 SQL 来汇总结果,您可以使用第五章中讨论的csvsql:

$ cat nyc*csv | header -a borough,count |
> csvsql --query 'SELECT borough, SUM(count) AS count FROM stdin GROUP BY boroug
h ORDER BY count DESC' |
> csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ queens        │   235 │
│ manhattan     │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

8.5 总结

作为一名数据科学家,您需要处理数据,有时会处理大量数据。这意味着有时您需要多次运行一个命令,或者将数据密集型命令分布到多个内核上。在本章中,我已经向您展示了并行化命令是多么容易。是一个非常强大和灵活的工具,可以加速普通命令行工具并分发它们。它提供了许多功能,在这一章中,我只能够触及表面。在下一章中,我将介绍 OSEMN 模型的第四步:数据建模。

8.6 进一步探索

  • 一旦你对parallel及其最重要的选项有了基本的了解,我推荐你看看在线教程。您将学习如何指定不同的输入方式,保存所有作业的日志,以及如何超时、恢复和重试作业。正如本教程中 Ole Tange 的创建者所说,“你的命令行会喜欢它的”。# 八、并行管道

原文:https://datascienceatthecommandline.com/2e/chapter-8-parallel-pipelines.html

在前面的章节中,我们一直在处理一次性处理整个任务的命令和管道。然而,在实践中,您可能会发现自己面临一个需要多次运行相同命令或管道的任务。例如,您可能需要:

  • 抓取数百个网页
  • 进行几十次 API 调用并转换它们的输出
  • 为一系列参数值训练分类器
  • 为数据集中的每对特征生成散点图

在上述任何一个例子中,都包含了某种形式的重复。使用您最喜欢的脚本或编程语言,您可以使用for循环或while循环来处理这个问题。在命令行上,您可能倾向于做的第一件事是按下Up来恢复之前的命令,如果需要的话对其进行修改,然后按下Enter来再次运行该命令。这样做两三次没问题,但是想象一下这样做几十次。这种方法很快变得繁琐、低效,并且容易出错。好消息是,您也可以在命令行上编写这样的循环。这就是本章的全部内容。

有时候,一个接一个地重复快速命令(以序列的方式)就足够了。当您拥有多个内核(甚至可能是多台机器)时,如果您能够利用这些内核就好了,尤其是当您面临数据密集型任务时。使用多个内核或机器时,总运行时间可能会显著减少。在这一章中,我将介绍一个非常强大的工具,叫做parallel,它可以处理好这一切。它使您能够对一系列参数(如数字、行和文件)应用命令或管道。另外,顾名思义,它允许您在并行中运行命令。

8.1 概述

本章讨论了几种加速需要多次运行命令和管道的任务的方法。我的主要目标是向你展示parallel的灵活性和力量。因为该工具可以与本书中讨论的任何其他工具相结合,所以它将积极地改变您使用命令行进行数据科学的方式。在本章中,您将了解:

  • 对一系列数字、行和文件串行运行命令
  • 将一个大任务分成几个小任务
  • 并行运行管道
  • 将管道分发到多台机器

本章从以下文件开始:

$ cd /data/ch08

$ l
total 20K
-rw-r--r-- 1 dst dst  126 Mar  3 10:51 emails.txt
-rw-r--r-- 1 dst dst   61 Mar  3 10:51 movies.txt
-rwxr-xr-x 1 dst dst  125 Mar  3 10:51 slow.sh*
-rw-r--r-- 1 dst dst 5.1K Mar  3 10:51 users.json

获取这些文件的说明在第二章中。任何其他文件都是使用命令行工具下载或生成的。

8.2 串行处理

在深入研究并行化之前,我将简要讨论串行循环。知道如何做到这一点是值得的,因为这个功能总是可用的,语法非常类似于其他编程语言中的循环,并且它将真正使您欣赏parallel

从本章介绍中提供的例子中,我们可以提取三种类型的项目进行循环:数字、行和文件。这三种类型的项目将在接下来的三个小节中分别讨论。

8.2.1 数字上的循环

假设您需要计算 0 到 100 之间的每个偶数的平方。有一个叫做bc的工具,这是一个基本计算器,你可以用管道把一个方程。计算 4 的平方的命令如下所示:

$ echo "4^2" | bc
16

对于一次性计算,这就可以了。但是,正如介绍中提到的,你需要疯狂地按下Up,改变数字,并按下Enter 50 次!在这种情况下,最好让 Shell 通过使用for循环来为您完成困难的工作:

$ for i in {0..100..2}  # ➊
> do
> echo "$i^2" | bc      # ➋
> done | trim
0
4
16
36
64
100
144
196
256
324
… with 41 more lines

➊ ZShell 有一个特性叫做大括号扩展,将{0..100..2}转换成一个由空格分隔的列表: 0 2 4 … 98 100。变量i在第一次迭代中赋值0,在第二次迭代中赋值1,依此类推。

➌ 这个变量的值可以通过在它前面加一个美元符号($)来使用。Shell 将在执行echo之前用它的值替换$i。注意在dodone之间可以有多个命令。

虽然与您最喜欢的编程语言相比,语法可能显得有点奇怪,但是值得记住这一点,因为它在 Shell 中总是可用的。稍后我将介绍一种更好、更灵活的重复命令的方式。

8.2.2 行上的循环

第二种可以循环的项目是行。这些行可以来自文件或标准输入。这是一种非常通用的方法,因为这些行可以包含任何内容,包括:数字、日期和电子邮件地址。

假设你想给你所有的联系人发一封电子邮件。让我们首先使用免费的随机用户生成器 API 生成一些假用户:

$ curl -s "https://randomuser.me/api/1.2/?results=5&seed=dsatcl2e" > users.json

$ < users.json jq -r '.results[].email' > emails

$ bat emails
───────┬────────────────────────────────────────────────────────────────────────
       │ File: emails
───────┼────────────────────────────────────────────────────────────────────────
   1   │ selma.andersen@example.com
   2   │ kent.clark@example.com
   3   │ ditmar.niehaus@example.com
   4   │ benjamin.robinson@example.com
   5   │ paulo.muller@example.com
───────┴────────────────────────────────────────────────────────────────────────

你可以用while循环遍历来自emails的行:

$ while read line                         # ➊
> do
> echo "Sending invitation to ${line}."   # ➋
> done < emails                           # ➌
Sending invitation to selma.andersen@example.com.
Sending invitation to kent.clark@example.com.
Sending invitation to ditmar.niehaus@example.com.
Sending invitation to benjamin.robinson@example.com.
Sending invitation to paulo.muller@example.com.

➊ 在这种情况下,您需要使用while循环,因为 ZShell 事先不知道输入包含多少行。
尽管在这种情况下line变量周围的花括号是不必要的(因为变量名不能包含句点),但这仍然是一个好的做法。

➌ 这个重定向也可以放在while之前。

您还可以通过指定特殊的文件标准输入/dev/stdin,以交互方式向while循环提供输入。完成后按Ctrl-D

$ while read line; do echo "You typed: ${line}."; done < /dev/stdin
one
You typed: one.
two
You typed: two.
three
You typed: three.

但是这种方法有一个缺点,就是一旦你按下Enter,那一行输入的dodone之间的命令会立即运行。没有回头路了。

8.2.3 文件上的循环

在这一节中,我将讨论我们经常需要循环的第三种类型的项目:文件。

为了处理特殊字符,使用globbing(即路径名扩展)代替ls :

$ for chapter in /data/*
> do
> echo "Processing Chapter ${chapter}."
> done
Processing Chapter /data/ch02.
Processing Chapter /data/ch03.
Processing Chapter /data/ch04.
Processing Chapter /data/ch05.
Processing Chapter /data/ch06.
Processing Chapter /data/ch07.
Processing Chapter /data/ch08.
Processing Chapter /data/ch09.
Processing Chapter /data/ch10.
Processing Chapter /data/csvconf.

就像大括号展开一样,表达式/data/在被for循环处理之前,首先被 ZShell 展开成一个列表。

清单文件的一个更详细的替代是find,其中:

  • 可以向下遍历目录
  • 允许对诸如大小、访问时间和权限等属性进行详细搜索
  • 处理特殊字符,如空格和换行符

例如,下面的find调用列出了目录/data下扩展名为csv且小于 2kb 的所有文件:

$ find /data -type f -name '*.csv' -size -2k
/data/ch03/tmnt-basic.csv
/data/ch03/tmnt-missing-newline.csv
/data/ch03/tmnt-with-header.csv
/data/ch05/irismeta.csv
/data/ch05/names-comma.csv
/data/ch05/names.csv
/data/ch07/datatypes.csv

8.3 并行处理

假设您有一个运行时间很长的工具,如下所示:

$ bat slow.sh
───────┬────────────────────────────────────────────────────────────────────────
       │ File: slow.sh
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/bin/bash
   2   │ echo "Starting job $1" | ts # ➊
   3   │ duration=$((1+RANDOM%5)) # ➋
   4   │ sleep $duration # ➌
   5   │ echo "Job $1 took ${duration} seconds" | ts
───────┴────────────────────────────────────────────────────────────────────────

ts增加一个时间戳。

➋ 魔法变量RANDOM调用一个内部 Bash 函数,返回一个 0 到 32767 之间的伪随机整数。将该整数除以 5 的余数加上 1 确保了duration在 1 和 5 之间。

sleep暂停执行给定的秒数。

这个过程可能不会占用所有可用的资源。碰巧你需要运行这个命令很多次。例如,您需要下载一系列文件。

一种简单的并行化方法是在后台运行命令。让我们运行slow.sh三次:

$ for i in {A..C}; do
> ./slow.sh $i & # ➊
> done
[2] 385 # ➋
[3] 387
[4] 390

$ Mar 03 10:52:01 Starting job A
Mar 03 10:52:01 Starting job B
Mar 03 10:52:01 Starting job C
Mar 03 10:52:02 Job A took 1 seconds

[2]    done       ./slow.sh $i
$ Mar 03 10:52:04 Job C took 3 seconds

[4]  + done       ./slow.sh $i
$ Mar 03 10:52:05 Job B took 4 seconds

[3]  + done       ./slow.sh $i
$

➊ “与”号(&)将命令发送到后台,允许for循环立即继续下一次迭代。

➋ 这一行显示了 ZShell 给定的作业号和进程 ID,可以用于更细粒度的作业控制。这个话题虽然强大,但超出了本书的范围。

记住并不是所有的东西都可以并行化. API 函数可能只有一个特定的数字, 或者一些命令,只可能有 1 个实例。.

图 8.1 从概念层面上说明了串行处理、简单并行处理和使用 GNU Parallel 的并行处理在并发进程数量和运行所有事务所花费的总时间方面的区别。

<img/030dcbf30b757579bf9115a13b9132b9.png>

图 8.1:串行处理、简单并行处理和使用 GNU Parallel 的并行处理

这种幼稚的方法有两个问题。首先,没有办法控制您同时运行多少个进程。如果您一次启动太多的作业,它们可能会竞争相同的资源,如 CPU、内存、磁盘访问和网络带宽。这可能会导致运行所有程序需要更长的时间。第二,很难区分哪个输出属于哪个输入。让我们看看更好的方法。

8.3.1 GNU Parallel 简介

请允许我介绍一下parallel,这是一个命令行工具,允许您并行化和分发命令和管道。这个工具的美妙之处在于,现有的工具可以原样使用;它们不需要修改。

有 2 个命令行工具有相同的名字parallel. 如果你使用 Docker 镜像那么你已经安装了正确的命令行工具了. 否则, 你可能要运行parallel --version检查下是否安装了正确的版本. 结果应该为GNU parallel

在我深入讨论parallel的细节之前,这里有一个小笑话向你展示替换之前的for循环是多么容易:

$ seq 0 2 100 | parallel "echo {}^2 | bc" | trim
0
4
16
36
64
100
144
196
256
324
… with 41 more lines

这是parallel最简单的形式:要循环的项目通过标准输入传递,除了parallel需要运行的命令之外,没有任何参数。参见图 8.2 了解parallel如何在进程间并发分配输入并收集它们的输出。

<img/d9c2c9e6061706d6cec95f0426a05e89.png>

图 8.2: GNU Parallel 同时在进程间分配输入并收集它们的输出

正如你所看到的,它基本上是一个for循环。这是另一个笑话,它取代了上一节中的for循环。

$ parallel --jobs 2 ./slow.sh ::: {A..C}
Mar 03 10:52:12 Starting job A
Mar 03 10:52:13 Job A took 1 seconds
Mar 03 10:52:12 Starting job B
Mar 03 10:52:16 Job B took 4 seconds
Mar 03 10:52:13 Starting job C
Mar 03 10:52:18 Job C took 4 seconds

这里,使用--jobs选项,我指定parallel最多可以同时运行两个作业。slow.sh的参数被指定为一个参数,而不是通过标准输入。

凭借多达 159 种不同的选项,parallel提供了大量的功能。(也许太多了。幸运的是,你只需要知道一小部分就能有效。如果您需要使用一个不常用的选项,手册页提供了很多信息。

8.3.2 指定输入

parallel最重要的参数是您希望为每个输入运行的命令或管道。问题是:输入项应该插入命令行的什么位置?如果不指定任何内容,那么输入项将被追加到管道的末尾。

$ seq 3 | parallel cowsay

 ___
< 1 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
 ___
< 2 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
 ___
< 3 >
 ---
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

以上与跑步相同:

$ cowsay 1 > /dev/null # ➊

$ cowsay 2 > /dev/null

$ cowsay 3 > /dev/null

➊ 因为输出和之前一样,所以我把它重定向到/dev/null来抑制它。

虽然这通常是可行的,但我建议您通过使用占位符来明确输入项应该插入到命令中的什么位置。在这种情况下,因为您想一次使用整个输入行(一个数字),所以您只需要一个占位符。您用一对花括号({})指定占位符,换句话说,指定输入项的位置:

$ seq 3 | parallel cowsay {} > /dev/null

有其他的方法提供parallel的输入. 我提倡用管道(就像我在整章中做的那样)因为那是大多数命令行工具串联在一起的工具. 另外一个方法是用不常见的语法. 不得不说的是, 它们确实增加了新的功能, 比如遍历多个数组的所有组合, 所以如果想了解更多,读下parallel的帮助手册

当输入项是文件名时,有几个修饰符可以只使用文件名的一部分。例如,使用{/},将只使用文件名的基本名称:

$ find /data/ch03 -type f | parallel echo '{#}\) \"{}\" has basename \"{/}\"' # ➊
1) "/data/ch03/tmnt-basic.csv" has basename "tmnt-basic.csv"
2) "/data/ch03/logs.tar.gz" has basename "logs.tar.gz"
3) "/data/ch03/tmnt-missing-newline.csv" has basename "tmnt-missing-newline.csv"
4) "/data/ch03/r-datasets.db" has basename "r-datasets.db"
5) "/data/ch03/top2000.xlsx" has basename "top2000.xlsx"
6) "/data/ch03/tmnt-with-header.csv" has basename "tmnt-with-header.csv"

➊ 括号())和引号(")等字符在 Shell 中有特殊的含义。要按字面意思使用它们,你要在它们前面加一个反斜杠\。这叫转义

如果输入行有多个由分隔符分隔的部分,您可以向占位符添加数字。例如:

$ < input.csv parallel --colsep , "mv {2} {1}" > /dev/null

在这里,您可以应用相同的占位符修饰符。也可以重用相同的输入项。如果parallel的输入是一个带标题的 CSV 文件,那么您可以使用列名作为占位符:

$ < input.csv parallel -C, --header : "invite {name} {email}"

如果你想知道你的占位符是否设置正确, 你可以加上--dryrun选项. parallel将会打印出所有它将要执行的命令而不是真正的执行它们.

8.3.3 控制并发作业的数量

默认情况下,parallel在每个 CPU 内核上运行一个作业。您可以使用--jobs-j选项控制同时运行的任务数量。指定一个数字意味着许多作业将同时运行。如果你在数字前面加一个加号,那么parallel将运行N个任务加上 CPU 核心的数量。如果你在数字前面加一个减号,那么parallel将运行N-M个任务。其中N是 CPU 内核的数量。您还可以指定一个百分比,默认值为 CPU 核心数的 100%。并发运行的作业的最佳数量取决于您正在运行的实际命令。

$ seq 5 | parallel -j0 "echo Hi {}"
Hi 1
Hi 3
Hi 2
Hi 4
Hi 5
$ seq 5 | parallel -j200% "echo Hi {}"
Hi 1
Hi 2
Hi 3
Hi 4
Hi 5

如果您指定-j1,那么命令将串行运行。即使这不做正义的工具的名称,它仍然有它的用途。例如,当您需要访问一个一次只允许一个连接的 API 时。如果您指定了-j0,那么parallel将会并行运行尽可能多的作业。这可以与您的带&符号的循环相比较。这是不可取的。

8.3.4 日志和输出

为了保存每个命令的输出,您可能会尝试以下操作:

$ seq 5 | parallel "echo \"Hi {}\" > hi-{}.txt"

这将把输出保存到单独的文件中。或者,如果您想将所有内容保存到一个大文件中,您可以执行以下操作:

$ seq 5 | parallel "echo Hi {}" >> one-big-file.txt

然而,parallel提供了--results选项,它将输出存储在单独的文件中。对于每个作业,parallel创建三个文件: seq,保存作业编号,stdout,包含作业产生的输出,stderr,包含作业产生的任何错误。这三个文件根据输入值放在子目录中。

parallel仍然打印所有的输出,在这种情况下是多余的。您可以将标准输入和标准输出重定向到/dev/null,如下所示:

$ seq 10 | parallel --results outdir "curl 'https://anapioficeandfire.com/api/ch
aracters/{}' | jq -r '.aliases[0]'" 2>/dev/null 1>&2

$ tree outdir | trim outdir
└── 1
    ├── 1
    │   ├── seq
    │   ├── stderr
    │   └── stdout
    ├── 10
    │   ├── seq
    │   ├── stderr
    │   └── stdout
… with 34 more lines

参见图 8.3 了解--results选项如何工作的图示概述。

<img/3a5264c5b07ac5121dfc608641e462ff.png>

图 8.3: GNU Parallel 使用--results选项将输出存储在单独的文件中

当您并行运行多个作业时,作业运行的顺序可能与输入的顺序不一致。因此,工作的产出也是混杂的。要保持相同的顺序,请指定--keep-order选项或-k选项。

有时,记录哪个输入生成了哪个输出是很有用的。parallel允许您用--tag选项标记输出,这将为每一行添加输入项。

$ seq 5 | parallel --tag "echo 'sqrt({})' | bc -l"
1       1
2       1.41421356237309504880
3       1.73205080756887729352
4       2.00000000000000000000
5       2.23606797749978969640

$ parallel --tag --keep-order "echo '{1}*{2}' | bc -l" ::: 3 4 ::: 5 6 7
3 5     15
3 6     18
3 7     21
4 5     20
4 6     24
4 7     28

8.3.5 创建并行工具

我在本章开始时使用的bc工具本身并不是并行的。但是,您可以使用parallel将其并行化。Docker 图像包含一个名为pbc的工具。它的代码如下所示:

$ bat $(which pbc)
───────┬────────────────────────────────────────────────────────────────────────
       │ File: /usr/bin/dsutils/pbc
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/bin/bash
   2   │ # pbc: parallel bc. First column of input CSV is mapped to {1}, second
       │ to {2}, and so forth.
   3   │ #
   4   │ # Example usage: paste -d, <(seq 100) <(seq 100 -1 1) | ./pbc 'sqrt({1}
       │ *{2})'
   5   │ #
   6   │ # Dependency: GNU parallel
   7   │ #
   8   │ # Author: http://jeroenjanssens.com
   9   │
  10   │ parallel -C, -k -j100% "echo '$1' | bc -l"
───────┴────────────────────────────────────────────────────────────────────────

这个工具也允许我们简化本章开头使用的代码。它可以同时处理逗号分隔的值:

$ seq 100 | pbc '{1}^2' | trim
1
4
9
16
25
36
49
64
81
100
… with 90 more lines

$ paste -d, <(seq 4) <(seq 4) <(seq 4) | pbc 'sqrt({1}+{2})^{3}'
1.41421356237309504880
4.00000000000000000000
14.69693845669906858905
63.99999999999999999969

8.4 分布式处理

有时你需要比你的本地机器更多的能量,即使它有所有的核心。幸运的是,parallel还可以利用远程机器的能力,这真的可以让你加快流水线的速度。

最棒的是parallel不必安装在远程机器上。所需要的就是你可以用安全 Shell 协议(或 SSH)连接到远程机器,这也是parallel用来分发你的管道的。(安装parallel很有帮助,因为它可以决定在每台远程机器上使用多少内核;稍后将详细介绍。)

首先,我将获得正在运行的 AWS EC2 实例的列表。如果您没有任何远程机器,也不用担心,您可以用--sshlogin :替换任何出现的--slf hostnames,它告诉parallel使用哪个远程机器。这样,您仍然可以遵循本节中的示例。

一旦您知道要接管哪些远程机器,我们将考虑三种类型的分布式处理:

  • 在远程机器上运行普通命令
  • 在远程机器之间直接分发本地数据
  • 将文件发送到远程机器,处理它们,并检索结果

8.4.1 获取正在运行的 AWS EC2 实例列表

在本节中,我们将创建一个名为hostnames的文件,其中每行包含一个远程机器的主机名。我以亚马逊网络服务(AWS)为例。我假设您有一个 AWS 帐户,并且知道如何启动实例。如果你正在使用不同的云计算服务(比如谷歌云平台或微软 Azure),或者如果你有自己的服务器,请确保在继续下一部分之前,你自己创建了一个hostnames文件。

您可以使用 AWS API 的命令行接口aws获得正在运行的 AWS EC2 实例的列表。有了aws,你几乎可以用在线 AWS 管理控制台做所有你能做的事情。

命令aws ec2 describe-instances以 JSON 格式返回关于所有 EC2 实例的大量信息(更多信息请参见在线文档)。您可以使用jq提取相关字段:

$ aws ec2 describe-instances | jq '.Reservations[].Instances[] | {public_dns: .P
ublicDnsName, state: .State.Name}'

EC2 实例的可能状态有: pendingrunningshutting-downterminatedstoppingstopped。因为您只能将管道分发到正在运行的实例,所以您可以按如下方式过滤掉未运行的实例:

> aws ec2 describe-instances | jq -r '.Reservations[].Instances[] | select(.Stat
e.Name=="running") | .PublicDnsName' | tee hostnames
ec2-54-88-122-140.compute-1.amazonaws.com
ec2-54-88-89-208.compute-1.amazonaws.com

(如果没有-r--raw-output选项,主机名就会被双引号括起来。)输出被保存到hostnames,以便我稍后可以将它传递给parallel

如上所述,parallel采用了ssh来连接到远程机器。如果您想连接到 EC2 实例,而不是每次都键入凭证,那么您可以将类似下面的文本添加到文件~/.ssh/config中。

$ bat ~/.ssh/config
───────┬────────────────────────────────────────────────────────────────────────
       │ File: /home/dst/.ssh/config
───────┼────────────────────────────────────────────────────────────────────────
   1   │ Host *.amazonaws.com
   2   │         IdentityFile ~/.ssh/MyKeyFile.pem
   3   │         User ubuntu
───────┴────────────────────────────────────────────────────────────────────────

根据您运行的发行版,您的用户名可能不同于ubuntu

8.4.2 在远程机器上运行命令

分布式处理的第一种风格是在远程机器上运行普通命令。让我们首先通过在每个 EC2 实例上运行工具hostname来仔细检查一下parallel是否在工作:

$ parallel --nonall --sshloginfile hostnames hostname
ip-172-31-23-204
ip-172-31-23-205

这里,--sshloginfile--slf选项用于引用文件hostnames--nonall选项指示parallel在不使用任何参数的情况下,在hostnames文件中的每台远程机器上执行相同的命令。记住,如果您没有任何远程机器可以利用,您可以用--sshlogin :替换--slf hostnames,这样命令就可以在您的本地机器上运行:

$ parallel --nonall --sshlogin : hostname
data-science-toolbox

在每台远程机器上运行相同的命令一次,每台机器只需要一个内核。如果您想将传入的参数列表分发给parallel,那么它可能会使用多个内核。如果没有明确指定核心的数量,parallel将尝试确定这一点。

$ seq 2 | parallel --slf hostnames echo 2>&1
bash: parallel: command not found
parallel: Warning: Could not figure out number of cpus on ec2-54-88-122-140.comp
ute-1.amazonaws.com (). Using 1.
1
2

在本例中,我在两台远程机器中的一台上安装了parallel。我收到一条警告消息,指出在其中一个上找不到parallel。因此,parallel无法确定核心的数量,将默认使用一个核心。当您收到此警告消息时,您可以执行以下四项操作之一:

  • 不要担心,每台机器使用一个内核会让您很开心
  • 通过--jobs-j选项指定每台机器的工作数量
  • 指定每台机器要使用的内核数量,例如,如果您想要两个内核,可以在hostnames文件中的每个主机名前面加上2/
  • 使用软件包管理器安装parallel。例如,如果远程机器都运行 Ubuntu:
$ parallel --nonall --slf hostnames "sudo apt-get install -y parallel"

8.4.3 在远程机器间分发本地数据

分布式处理的第二种风格是在远程机器之间直接分发本地数据。假设您有一个非常大的数据集,您想使用多台远程机器来处理它。为了简单起见,让我们对 1 到 1000 之间的所有整数求和。首先,让我们通过使用wc打印远程机器的主机名和它接收到的输入的长度,来仔细检查您的输入实际上是被分发的:

$ seq 1000 | parallel -N100 --pipe --slf hostnames "(hostname; wc -l) | paste -s
d:"
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100
ip-172-31-23-205:100
ip-172-31-23-204:100

非常好。您可以看到您的 1000 个数字平均分布在 100 个子集上(由-N100指定)。现在,您可以对所有这些数字求和了:

$ seq 1000 | parallel -N100 --pipe --slf hostnames "paste -sd+ | bc" | paste -sd

500500

在这里,您还可以立即对从远程机器上获得的 10 笔金额进行求和。让我们通过在没有parallel的情况下进行相同的计算来检查答案是否正确:

$ seq 1000 | paste -sd+ | bc
500500

很好,这很有效。如果您有一个想要在远程机器上执行的更大的管道,您也可以将它放在一个单独的脚本中,并用parallel上传。我将通过创建一个名为add的非常简单的命令行工具来演示这一点:

$ echo '#!/usr/bin/env bash' > add

$ echo 'paste -sd+ | bc' >> add

$ bat add
───────┬────────────────────────────────────────────────────────────────────────
       │ File: add
───────┼────────────────────────────────────────────────────────────────────────
   1   │ #!/usr/bin/env bash
   2   │ paste -sd+ | bc
───────┴────────────────────────────────────────────────────────────────────────

$ chmod u+x add

$ seq 1000 | ./add
500500

使用--basefile选项,parallel首先将文件上传到所有远程机器,然后运行作业:

$ seq 1000 |
> parallel -N100 --basefile add --pipe --slf hostnames './add' |
> ./add
500500

对 1000 个数求和当然只是一个玩具例子。另外,在本地进行会快得多。尽管如此,我还是希望从这里可以清楚地看到parallel可以变得无比强大。

8.4.4 在远程机器上处理文件

分布式处理的第三种风格是将文件发送到远程机器,处理它们,并检索结果。假设您想统计纽约市每个区接到 311 服务电话的频率。您的本地机器上还没有这些数据,所以让我们首先从免费的 NYC 开放数据 API 中获取这些数据:

$ seq 0 100 900 | parallel  "curl -sL 'http://data.cityofnewyork.us/resource/erm
2-nwe9.json?\$limit=100&\$offset={}' | jq -c '.[]' | gzip > nyc-{#}.json.gz"

现在有 10 个包含压缩 JSON 数据的文件:

$ l nyc*json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:55 nyc-10.json.gz
-rw-r--r-- 1 dst dst 14K Mar  3 10:53 nyc-1.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:53 nyc-2.json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:54 nyc-3.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-4.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:53 nyc-5.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-6.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-7.json.gz
-rw-r--r-- 1 dst dst 15K Mar  3 10:54 nyc-8.json.gz
-rw-r--r-- 1 dst dst 16K Mar  3 10:54 nyc-9.json.gz

注意,jq -c '.[]'用于展平 JSON 对象的数组,这样每行有一个对象,每个文件总共有 100 行。使用zcat,你直接打印一个压缩文件的内容:

$ zcat nyc-1.json.gz | trim
{"unique_key":"53497809","created_date":"2022-03-02T01:59:41.000","agency":"EDC…
{"unique_key":"53496727","created_date":"2022-03-02T01:59:28.000","agency":"NYP…
{"unique_key":"53501332","created_date":"2022-03-02T01:58:14.000","agency":"NYP…
{"unique_key":"53502331","created_date":"2022-03-02T01:58:12.000","agency":"NYP…
{"unique_key":"53496515","created_date":"2022-03-02T01:56:51.000","agency":"NYP…
{"unique_key":"53501441","created_date":"2022-03-02T01:56:44.000","agency":"NYP…
{"unique_key":"53502239","created_date":"2022-03-02T01:54:11.000","agency":"NYP…
{"unique_key":"53495487","created_date":"2022-03-02T01:54:07.000","agency":"NYP…
{"unique_key":"53497370","created_date":"2022-03-02T01:53:59.000","agency":"NYP…
{"unique_key":"53502342","created_date":"2022-03-02T01:53:01.000","agency":"NYP…
… with 90 more lines

让我们看看一行 JSON 看起来像什么:

$ zcat nyc-1.json.gz | head -n 1
{"unique_key":"53497809","created_date":"2022-03-02T01:59:41.000","agency":"EDC"
,"agency_name":"Economic Development Corporation","complaint_type":"Noise - Heli
copter","descriptor":"Other","location_type":"Above Address","incident_zip":"100
03","incident_address":"103 2 AVENUE","street_name":"2 AVENUE","cross_street_1":
"EAST    6 STREET","cross_street_2":"NICHOLAS FIGUEROA WAY","intersection_street
_1":"EAST    6 STREET","intersection_street_2":"NICHOLAS FIGUEROA WAY","address_
type":"ADDRESS","city":"NEW YORK","landmark":"2 AVENUE","status":"In Progress","
community_board":"03 MANHATTAN","bbl":"1004620030","borough":"MANHATTAN","x_coor
dinate_state_plane":"987442","y_coordinate_state_plane":"204322","open_data_chan
nel_type":"ONLINE","park_facility_name":"Unspecified","park_borough":"MANHATTAN"
,"latitude":"40.7274928080516","longitude":"-73.98848345588063","location":{"lat
itude":"40.7274928080516","longitude":"-73.98848345588063","human_address":"{\"a
ddress\": \"\", \"city\": \"\", \"state\": \"\", \"zip\": \"\"}"},":@computed_re
gion_efsh_h5xi":"11724",":@computed_region_f5dn_yrer":"70",":@computed_region_ye
ji_bk3q":"4",":@computed_region_92fq_4b7q":"50",":@computed_region_sbqj_enih":"5
"}

如果您要获得本地机器上每个区的服务呼叫总数,您可以运行以下命令:

$ zcat nyc*json.gz | # ➊
> jq -r '.borough' | # ➋
> tr '[A-Z] ' '[a-z]_' | # ➌
> sort | uniq -c | sort -nr | # ➍
> awk '{print $2","$1}' | # ➎
> header -a borough,count | # ➏
> csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ queens        │   235 │
│ manhattan     │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

➊ 使用zcat展开所有压缩文件。

➋ 对于每个呼叫,使用jq提取行政区的名称。

➌ 将区名转换成小写,并用下划线替换空格(因为awk默认情况下会在空格上拆分)。

➍ 用sortuniq统计每个区的出现次数。

➎ 反转两列,用逗号分隔,用awk分隔。

➏ 使用header添加表头。

想象一下,您自己的机器非常慢,您根本无法在本地执行这个管道。您可以使用parallel在远程机器之间分发本地文件,让它们进行处理,并检索结果:

$ ls *.json.gz | # ➊
> parallel -v --basefile jq \ # ➋
> --trc {.}.csv \ # ➌
> --slf hostnames \ # ➍
> "zcat {} | ./jq -r '.borough' | tr '[A-Z] ' '[a-z]_' | sort | uniq -c | awk '{
print \$2\",\"\$1}' > {.}.csv" # ➎

➊ 打印文件列表,并通过管道将其输入parallel

➋ 将jq二进制传输到每个远程机器。幸运的是,jq没有附属国。这个文件随后将从远程机器上删除,因为我指定了--trc选项(这意味着--cleanup选项)。注意流水线用的是./jq而不仅仅是jq。这是因为管道需要使用上传的版本,而不是可能在或可能不在搜索路径上的版本。

➌ 命令行参数--trc {.}.csv--transfer --return {.}.csv --cleanup的简称。(替换字符串{.}被没有最后扩展名的输入文件名替换。)在这里,这意味着 JSON 文件被传输到远程机器,CSV 文件被返回到本地机器,并且这两个文件都将在远程机器的每个作业之后被删除

➍ 指定一个主机名列表。记住,如果你想在本地尝试一下,你可以指定--sshlogin :而不是--slf hostnames

➎ 注意awk表达式中的转义。引用有时会很棘手。在这里,美元符号和双引号被转义。如果引用变得太混乱,记得你把管道放到一个单独的命令行工具中,就像我用add做的那样

在这个过程中,如果您在一台远程机器上运行ls,您会看到parallel确实传输(并清理)了二进制文件jq、JSON 文件和 CSV 文件:

$ ssh $(head -n 1 hostnames) ls

每个 CSV 文件看起来都像这样:

> cat nyc-1.json.csv
bronx,3
brooklyn,5
manhattan,24
queens,3
staten_island,2

您可以使用rush和 tidyverse 对每个 CSV 文件中的计数求和:

$ cat nyc*csv | header -a borough,count |
> rush run -t 'group_by(df, borough) %>% summarize(count = sum(count))' - |
> csvsort -rc count | csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ manhattan     │   235 │
│ queens        │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

或者,如果您喜欢使用 SQL 来汇总结果,您可以使用第五章中讨论的csvsql:

$ cat nyc*csv | header -a borough,count |
> csvsql --query 'SELECT borough, SUM(count) AS count FROM stdin GROUP BY boroug
h ORDER BY count DESC' |
> csvlook
│ borough       │ count │
├───────────────┼───────┤
│ brooklyn      │   300 │
│ queens        │   235 │
│ manhattan     │   235 │
│ bronx         │   191 │
│ staten_island │    38 │
│ unspecified   │     1 │

8.5 总结

作为一名数据科学家,您需要处理数据,有时会处理大量数据。这意味着有时您需要多次运行一个命令,或者将数据密集型命令分布到多个内核上。在本章中,我已经向您展示了并行化命令是多么容易。是一个非常强大和灵活的工具,可以加速普通命令行工具并分发它们。它提供了许多功能,在这一章中,我只能够触及表面。在下一章中,我将介绍 OSEMN 模型的第四步:数据建模。

8.6 进一步探索

  • 一旦你对parallel及其最重要的选项有了基本的了解,我推荐你看看在线教程。您将学习如何指定不同的输入方式,保存所有作业的日志,以及如何超时、恢复和重试作业。正如本教程中 Ole Tange 的创建者所说,“你的命令行会喜欢它的”。

虽然rush plot适合于在探索数据时创建基本的图表,但它肯定有其局限性。有时您需要更多的灵活性和复杂的选项,如多种几何图形、坐标转换和主题化。在这种情况下,可能值得了解更多关于rush plot利用其功能的底层包,即用于 R 的ggplot2包。当你对 Python 比对 R 更感兴趣时,还有plotnine,它是用于 Python 的ggplot2的重新实现。

7.5 总结

在这一章中,我们已经研究了探索数据的各种方法。文本和图形数据可视化各有利弊。图形的质量显然要高得多,但是在命令行中查看可能有些棘手。这就是文本可视化派上用场的地方。由于有了Rggplot2,至少rush有了创建这两种类型的一致语法。

下一章又是一个间奏曲章节,在这一章中,我将讨论如何提高命令和管道的速度。如果您迫不及待地想在第九章中开始对数据建模,请稍后阅读该章。

7.6 进一步探索

  • 不幸的是,一本合适的教程超出了本书的范围。如果你想更好地可视化你的数据,我强烈建议你花一些时间去理解图形语法的力量和美丽。由 Hadley Wickham 和 Garrett Grolemund 所著的《面向数据科学的 R》一书的第三章和第 28 章是很好的参考资料。
    ** 说到第三章和第 28 章,我用 Plotnine 和 Pandas 把它们翻译成了 Python,以防你对 Python 比对 R 更感兴趣。
posted @ 2023-03-30 12:12  绝不原创的飞龙  阅读(75)  评论(0)    收藏  举报