awkの魅力を伝える: 「ヘッダ付きcsvファイルの分割・統合」

2022/03/20に公開

はじめに

今回はawkを用いた「ヘッダ付きcsvファイルの分割・統合」をテーマにしています。
以下のテクニックを扱います。

以下の記事の続きとなっています。
※内容そのものに関連はないですが、一部の説明やデータなどに関連がある場合があります。
https://zenn.dev/nutmeg/articles/awk_select_10
https://zenn.dev/nutmeg/articles/awk_charm_02

使用するテストデータ

以前の記事で使用した天気データを加工して(部分的に前回の記事で紹介したコマンドを使用しています)、日付ごとの平均気温のデータを1月~3月分作成しました。
例によって長いので、折りたたんでいます。

クリックして表示
date2temp-avg_01.csv
date,temp-avg
2022-01-01,3.04375
2022-01-02,3.985
2022-01-03,5.7225
2022-01-04,6.5275
2022-01-05,4.45125
2022-01-06,2.92625
2022-01-07,4.4925
2022-01-08,5.5125
2022-01-09,7.75625
2022-01-10,8.20125
2022-01-11,6.89375
2022-01-12,5.61375
2022-01-13,5.45875
2022-01-14,4.66875
2022-01-15,5.4
2022-01-16,6.48
2022-01-17,7.19
2022-01-18,4.81625
2022-01-19,3.98625
2022-01-20,4.3825
2022-01-21,3.7475
2022-01-22,4.23375
2022-01-23,5.545
2022-01-24,7.2625
2022-01-25,5.135
2022-01-26,5.76
2022-01-27,7.065
2022-01-28,5.545
2022-01-29,5.255
2022-01-30,5.5875
2022-01-31,5.075
date2temp-avg_02.csv
date,temp-avg
2022-02-01,5.6025
2022-02-02,6.895
2022-02-03,6.54875
2022-02-04,5.11125
2022-02-05,5.46375
2022-02-06,3.48875
2022-02-07,4.065
2022-02-08,4.52125
2022-02-09,6.49875
2022-02-10,4.54125
2022-02-11,5.2475
2022-02-12,5.53875
2022-02-13,5.87125
2022-02-14,5.2175
2022-02-15,6.5575
2022-02-16,6.34375
2022-02-17,4.9125
2022-02-18,6.3775
2022-02-19,6.1375
2022-02-20,6.36
2022-02-21,4.61375
2022-02-22,5.1375
2022-02-23,4.41
2022-02-24,4.7025
2022-02-25,5.77125
2022-02-26,8.1625
2022-02-27,10.0762
2022-02-28,8.7325
date2temp-avg_03.csv
date,temp-avg
2022-03-01,11.39
2022-03-02,10.4475
2022-03-03,9.37875
2022-03-04,7.8175
2022-03-05,12.1112
2022-03-06,8.97125
2022-03-07,7.73125
2022-03-08,7.24375
2022-03-09,8.53125
2022-03-10,9.5025
2022-03-11,12.6912
2022-03-12,14.5025
2022-03-13,14.7
2022-03-14,17.5975
2022-03-15,14.36
2022-03-16,15.6025
2022-03-17,14.8275
2022-03-18,10.1775
2022-03-19,12.2513

実践例6. 複数のcsvファイルを1つのファイルにまとめる

同じ形式のcsvファイルが複数あり、これらを1つのファイルにまとめるというものです。
複数ファイルを連結する場合は「catコマンド」を利用するのが一般的ですが、以下のようなヘッダ付きのcsvファイルを連結する場合に「ヘッダは最初のみ付けて後のファイルでは無視したい」という状況もあると思います。
この処理は、簡単なようで少し厄介ですが、awkを使うと非常にシンプルな記述で処理をすることが可能です。

6-実行コマンド
awk 'FNR!=1||NR==1' date2temp-avg_??.csv > date2temp-avg_summary.csv

このコマンドによって、以下3つのファイル

  • date2temp-avg_01.csv
  • date2temp-avg_02.csv
  • date2temp-avg_03.csv

を、以下のファイルに集約することが出来ます。

  • date2temp-avg_summary.csv

3つのファイルにはdate,temp-avgというヘッダ行がありますが、このヘッダ行は最初の行のみ出力して、後はデータの行のみを出力するようにしています。
実行結果をcatで確認してみます。長いので折りたたんでいます。

