6 votes

Perl fusionne 2 fichiers csv ligne par ligne avec une clé primaire

Edit : solution ajoutée.

Bonjour, j'ai actuellement un code qui fonctionne, bien que lent.

Il fusionne 2 CSV les fichiers ligne par ligne en utilisant une clé primaire. Par exemple, si le fichier 1 a la ligne :

"one,two,,four,42"

et le fichier 2 a cette ligne ;

"one,,three,,42"

où dans 0 indexé $position = 4 a la clé primaire = 42 ;

puis le sous : merge_file($file1,$file2,$outputfile,$position) ;

produira un fichier avec la ligne :

"one,two,three,four,42";

Chaque clé primaire est unique dans chaque fichier, et une clé peut exister dans un fichier mais pas dans l'autre (et vice versa).

Il y a environ 1 million de lignes dans chaque fichier.

En parcourant chaque ligne du premier fichier, j'utilise un hash pour stocker la clé primaire, et je stocke le numéro de ligne comme valeur. Le numéro de ligne correspond à un tableau [line num] qui stocke chaque ligne du premier fichier.

Ensuite, je passe en revue chaque ligne du deuxième fichier, et je vérifie si la clé primaire est dans le hachage, et si c'est le cas, je récupère la ligne du tableau file1 et j'ajoute les colonnes dont j'ai besoin du premier tableau au deuxième tableau, puis je les concatène à la fin. Ensuite, je supprime le hachage et, à la toute fin, j'envoie le tout au fichier. (J'utilise un SSD donc je veux minimiser les écritures dans le fichier).

La meilleure façon de l'expliquer est probablement d'utiliser un code :

sub merge_file2{
 my ($file1,$file2,$out,$position) = ($_[0],$_[1],$_[2],$_[3]);
 print "merging: \n$file1 and \n$file2, to: \n$out\n";
 my $OUTSTRING = undef;

 my %line_for;
 my @file1array;
 open FILE1, "<$file1";
 print "$file1 opened\n";
 while (<FILE1>){
      chomp;
      $line_for{read_csv_string($_,$position)}=$.; #reads csv line at current position (of key)
      $file1array[$.] = $_; #store line in file1array.
 }
 close FILE1;
 print "$file2 opened - merging..\n";
 open FILE2, "<", $file2;
 my @from1to2 = qw( 2 4 8 17 18 19); #which columns from file 1 to be added into cols. of file 2.
 while (<FILE2>){
      print "$.\n" if ($.%1000) == 0;
      chomp;
      my @array1 = ();
      my @array2 = ();
      my @array2 = split /,/, $_; #split 2nd csv line by commas

      my @array1 = split /,/, $file1array[$line_for{$array2[$position]}];
      #                            ^         ^                  ^
      # prev line  lookup line in 1st file,lookup hash,     pos of key
      #my @output = &merge_string(\@array1,\@array2); #merge 2 csv strings (old fn.)

      foreach(@from1to2){
           $array2[$_] = $array1[$_];
      }
      my $outstring = join ",", @array2;
      $OUTSTRING.=$outstring."\n";
      delete $line_for{$array2[$position]};
 }
 close FILE2;
 print "adding rest of lines\n";
 foreach my $key (sort { $a <=> $b } keys %line_for){
      $OUTSTRING.= $file1array[$line_for{$key}]."\n";
 }

 print "writing file $out\n\n\n";
 write_line($out,$OUTSTRING);
}

La première boucle while fonctionne bien et prend moins d'une minute, mais la deuxième boucle while prend environ une heure à s'exécuter et je me demande si j'ai adopté la bonne approche. Je pense qu'il est possible de gagner beaucoup en vitesse :) Merci d'avance.


Solution :

