Unix/Perl/Python:大数据集上的替代列表

Unix/Perl/Python: substitute list on big data set

我有一个包含大约 13491 key/value 对的映射文件,我需要用它来将密钥替换为数据集中大约 500000 行的值,这些数据集分为 25 个不同的文件。

映射示例: value1,value2

示例输入:field1,field2,**value1**,field4

示例输出:field1,field2,**value2**,field4

请注意,该值可能位于出现次数超过 1 次的行中的不同位置。

我目前的方法是使用 AWK:

awk -F, 'NR==FNR { a[]= ; next } { for (i in a) gsub(i, a[i]); print }' mapping.txt file1.txt > file1_mapped.txt

但是,这需要很长时间。

有没有其他方法可以加快速度?可以使用多种工具(Unix、AWK、Sed、Perl、Python 等)

注意 使用Text::CSV模块解析文件的版本见第二部分


将映射加载到散列(字典)中,然后遍历您的文件并测试每个字段以了解散列中是否有这样的键,如果有则替换为值。将每一行写到一个临时文件中,完成后将其移动到一个新文件中(或覆盖已处理的文件)。任何工具或多或少都必须这样做。

使用 Perl,测试了一些小 made-up 文件

use warnings;
use strict;
use feature 'say';

use File::Copy qw(move);

my $file = shift;
die "Usage: [=10=] mapping-file data-files\n"  if not $file or not @ARGV;