クリックして表示
6-実行結果確認
$ cat date2temp-avg_summary.csv
date,temp-avg
2022-01-01,3.04375
2022-01-02,3.985
2022-01-03,5.7225
2022-01-04,6.5275
2022-01-05,4.45125
2022-01-06,2.92625
2022-01-07,4.4925
2022-01-08,5.5125
2022-01-09,7.75625
2022-01-10,8.20125
2022-01-11,6.89375
2022-01-12,5.61375
2022-01-13,5.45875
2022-01-14,4.66875
2022-01-15,5.4
2022-01-16,6.48
2022-01-17,7.19
2022-01-18,4.81625
2022-01-19,3.98625
2022-01-20,4.3825
2022-01-21,3.7475
2022-01-22,4.23375
2022-01-23,5.545
2022-01-24,7.2625
2022-01-25,5.135
2022-01-26,5.76
2022-01-27,7.065
2022-01-28,5.545
2022-01-29,5.255
2022-01-30,5.5875
2022-01-31,5.075
2022-02-01,5.6025
2022-02-02,6.895
2022-02-03,6.54875
2022-02-04,5.11125
2022-02-05,5.46375
2022-02-06,3.48875
2022-02-07,4.065
2022-02-08,4.52125
2022-02-09,6.49875
2022-02-10,4.54125
2022-02-11,5.2475
2022-02-12,5.53875
2022-02-13,5.87125
2022-02-14,5.2175
2022-02-15,6.5575
2022-02-16,6.34375
2022-02-17,4.9125
2022-02-18,6.3775
2022-02-19,6.1375
2022-02-20,6.36
2022-02-21,4.61375
2022-02-22,5.1375
2022-02-23,4.41
2022-02-24,4.7025
2022-02-25,5.77125
2022-02-26,8.1625
2022-02-27,10.0762
2022-02-28,8.7325
2022-03-01,11.39
2022-03-02,10.4475
2022-03-03,9.37875
2022-03-04,7.8175
2022-03-05,12.1112
2022-03-06,8.97125
2022-03-07,7.73125
2022-03-08,7.24375
2022-03-09,8.53125
2022-03-10,9.5025
2022-03-11,12.6912
2022-03-12,14.5025
2022-03-13,14.7
2022-03-14,17.5975
2022-03-15,14.36
2022-03-16,15.6025
2022-03-17,14.8275
2022-03-18,10.1775
2022-03-19,12.2513

6-実行コマンドの解説

awkのコマンドとして記述しているのは以下のみです。

'FNR!=1||NR==1'

この例では、パターンのみの指定しかしていません。

FNRが1でない または NRが1のときに、$0をprintで出力する。

という動作になります。FNRとNRについて説明します。

組み込み変数のNRFNR

NRは以前にも登場しているので今更ここで説明しても…というところですが、
NRは、扱っているデータが入力ファイル全体で何行目かを表しています。[1]
一方、FNRはファイルごとに見たとき、ファイル内で何行目かを表しています。
以下のコマンドの結果を確認していただく方が早いかもしれません。

$ cat file_1.txt
ファイル1の1行目
ファイル1の2行目
ファイル1の3行目
$ cat file_2.txt
ファイル2の1行目
ファイル2の2行目
ファイル2の3行目
ファイル2の4行目
$ awk '{print NR,FNR,$0}' file_[12].txt
1 1 ファイル1の1行目
2 2 ファイル1の2行目
3 3 ファイル1の3行目
4 1 ファイル2の1行目
5 2 ファイル2の2行目
6 3 ファイル2の3行目
7 4 ファイル2の4行目

ファイルを標準入力から読み込むか、awkの引数で読み込むか

これまでの説明ではcat等で出力をした結果を|awkに繋いで処理をしていましたが、引数としてファイルを与えることも出来ます。[2]
つまり、以下の処理は同じことをやっています。

標準入力から読み込む
cat ファイル | awk 'パターン{アクション}'
awkの引数で読み込む
awk 'パターン{アクション}' ファイル

個人的な好みの問題だと思っていますが、私は前者のやり方の方が好きなので[3]、基本的にはcatコマンドで出力した結果をgrepやawkに渡すという習慣が身についています。
しかし、今回の例では前者のやり方ではうまく処理が出来ません。
以下の出力を確認すれば一目瞭然でしょう。

$ cat file_1.txt
ファイル1の1行目
ファイル1の2行目
ファイル1の3行目
$ cat file_2.txt
ファイル2の1行目
ファイル2の2行目
ファイル2の3行目
ファイル2の4行目
$ cat file_[12].txt | awk '{print NR,FNR,$0}'
1 1 ファイル1の1行目
2 2 ファイル1の2行目
3 3 ファイル1の3行目
4 4 ファイル2の1行目
5 5 ファイル2の2行目
6 6 ファイル2の3行目
7 7 ファイル2の4行目

NRとFNRの値が同じになってしまいます。標準入力から受け取った場合は、同一ファイルの内容として扱ってしまうというわけです。組み込み変数FNRを生かす場合はawkの引数でファイルを読み込む必要があるということです。