sub merge_file3{
my ($file1,$file2,$out,$position,$hsize) = ($_[0],$_[1],$_[2],$_[3],$_[4]);
print "merging: \n$file1 and \n$file2, to: \n$out\n";
my $OUTSTRING = undef;
my $header;

my (@file1,@file2);
open FILE1, "<$file1" or die;
while (<FILE1>){
    if ($.==1){
        $header = $_;
        next;
    }
    print "$.\n" if ($.%100000) == 0;
    chomp;
    push @file1, [split ',', $_];
}
close FILE1;

open FILE2, "<$file2" or die;
while (<FILE2>){
    next if $.==1;
    print "$.\n" if ($.%100000) == 0;
    chomp;
    push @file2, [split ',', $_];
}
close FILE2;

print "sorting files\n";
my @sortedf1 = sort {$a->[$position] <=> $b->[$position]} @file1;
my @sortedf2 = sort {$a->[$position] <=> $b->[$position]} @file2;   
print "sorted\n";
@file1 = undef;
@file2 = undef;
#foreach my $line (@file1){print "\t [ @$line ],\n";    }

my ($i,$j) = (0,0);
while ($i < $#sortedf1 and $j < $#sortedf2){
    my $key1 = $sortedf1[$i][$position];
    my $key2 = $sortedf2[$j][$position];
    if ($key1 eq $key2){
        foreach(0..$hsize){ #header size.
            $sortedf2[$j][$_] = $sortedf1[$i][$_] if $sortedf1[$i][$_] ne undef;
        }
        $i++;
        $j++;
    }
    elsif ( $key1 < $key2){
        push(@sortedf2,[@{$sortedf1[$i]}]);
        $i++;
    }
    elsif ( $key1 > $key2){ 
        $j++;
    }
}

#foreach my $line (@sortedf2){print "\t [ @$line ],\n"; }

print "outputting to file\n";
open OUT, ">$out";
print OUT $header;
foreach(@sortedf2){
    print OUT (join ",", @{$_})."\n";
}
close OUT;

}

Merci à tous, la solution est affichée ci-dessus. Il faut maintenant environ 1 minute pour fusionner le tout ! :)

4voto

hobbs Points 71946

Deux techniques me viennent à l'esprit.

  1. Lire les données des fichiers CSV dans deux tables d'un SGBD (SQLite conviendrait parfaitement), puis utiliser le SGBD pour effectuer une jointure et réécrire les données en CSV. La base de données utilisera des index pour optimiser la jointure.

  2. Tout d'abord, triez chaque fichier par clé primaire (en utilisant perl ou unix sort ), puis effectuer un balayage linéaire sur chaque fichier en parallèle (lire un enregistrement de chaque fichier ; si les clés sont égales, sortir une ligne jointe et faire avancer les deux fichiers ; si les clés ne sont pas égales, faire avancer le fichier avec la clé la plus petite et réessayer). Cette étape prend O(n + m) au lieu de O(n * m), et O(1) pour la mémoire.

3voto

FMc Points 22663

Ce qui tue la performance est ce code, qui concatène des millions de fois.

$OUTSTRING.=$outstring."\n";

....

foreach my $key (sort { $a <=> $b } keys %line_for){
    $OUTSTRING.= $file1array[$line_for{$key}]."\n";
}

Si vous souhaitez n'écrire qu'une seule fois dans le fichier de sortie, accumulez vos résultats dans un tableau, puis imprimez-les à la toute fin, en utilisant la méthode suivante join . Ou, mieux encore, inclure les nouvelles lignes dans les résultats et écrire le tableau directement.

Pour voir comment la concaténation n'est pas adaptée au traitement des données volumineuses, expérimentez avec ce script de démonstration. Lorsque vous l'exécutez dans concat les choses commencent à ralentir considérablement après quelques centaines de milliers de concaténations - j'ai abandonné et tué le script. En revanche, la simple impression d'un tableau d'un million de lignes a pris moins d'une minute sur ma machine.

# Usage: perl demo.pl 50 999999 concat|join|direct
use strict;
use warnings;

my ($line_len, $n_lines, $method) = @ARGV;
my @data = map { '_' x $line_len . "\n" } 1 .. $n_lines;

open my $fh, '>', 'output.txt' or die $!;

if ($method eq 'concat'){         # Dog slow. Gets slower as @data gets big.
    my $outstring;
    for my $i (0 .. $#data){
        print STDERR $i, "\n" if $i % 1000 == 0;
        $outstring .= $data[$i];
    }
    print $fh $outstring;
}
elsif ($method eq 'join'){        # Fast
    print $fh join('', @data);
}
else {                            # Fast
    print $fh @data;
}

1voto

Daniel Martin Points 9148

