はじめに
前回の記事では、PerlでCSVを扱うための基本的な考え方を説明しました。本記事では引き続き、PerlでCSVファイルを集計するためのノウハウを紹介します。
対象読者
- Perlのごく初歩的な知識(配列、ハッシュ)を有している方。
- Perlを利用してCSV形式のデータを集計したい方。
必要な環境
- テキストエディタ。
- Perl 5.8.X。ただし、ほとんどのコードはそれ以下のバージョンでも動きます。
環境を整える方法に関しては、前回の記事を参照して下さい。
2つのCSVを扱う
キーによってJOINする
顧客にアンケートをとった結果が格納された、次のような「enquate.csv」があるとします。左から順に、
顧客ID,設問1の回答,設問2の回答,設問3の回答
というような4カラムのデータです。
00893,1,4,2 89204,4,2,3 75648,2,2,2 : :(以下、30,000件) :
あなたは、このデータの各行の後ろに顧客の氏名と住所を紐づけ、プレゼント発送担当者に渡さなければなりません。どうすれば、このような処理ができるでしょうか? 顧客のデータは「address.csv」という名前で、
顧客ID,氏名,住所
の3カラムからなっています。
02547,佐藤大輔,北海道苫小牧市XXXXYYYY 15983,田中久志,沖縄県那覇市XXXXYYYY 00893,本間雅洋,神奈川県横浜市XXXXYYYY : :(以下、100,000件) :
絶対にやってはいけないコーディング
ある新人クンがこの仕事を引き受け、次のようなコードを書いて実行しました。
open(OUT, '>result.csv'); open(IN1, 'enquate.csv'); # アンケートデータを1行ずつ処理する while(my $line1 = <IN1>){ # 1行を4つに分ける chomp($line1); my ($id, $ans1, $ans2, $ans3) = split(/,/, $line1, 4); # この行にマッチする顧客データを検索する my $name = ''; my $address = ''; open(IN2, 'address.csv'); while(my $line2 = <IN2>){ chomp($line2); my ($tmp_id, $tmp_name, $tmp_address) = split(/,/, $line2, 3); if($tmp_id eq $id){ # 対象となる顧客が見つかった! $name = $tmp_name; $address = $tmp_address; last; } } close(IN2); # 出力する print OUT join(',', $id, $ans1, $ans2, $ans3, $name, $address), "\n"; } close(OUT); close(IN1);
結果が出たらメールで送信して、今日はさっさと定時で帰ろうと思っていた新人クン。ところが、いつまで待ってもこの処理は終わる兆しが見えません。処理が終わるまでの待ち時間を利用してコードを1行ずつ何度も見直したのですが、論理的には間違えていないコーディングに見えます。いったいなぜ終わらないのでしょう?
30億回のループ処理
新人クンのスクリプトがなかなか終了しなかった原因は、ファイルの読み込み処理が2重のループになっていることにあります。外側の「enquate.csv」の件数が3万件で、その1行1行について「address.csv」を毎回開いてデータを検索しています。「address.csv」の行数が10万行ですので、単純計算で10万行×3万回=30億行をファイルから読み込む処理であるということになります(ただし、検索が完了したらlast
しているので、実際にはもっと少ない行数で済みますが)。
こんな回数をループ処理していれば、処理が終わらないのは当たり前です。筆者の環境では1時間待っても処理が終わらなかったので、とうとう[Ctrl]+[C]で強制終了してしまいました。
ハッシュを有効に使う
では、この処理を現実的な時間で終えるためにはどうすればいいのでしょうか? そのためには、「address.csv」から対象行を呼び出す処理を高速化する必要があります。
前回の記事でも述べた内容ですが、ここで、取り扱うファイルの大きさが問題となってきます。今回扱っている「address.csv」は5MB程度の大きさですので、全てメモリに取り込んでも大きな問題になることはなさそうです。そこで、3万回繰り返していた「address.csv」の読み込み処理を最初に持ってきて、全てメモリに持つように変更してみます。これでファイルの読み込み行数は30億行から10万行に減るはずです。
検索の高速化にはもう一つ重要なことがあります。それは、メモリ上のデータを高速に検索するための索引、つまり、インデックスを張ることです。インデックスが用意されていなければ、対象となる顧客IDに対応する行を探すのに、毎回全データを走査しなければならなくなりますが、それはあまりに非効率的過ぎます。
Perlにおいて、このインデックスとして適任なのはハッシュです。キーに対する値を、高速に探し出すことができるからです。今回の例では、顧客IDから対象となる行がユニークに定まりますので、顧客IDをキーとしたハッシュに「address.csv」のデータを保存します。
コードは以下のようになりました。
# 最初に、顧客の住所を全てハッシュ(メモリ上)に取り込む my %address_datas = (); open(IN, 'address.csv'); while(<IN>){ chomp; my ($id, $name, $address) = split(/,/, $_, 3); # (※1)顧客IDをキーとし、対応する名前と住所の配列を保存する $address_datas{$id} = [$name, $address]; } close(IN); # アンケートデータに氏名と住所をマージする open(OUT, '>result.csv'); open(IN, 'enquate.csv'); while(my $line = <IN>){ chomp($line); my ($id, $ans1, $ans2, $ans3) = split(/,/, $line, 4); # この行にマッチする顧客データを検索する my $ref_data = $address_datas{$id}; # 配列の0番目に名前、1番目に住所が入っている (※1を参照) my $name = $ref_data->[0]; my $address = $ref_data->[1]; # 出力する print OUT join(',', $id, $ans1, $ans2, $ans3, $name, $address), "\n"; } close(OUT); close(IN);
このコードを筆者の環境で実行すると、5秒も立たないうちに終了しました。これなら実用上まったく問題がなさそうです。
この例のように、特定のID項目を含む複数のファイルをそのID項目でJOINする場合には、ID項目をキーとしたハッシュへJOINしたいファイルのデータを読み込んでおくと、効率よく処理することができます。間違えても、最初のコードのように2重ループを作って検索することのないように気をつけましょう。
(追記:このコードに関しまして、弾さんからトラックバックを頂いております。あわせてご参照ください。)
2つのCSVに共通するID、片側にしかないID
次は、2つのCSVの包含関係を調べる処理です。「enquate.csv」と同じフォーマットの「enquate2.csv」というファイルがあります。このファイルは2回目にとったアンケートですが、1回目とは違うユーザも同じユーザも混じっています。この2つのファイルを読み込んで、「1回目だけ応募した顧客」「2回目だけ応募した顧客」「どちらも応募した顧客」を調べてみましょう。出力は2カラムで、
顧客ID,1 or 2 or 3 (1: 1回目だけ応募、2: 2回目だけ応募、3: どちらも応募)
とします。「enquate2.csv」の行数は、「enquate.csv」と同じ30,000行です。
この処理を実現するために、今回は以下の手順をとります。
- 「enquate2.csv」をハッシュに取り込む
- 「enquate.csv」を1行ずつ読み込む
- ハッシュにデータがなければ、「1回目だけ応募した顧客」
- ハッシュにデータがあれば、「どちらも応募した顧客」
- ハッシュから、該当データ(1回目に応募した顧客のデータ)を消す
- 最後に、消されずに残ったデータが「2回目だけ応募した顧客」
対称性がなくて多少わかりにくい処理ではありますが、この方法だとメモリに読み込むのは片方のファイルだけで済みますので、大きめのファイルでもなんとかなるのが利点です。コードは、以下のようになります。
# 2回目のアンケート応募者をハッシュに取り込む my %enq2_data = (); open(IN, 'enquate2.csv'); while(<IN>){ chomp; # IDだけ必要なので、IDだけ取り出す my ($id) = split(/,/, $_, 2); # キーを顧客IDとし、目印(フラグ)として1を入れる $enq2_data{$id} = 1; } close(IN);
まず最初に、2回目のアンケート結果をハッシュに取り込みます。今回必要なデータはIDだけで設問の回答部分は必要ありませんので、split
の引き数の最後に2を指定し、左辺では$id
だけを指定することで最初のカラム、つまり顧客IDだけを取り出しています。
また、ハッシュのキーは顧客IDとしてますが、ハッシュの値は特に利用しないので、ダミーの値として1を代入することにしました。
ハッシュにデータが整えば、次は1回目のアンケート結果を読み込んで比較する部分となります。最初の部分と同じように、行をsplit
する部分では顧客IDだけを取り出します。その後、ハッシュに含まれる2回目応募者の顧客IDを検索し、キーが存在するようであればどちらにも応募した顧客として「3」を出力します。また、delete
関数によってハッシュからこの顧客IDを削除します。これにより、2回目のアンケート応募者から1回目も応募した顧客IDが削除され、最終的には2回目のみ応募した応募者が残るということになります。
2回目応募者のハッシュ内にキーが存在しなければ、1回目のアンケート結果にしか含まれなかった顧客IDということになりますので、「1」を出力します。
# 1回目のアンケート応募者と2回目の応募者を比較する open(OUT, '>result.csv'); open(IN, 'enquate.csv'); while(<IN>){ chomp; # IDだけ必要なので、IDだけ取り出す my ($id) = split(/,/, $_, 2); if($enq2_data{$id}){ # どちらにも応募した顧客 print OUT "$id,3\n"; # ハッシュから「1回目応募した顧客」を削除 delete $enq2_data{$id}; }else{ # 1回目だけ応募した顧客 print OUT "$id,1\n"; } } close(IN);
ここまでで、「1回目のみ応募した顧客」と「どちらにも応募した顧客」の出力が終わりました。後は、ハッシュに残っている「2回目のみ応募した顧客」を出力させれば、処理は完了となります。
# 最後に、ハッシュに残ったのが2回目のみ応募した顧客 foreach(keys %enq2_data){ print OUT "$_,2\n"; } close(OUT);
今回の例では、1つのファイルに全ての結果を吐きましたが、グループごとに3つのファイルに分けるという出力方法も考えられます。その場合は、OUT1
、OUT2
、OUT3
の用にファイルハンドルを複数用意してopen
し、適切なハンドルに向けて結果を出力させるようにするとよいでしょう。