結果の解釈

念のため以下のパターンについて改めて説明をしておきます。

'FNR!=1||NR==1'

FNRが1のときは必ずヘッダ行になります。
FNR!=1すなわちFNRが1でないとき出力するので、データ行は必ず出力されます。
NR==1すなわち読み込んだファイル全体で見たときの先頭行は出力するので、最初のファイルのヘッダ行は出力されます。
一見複雑な処理のはずが、組み込み変数FNRを活用することで非常にシンプルなパターンの記述のみで目的を達成することが出来ました。

別解1: 最初に連結をしたうえでヘッダ行を削除する(awk不使用)

awkのFNRを生かさないとすると、少し複雑な方法になってしまいます。[4]
連結をしたうえで1つずつヘッダ行を削除する方法を2通り紹介します。
といっても、異なるのは最後の挿入部分のみです。

6-別解1
ls date2temp-avg_??.csv | xargs -I@ sed 1d @ | sed 1i`head -1 date2temp-avg_01.csv` > date2temp-avg_summary.csv
ls date2temp-avg_??.csv | xargs -I@ sed 1d @ | cat <(head -1 date2temp-avg_01.csv) - > date2temp-avg_summary.csv

sed 1d ファイルとすることでヘッダ行を1行ずつ削除できるので、lsで連結の対象となるファイル一式をxargsに渡し、1つずつsed 1d ファイルを実行します。
その結果、ヘッダ行がない状態でcsvファイルが連結されるので、最後に最初の行にだけヘッダ行を挿入します。

sedによる行挿入

sed 1iヘッダ内容

とすると、標準入力の1行目にヘッダ内容を挿入できます。
``はbashのコマンド置換という機能を利用しており、head -1 date2temp-avg_01.csvによってdate2temp-avg_01.csvの1行目をその場で出力させています。

catでヘッダを連結

catでヘッダを連結してしまうことも可能です。

cat <(head -1 date2temp-avg_01.csv) -

<()はbashのプロセス置換という機能を利用しており、head -1 date2temp-avg_01.csvを1つのファイルをして連結します。2つ目の-は標準入力の内容を指します。[5]

別解2: awkによる重複排除を利用する(同一データがない想定)

今回テストで扱っているcsvファイルは日付を1列目に持っており、これらは重複することがありません。つまり、データ行において$1が同じ値となることがあり得ません。
このようなケースでは、awkによる重複排除が生かせます。

6-別解3
cat date2temp-avg_??.csv | awk '!a[$1]++' > date2temp-avg_summary.csv

ヘッダ行は全く同じ値となるので最初のみ出力されます。データ行は、必ず異なる値が$1に格納されている想定のため、すべて出力されることになります。
もちろん、同じデータ行が存在するケースではデータをロストしてしまうため、重複が発生しないデータであることを事前に確認しておく必要があります。

実践例7. 1つのcsvファイルを月毎に別のファイルに分割する

実践例6と逆の操作を行います。実践例6で生成したdate2temp-avg_summary.csvを使用します。
1つのファイルに記述された1月-3月の平均気温データを、月ごとに別々のファイルに分けるということをやります。

下準備として、実践例6で作成した月毎のファイルをすべて消しておきます。

下準備
rm date2temp-avg_??.csv
7-実行コマンド
cat date2temp-avg_summary.csv | sed 1d | awk -F- '!a[$2]++{print "echo date,temp-avg >> date2temp-avg_"$2".csv"}{print "echo",$0,">>","date2temp-avg_"$2".csv"}' | sh

7-実行コマンドの解説

今まで以上にやっていることが複雑だと思うので、cat以降のコマンドを順に説明します。

| sed 1d

先頭のヘッダ行を削除します。次のawkコマンドにて再度挿入しますが、ヘッダが二重にならないようにするためです。

| awk -F- '!a[$2]++{print "echo date,temp-avg >> date2temp-avg_"$2".csv"}{print "echo "$0" >> date2temp-avg_"$2".csv"}'

ここが複雑ですね。何をやっているかというと、ファイルの内容 を ファイルの内容を出力するコマンド に変換しています。今回はヘッダ行が欲しいので、各月の最初のデータが登場した場合はその場でヘッダ行も出力させています。

7-実行コマンドの注釈
# 日付の値('-'区切りで2列目)が初出の場合
!a[$2]++
{
    # 「ヘッダ行を出力するコマンド」を出力する
    print "echo date,temp-avg >> date2temp-avg_"$2".csv"
}
# 入力の1行ごとに
{
    # 「行の値を出力するコマンド」を出力する
    print "echo "$0" >> date2temp-avg_"$2".csv"
}