Je ne vois rien qui me semble manifestement lent, mais je ferais ces changements :

  • D'abord, j'éliminerais le @file1array variable. Vous n'en avez pas besoin ; stockez simplement la ligne elle-même dans le hash :

    while (<FILE1>){
         chomp;
         $line_for{read_csv_string($_,$position)}=$_;
    }
  • Deuxièmement, bien que cela ne devrait pas vraiment faire de différence avec perl, je n'ajouterais pas à $OUTSTRING tout le temps. Au lieu de cela, gardez un tableau de lignes de sortie et push sur elle à chaque fois. Si pour une raison quelconque, vous devez quand même appeler write_line avec une chaîne massive, vous pouvez toujours utiliser join('', @OUTLINES) à la fin.

  • Si write_line n'utilise pas syswrite ou quelque chose de bas niveau comme ça, mais utilise plutôt print ou d'autres appels basés sur stdio, alors vous n'économisez aucune écriture sur le disque en construisant le fichier de sortie en mémoire. Par conséquent, vous pourriez tout aussi bien ne pas construire votre fichier de sortie en mémoire du tout, et plutôt l'écrire au fur et à mesure que vous le créez. Bien sûr, si vous utilisez syswrite oubliez ça.

  • Puisque rien n'est manifestement lent, essayez de lancer Devel::SmallProf à votre code. J'ai trouvé que c'était le meilleur profileur perl pour produire ces "Oh ! Ce C'est la ligne lente !".

1voto

Si vous voulez fusionner, vous devriez vraiment fusionner. Tout d'abord, vous devez trier vos données par clé et ensuite fusionner ! Vous battrez même MySQL en performance. J'ai beaucoup d'expérience avec cela.

Vous pouvez écrire quelque chose dans ce sens :

#!/usr/bin/env perl
use strict;
use warnings;

use Text::CSV_XS;
use autodie;

use constant KEYPOS => 4;

die "Insufficient number of parameters" if @ARGV < 2;
my $csv = Text::CSV_XS->new( { eol => $/ } );
my $sortpos = KEYPOS + 1;
open my $file1, "sort -n -k$sortpos -t, $ARGV[0] |";
open my $file2, "sort -n -k$sortpos -t, $ARGV[1] |";
my $row1 = $csv->getline($file1);
my $row2 = $csv->getline($file2);
while ( $row1 and $row2 ) {
    my $row;
    if ( $row1->[KEYPOS] == $row2->[KEYPOS] ) {    # merge rows
        $row  = [ map { $row1->[$_] || $row2->[$_] } 0 .. $#$row1 ];
        $row1 = $csv->getline($file1);
        $row2 = $csv->getline($file2);
    }
    elsif ( $row1->[KEYPOS] < $row2->[KEYPOS] ) {
        $row  = $row1;
        $row1 = $csv->getline($file1);
    }
    else {
        $row  = $row2;
        $row2 = $csv->getline($file2);
    }
    $csv->print( *STDOUT, $row );
}

# flush possible tail
while ( $row1 ) {
    $csv->print( *STDOUT, $row1 );
    $row1 = $csv->getline($file1);
}
while ( $row2 ) {
    $csv->print( *STDOUT, $row2 );
    $row2 = $csv->getline($file1);
}
close $file1;
close $file2;

Rediriger la sortie vers un fichier et mesurer.

Si vous voulez plus d'ordre dans les arguments de tri, vous pouvez remplacer la partie ouverture de fichier par

(open my $file1, '-|') || exec('sort',  '-n',  "-k$sortpos",  '-t,',  $ARGV[0]);
(open my $file2, '-|') || exec('sort',  '-n',  "-k$sortpos",  '-t,',  $ARGV[1]);

0voto

neal aise Points 600

En supposant des lignes de 20 octets, chacun de vos fichiers représenterait environ 20 Mo, ce qui n'est pas trop grand. Puisque vous utilisez le hachage, la complexité temporelle ne semble pas être un problème.

Dans votre deuxième boucle, vous imprimez sur la console pour chaque ligne, ce qui est lent. Essayez de le supprimer, cela devrait beaucoup vous aider. Vous pouvez également éviter la suppression dans la deuxième boucle.

La lecture de plusieurs lignes à la fois devrait également vous aider. Mais pas trop je pense, il y aura toujours une lecture anticipée dans les coulisses.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X