my %map;
open my $fh, '<', $file or die "Can't open $file: $!";
while (<$fh>) { 
    my ($key, $val) = map { s/^\s+|\s+$//gr } split /\s*,\s*/;  # see Notes
    $map{$key} = $val;
}

my $outfile = "tmp.outfile.txt.$$";  # but better use File::Temp

foreach my $file (@ARGV) {
    open my $fh_out, '>', $outfile or die "Can't open $outfile: $!";
    open my $fh,     '<', $file    or die "Can't open $file: $!";
    while (<$fh>) {
        s/^\s+|\s+$//g;               # remove leading/trailing whitespace
        my @fields = split /\s*,\s*/;
        exists($map{$_}) && ($_=$map{$_}) for @fields;  # see Notes
        say $fh_out join ',', @fields;
    }   
    close $fh_out;

    # Change to commented out line once thoroughly tested
    #move($outfile, $file) or die "can't move $outfile to $file: $!";
    move($outfile, 'new_'.$file) or die "can't move $outfile: $!";
}

注释。

  • 针对映射的数据检查是为了提高效率而编写的:我们必须查看每个字段,这是不可避免的,但是我们只检查字段作为键(没有正则表达式)。为此,需要删除所有 leading/trailing 空格。因此这段代码可能会改变输出数据文件中的空格;如果出于某种原因这很重要,当然可以对其进行修改以保留原始空间。

  • 评论中提到数据中的字段实际上可以不同,因为有额外的引号。然后先提取would-be键

      for (@fields) {
          $_ = $map{}  if /"?([^"]*)/ and exists $map{};
      }
    

    这会在每次检查时启动正则表达式引擎,这会影响效率。这将有助于清理引号的输入 CSV 数据,并且 运行 使用上面的代码,没有正则表达式。这可以通过使用 CSV-parsing 模块读取文件来完成;评论见文末

  • 对于早于 5.14 的 Perls 替换

      my ($key, $val) = map { s/^\s+|\s+$//gr } split /\s*,\s*/;
    

      my ($key, $val) = map { s/^\s+|\s+$//g; $_ } split /\s*,\s*/;
    

    因为仅引入了“non-destructive”/r修饰符in v5.14

  • 如果您希望您的整个操作不会因为一个错误文件而死亡,请将 or die ... 替换为

      or do { 
          # print warning for whatever failed (warn "Can't open $file: $!";)
          # take care of filehandles and such if/as needed
          next;
      };
    

    并确保(可能记录和)查看输出。

这为一些效率改进留下了空间,但没有什么戏剧性的。


用逗号分隔字段的数据可能(也可能不是)有效的 CSV。由于问题根本没有解决这个问题,也没有报告问题,因此数据文件中不太可能使用 CSV 数据格式的任何属性(数据中嵌入的定界符、受保护的引号)。

但是,使用支持完整 CSV 的模块(如 Text::CSV)读取这些文件仍然是个好主意。通过处理额外的空格和引号并交给我们 cleaned-up 字段,这也使事情变得更容易。就是这样——和上面一样,但是使用模块来解析文件

use warnings;
use strict;
use feature 'say';
use File::Copy qw(move);

use Text::CSV;

my $file = shift;
die "Usage: [=15=] mapping-file data-files\n"  if not $file or not @ARGV;
    
my $csv = Text::CSV->new ( { binary => 1, allow_whitespace => 1 } ) 
    or die "Cannot use CSV: " . Text::CSV->error_diag ();

my %map;
open my $fh, '<', $file or die "Can't open $file: $!";
while (my $line = $csv->getline($fh)) {
    $map{ $line->[0] } = $line->[1]
}

my $outfile = "tmp.outfile.txt.$$";  # use File::Temp    

foreach my $file (@ARGV) {
    open my $fh_out, '>', $outfile or die "Can't open $outfile: $!";
    open my $fh,     '<', $file    or die "Can't open $file: $!";
    while (my $line = $csv->getline($fh)) {
        exists($map{$_}) && ($_=$map{$_}) for @$line;
        say $fh_out join ',', @$line;
    }
    close $fh_out;

    move($outfile, 'new_'.$file) or die "Can't move $outfile: $!";
}

现在我们根本不必担心空格或整体引号,这让事情变得简单了一点。

虽然在没有实际数据文件的情况下很难可靠地比较这两种方法,但我对涉及“相似”处理的 (made-up) 大数据文件进行了基准测试。使用 Text::CSV 解析 运行 的代码大致相同,或者(最多)快 50%。

构造函数选项 allow_whitespace makes it remove extra spaces, perhaps contrary to what the name may imply, as I do by hand above. (Also see allow_loose_quotes and related options.) There is far more, see docs. The Text::CSV defaults to Text::CSV_XS,如果已安装。

下面有一些评论表明 OP 需要处理真实的 CSV 数据,而问题是:

Please note that the value could be in different places on the line with more than 1 occurrence.

我认为这意味着这些是行,而不是 CSV 数据,并且需要 regex-based 解决方案。 OP还在上面的评论中确认了这一解释。

但是,如其他答案中所述,将数据分解为多个字段并简单地在地图中查找替换项会更快。

#!/usr/bin/env perl

use strict;
use warnings;

# Load mappings.txt into a Perl
# Hash %m.
#
open my $mh, '<', './mappings.txt'
  or die "open: $!";

my %m = ();
while ($mh) {
  chomp;
  my @f = split ',';
  $m{$f[0]} = $f[1];
}

# Load files.txt into a Perl
# Array @files.
#
open my $fh, '<', './files.txt';
chomp(my @files = $fh);

# Update each file line by line,
# using a temporary file similar
# to sed -i.
#
foreach my $file (@files) {

  open my $fh, '<', $file
    or die "open: $!";
  open my $th, '>', "$file.bak"
    or die "open: $!";

  while ($fh) {
    foreach my $k (keys %m) {
      my $v = $m[$k];
      s/\Q$k/$v/g;
    }
    print $th;
  }

  rename "$file.bak", $file
    or die "rename: $!";
}

我当然假设您在 mappings.txt 中有映射,在 files.txt 中有文件列表。

根据您的评论,您有正确的 CSV。当从映射文件读取、从数据文件读取和写入数据文件时,以下正确处理引号和转义。

您似乎想要匹配整个字段。以下是这样做的。它甚至支持包含逗号 (,) and/or 引号 (") 的字段。它使用哈希查找进行比较,这比正则表达式匹配快得多。

#!/usr/bin/perl
use strict;
use warnings;
use feature qw( say );

use Text::CSV_XS qw( );

my $csv = Text::CSV_XS->new({ auto_diag => 2, binary => 1 });

sub process {
   my ($map, $in_fh, $out_fh) = @_;
   while ( my $row = $csv->getline($in_fh) ) {
      $csv->say($out_fh, [ map { $map->{$_} // $_ } @$row ]);
   }
}

die "usage: [=10=] {map} [{file} [...]]\n"
   if @ARGV < 1;

my $map_qfn = shift;

my %map;
{
   open(my $fh, '<', $map_qfn)
      or die("Can't open \"$map_qfn\": $!\n");
   while ( my $row = $csv->getline($fh) ) {
      $map{$row->[0]} = $row->[1];
   }
}

if (@ARGV) {
   for my $qfn (@ARGV) {
      open(my $in_fh, '<', $qfn)
         or warn("Can't open \"$qfn\": $!\n"), next;
      rename($qfn, $qfn."~")
         or warn("Can't rename \"$qfn\": $!\n"), next;
      open(my $out_fh, '>', $qfn)
         or warn("Can't create \"$qfn\": $!\n"), next;
      eval { process(\%map, $in_fh, $out_fh); 1 }
         or warn("Error processing \"$qfn\": $@"), next;
      close($out_fh)
         or warn("Error writing to \"$qfn\": $!\n"), next;
   }
} else {
   eval { process(\%map, \*STDIN, \*STDOUT); 1 }
      or warn("Error processing: $@");
   close(\*STDOUT)
      or warn("Error writing to STDOUT: $!\n");
}

如果您不提供地图文件以外的文件名,它会从 STDIN 读取并输出到 STDOUT。

如果您在地图文件之外提供一个或多个文件名,它会替换文件 in-place(尽管会留下备份)。

您在 500,000 条输入行中的每一行都执行了 13,491 gsub()s - 总共将近 70 亿条 full-line 正则表达式 search/replaces。所以是的,这需要一些时间,而且它几乎肯定会以您没有注意到的方式破坏您的数据,因为一个 gsub() 的结果被下一个 gsub() 更改 and/or 你得到部分替换!

我在评论中看到,您的某些字段可以用双引号引起来。如果这些字段不能包含逗号或换行符,并且假设您想要完整的字符串匹配,那么这就是如何编写它:

$ cat tst.awk
BEGIN { FS=OFS="," }
NR==FNR {
    map[] = 
    map["\"""\""] = "\"""\""
    next
}
{
    for (i=1; i<=NF; i++) {
        if ($i in map) {
            $i = map[$i]
        }
    }
    print
}

我在一个有 13,500 个条目的映射文件和一个 500,000 行的输入文件上测试了上面的内容,在我动力不足的笔记本电脑上的 cygwin 中的大多数行上有多个匹配,它在大约 1 秒内完成:

$ wc -l mapping.txt
13500 mapping.txt

$ wc -l file500k
500000 file500k

$ time awk -f tst.awk mapping.txt file500k > /dev/null
real    0m1.138s
user    0m1.109s
sys     0m0.015s

如果这不能有效地满足您的需求,请编辑您的问题以提供 MCVE and clearer requirements, see