awkの入力時の列区切り文字は'-'を指定しているため、$2には月の値(01, 02, 03)が格納されます。連想配列の仕組みを上手く利用して、この値が最初に出力された場合のみ、ヘッダ出力のコマンドを挿入します。
リダイレクトは>にするとファイルの内容に対して上書きをしてしまうので、>>とすることで追記をさせています。[6]

各行に出力がどのように対応しているかを部分的に抜粋しました。
日付の月の値がファイル名に組み込まれていることが確認できます。

入力 出力
2022-01-01,3.04375 echo date,temp-avg >> date2temp-avg_01.csv
echo 2022-01-01,3.04375 >> date2temp-avg_01.csv
2022-01-02,3.985 echo 2022-01-02,3.985 >> date2temp-avg_01.csv
2022-01-03,5.7225 echo 2022-01-02,3.985 >> date2temp-avg_01.csv
2022-01-04,6.5275 echo 2022-01-02,3.985 >> date2temp-avg_01.csv
・・・ ・・・
2022-01-30,5.5875 echo 2022-01-30,5.5875 >> date2temp-avg_01.csv
2022-01-31,5.075 echo 2022-01-31,5.075 >> date2temp-avg_01.csv
2022-02-01,5.6025 echo date,temp-avg >> date2temp-avg_02.csv
echo 2022-02-01,5.6025 >> date2temp-avg_02.csv
2022-02-02,6.895 echo 2022-02-02,6.895 >> date2temp-avg_02.csv
・・・ ・・・

| sh

前のawkまでで実行コマンドを生成しているので、最後に1行ずつshに渡してコマンドを実行します。
このように、awkやsedなどを用いて実行コマンドを生成してから| shによって実行する手法はしばしば使われます。awkは文字列を器用に整形出来るので、Shellコマンドを動的に組み立てるといったことも容易に行うことが出来ます。
前回まで見てきた例ではただ出力するだけだったので、既存の環境が壊れるようなことはありませんでしたが、今回はファイルを新たに生成するために>>を使用してコマンドを実行するため、場合によっては既存のファイルが影響を受けてしまうことも考えられます。
awkを上手く利用して、特定の条件に従って一括のファイル変更・移動などを行うといったことも可能ですが、誤動作を防ぐために以下を心掛けておくべきだと思います。

  • awkで実行コマンドを出力した際、| shに渡す前に出力内容を十分に確認する
  • 安全に作業を行うため、作業ディレクトリ内でバックアップを取っておく
  • 場合によってはこ一時的なディレクトリを作成して、そこに必要な資材をコピーして作業する

splitコマンド

Linuxコマンドで1つの大きいファイルの内容を複数ファイルに分割するコマンドとして、splitコマンドというものがあります。データをバイト数や行数などを基準に、内容を分割して複数のファイルに書き込むことが出来ます。
今回の実践例7では、月毎にファイルを振り分けるためにデータ行の値を元にしているので、splitコマンドを使って狙い通りに振り分けるのは難しいです。[7]

まとめ

ファイルの統合・分割は、それぞれcatコマンド、splitコマンドで実行するのが一般的だと思います。しかし、余計なヘッダ行を除いての統合や、行の内容ごとにファイルを振り分けるといった場合、これらのコマンドで狙い通りの処理をするのが難しい場合も多いです。
awkではこれらのような複雑な処理も、最小限の仕組みを活用することでこなせる場合が多いです。

ここまで読んでくださって、ありがとうございました。

脚注
  1. 通常は改行コード(\n)を元に判定しますが、組み込み変数RSを設定することで別の文字を指定することも出来ます。 ↩︎

  2. cat/grep/sed/cut/sort等、多くのLinuxコマンドでは標準入力・引数のどちらでも受け取れる仕様になっています。 ↩︎

  3. ワンライナーを書くとき、途中まで書いたらその場で出力させ、結果を確認しつつ何度も書き直すことが多く、その際に前者の書き方のほうがカーソル移動がしやすい…という都合です。 ↩︎

  4. ワンライナーでなくとも、事前にヘッダ行を削除しておき、catで束ねたうえで最後にヘッダ行を1行だけ挿入するとすれば良い話ですが… ↩︎

  5. sort/diff/grep等のコマンドでもよく使います。 ↩︎

  6. ファイルが存在しない状態では'>'と同様にファイルを作成したうえで内容を書き込みますが、ファイルが存在する場合は末尾に上書きする形で追記をするため、繰り返して実行する場合は事前にファイルを削除・移動しておく必要があります。 ↩︎

  7. 月ごとの日数が同じであればsplitで行を指定して容易に分割することが出来ますが… ↩︎

GitHubで編集を提案

Discussion