UECジャーナル

開眼☆シェルスクリプト 端末上で扱いづらいテキストの対処法―AWKで乗り切れ!

端末上で扱いづらいテキストをAWKで乗り切る方法について説明する。

環境

作業環境を示す。

リスト1: 環境

uedamac:201305 ueda$ uname -a
Darwin uedamac.local 12.2.1 Darwin Kernel Version 12.2.1: Thu Oct 18 16:32:48 PDT 2012; root:xnu-2050.20.9~2/RELEASE_X86_64 x86_64
uedamac:201305 ueda$ awk --version
awk version 20070501
$ gsed --version
GNU sed version 4.2.1
Copyright (C) 2009 Free Software Foundation, Inc.
 (以下略) 
$ 

以降の問題4で必要になるため、Mac OS Xにデフォルトでインストールされているsed(1)の他に、GNU sed/gsed(1)をインストールしている。Mac OS Xユーザの方はMacPortsやHomebrewを使ってインストールしてみてほしい。FreeBSDの場合は/usr/ports/textproc/gsed/からインストールできる。

問題1

リスト2のようなデータがあったとする。やりたいことは各行の数字に対してその1つ上の数字との差を求めるということとする。出力は自分で見るためのものなので適当でも構わない。

リスト2: 問題1の入力ファイル

$ cat Q1.INPUT 
2
53
165
6
899
9
$ 

この問題はAWKに慣れている人ならすぐにできる。リスト3のように変数を使って行またぎの処理をする。AWKのアクションの中は今のレコードと「a」の差を求めて出力し、その後で今のレコードをaに代入するというもの。次のレコードに処理が移ったとき、aには前のレコードの値が入っている。

リスト3: 解答その1

$ cat Q1.INPUT | awk '{print $1-a;a=$1}'
2
51
112
-159
893
-890
$ 

この計算では一番最初のレコードの扱いが雑だ。最初のレコードの$1-aは、aは0で初期化されるので、2-0で2がそのまま出力されている。雑なら雑でよいのだが、もし最初のレコードがいらないならリスト4のようにtail(1)で取り除く。

リスト4: 解答その2

$ cat Q1.INPUT | awk '{print $1-a;a=$1}' | tail -n +2
51
112
-159
893
-890
$ 

AWKだけでやるとすると、もう少しエレガントな方法もある。リスト5に示す。

リスト5: 解答その3

$ cat Q1.INPUT | awk 'NR!=1{print $1-a}{a=$1}' 
51
112
-159
893
-890
$ 

問題2

次は足し算の問題を扱う。リスト6のファイルQ2.INPUTについて、キー(この例では001 AAAや002 BBBのこと)ごとに数字を足すものとする。

$ cat Q2.INPUT 
001 AAA 0.1
001 AAA 0.2
002 BBB 0.2
002 BBB 0.3
002 BBB 0.4
$ 

この問題を一番素直に解くと、リスト7のように連想配列を使ったものになる。1列目と2列目の文字列を空白をはさんで連結してキーにして、配列sumの当該のキーに値を足し込む。sumの各要素はゼロで初期化されているので、最初から+=で足して構わない。AWKの配列は連想配列で、インデックス(角括弧の中)に初期化せずになんでも指定できる。このためこのように文字列を丸ごとインデックスにできる。

リスト7: 解答その1

$ cat Q2.INPUT | awk '{sum[$1 " " $2] +=$3}END{for(k in sum){print k,sum[k]}}'
001 AAA 0.3
002 BBB 0.9
$ 

for(k in sum)は配列sumの全要素のインデックスを1つずつkにセットしてfor文を回す書き方。

この方法だと、ソートしていないデータでも足し算してくれる一方、入力されたデータをAWKが一度全部吸い込んでから出力するので、パイプの間に挟むとデータが一時的に流れなくなる。最後に順番に出力してくれるとも限らない。さらに、連想配列を使っているので、もう少しデータが大きくなると遅くなる。

入力レコードが多くなったときの書き方をリスト8に示す。データはソートされていることが前提となる。上記の方法で済むうちはわざと難しく書く必要はないので、上記の方法でやってほしい。

リスト8: 解答その2

$ cat Q2.INPUT | awk 'NR!=1 && k1!=$1{print k1,k2,sum;sum=0}\
		{k1=$1;k2=$2;sum+=$3}END{print k1,k2,sum}'
001 AAA 0.3
002 BBB 0.9
$ 

1列目と2列目が一対一対応でないことがある場合は、リスト9のようにする。

リスト9: 解答その3

$ cat Q2.INPUT | awk 'NR!=1 && k!=$1" "$2{print k,sum;sum=0}\
		{k=$1" "$2;sum+=$3}END{print k,sum}'
001 AAA 0.3
002 BBB 0.9
$ 

この方法だとキーの境目で出力があり、配列にデータを溜め込むということもない。

この手のAWKプログラミングでは、まずパターンがどれだけあるか考え、その後に各パターンで何をしなければならないのかを考えるとすんなり問題が解けることがある。この例では、

キーが変化するレコードで行う処理(キーと和を出力)
通常の処理(キーを記憶し、数字を足す)
最後の処理(一番最後のキーの和を出力)

と3つのパターンとアクションを考える事で、if文を使わずに目的の計算を実装している。

同じ処理をPythonで素直に書くとリスト10のようになる。一概に長い短いを比較することは乱暴だが、変数の初期化の方法、行の読み込み方、パターンv.s. if構文、という3つの点においてAWKの方が問題に対して近道であることが分かる。

リスト10: pythonでの解答

$ cat ./sum.py 
#!/usr/bin/env python

import sys

key = ""
n = 1
s = 0.0
for line in sys.stdin:
	token = line.rstrip().split(" ")
	k = " ".join(token[0:2])

	if n != 1 and key != k:
		print key, s
		s = 0.0

	s += float(token[2])
	key = k
	n += 1

print key, s
$ 
$ cat Q2.INPUT | ./sum.py 
001 AAA 0.3
002 BBB 0.9
$ 

リスト11: Open usp Tukubai を使った解答

$ sm2 1 2 3 3 Q2.INPUT 
001 AAA 0.3
002 BBB 0.9
$ 

問題3

リスト12のファイルを考える。Q3.SPANの各レコードは日付の範囲でQ3.DAYS は日付になっている。Q3.SPANの各日付の範囲にQ3.DAYSが何日ずつ含まれているかを調べることとする。

リスト12: 問題3の入力ファイル

$ cat Q3.SPAN 
20130101 20130125
20130126 20130212
20130213 20130310
20130311 20130402
$ cat Q3.DAYS 
20130102
20130203
20130209
20130312
20130313
20130429
$ 

「AWK1個だけ」という制限のもとに書いたワンライナーは次のようなものになる。

リスト13: 解答その1

$ awk 'FILENAME~/SPAN/{f[FNR]=$1;t[FNR]=$2}\
	FILENAME~/DAYS/{for(k in f){\
	if($1>=f[k] && $1<=t[k]){sum[f[k]" "t[k]]++}}}\
	END{for(k in sum){print k,sum[k]}}' Q3.SPAN Q3.DAYS 
20130126 20130212 2
20130311 20130402 2
20130101 20130125 1
$ 

どんな処理か説明すると次のようになる。

最初のパターンでQ3.SPANの各レコードを配列f (from) 、t (to)に代入次のパターンでQ3.DAYSの日付とf,tの内容を比較して、日付が期間中にあれば期間に対応するカウンタ(sum[<期間>])に1を足すENDパターンで、各期間とsumの内容を出力

変数FILENAMEにはオプションで指定したファイル名、FNRには各ファイル内でのレコード番号が予め代入されており、このコードではそれをパターンや配列のインデックスに使っている。

シェルスクリプトでデータ処理を行うときは、1レコードに計算する対象がすべて収まっていると楽な場合が多くなる。つまり、あらかじめそのような状態を作りにいけばよいということになる。

リスト14のようにQ3.DAYSとQ3.SPANのレコードの組み合わせを全通り作る。

リスト14: 解答その2

	
$ awk 'FILENAME~/SPAN/{key[FNR]=$1" "$2}FILENAME~/DAYS/{for(k in key){print key[k],$1}}' Q3.SPAN Q3.DAYS 
20130126 20130212 20130102
20130213 20130310 20130102
20130311 20130402 20130102
20130101 20130125 20130102
20130126 20130212 20130203
20130213 20130310 20130203
...
$ 

3列目の日付が1、2列目の日付の範囲に含まれているものを出力し、数を数えるとよいということになる。この方がAWKですべて処理するよりすっきりする。

リスト15: 解答その3

$ awk 'FILENAME~/SPAN/{key[FNR]=$1" "$2}FILENAME~/DAYS/{for(k in key){print key[k],$1}}' Q3.SPAN Q3.DAYS | awk '$1<=$3&&$3<=$2{print $1,$2}' | uniq -c
   1 20130101 20130125
   2 20130126 20130212
   2 20130311 20130402
$ 

usp Tukubai を使うともっと楽になる。リスト16に解答を示す。loopxは、上のリストの最初のAWKと同じ処理をしている。

リスト16: Open usp Tukubai を使った解答

	 loopx Q3.SPAN Q3.DAYS | awk '$1<=$3&&$3<=$2{print $1,$2}' | uniq -c
   1 20130101 20130125
   2 20130126 20130212
   2 20130311 20130402
$ 

問題4

最後に文章の処理を扱う。さばくのはリスト17のファイル(文章は嘘文章なので注意)。

リスト17: 問題4の入力ファイル

$ cat Q4.MEMO 
「コロラド大ダンゴ虫」は、直径20cmになる世界最大のダンゴ虫。ダンゴ虫を転がし、トゲを抜いた柱状サボテンを倒して遊んだのが、ボーリングの始まり。
$ 

このファイルを、何かのフォームに貼付けるために、20文字で折り返すという想定とする。

リスト18: 解答その1

$ cat Q4.MEMO | gsed 's/./&\n/g' |
	awk '{printf $1}NR%20==0{print ""}END{print ""}' > tmp
$ cat tmp
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転がし
、トゲを抜いた柱状サボテンを倒して遊んだ
のが、ボーリングの始まり。
$ 

最初のgsed(1)で1文字1文字、後ろに改行を入れて出力し次のAWKで20文字ずつまとめている。単に文字列を改行しないで出力したい場合は、この例のようにprintfを括弧なしで変数を指定すればよい。

句読点を21文字目にくっつけてよいなら、リスト19のようにコードを書けばよい。

リスト19: 解答その2 (句読点対応)

$ cat tmp | awk 'NR!=1{\
	if($1~/^、/){print a"、"}else{print a}}\
	{a=$1}END{print a}' | sed 's/^、//'
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転がし、
トゲを抜いた柱状サボテンを倒して遊んだ
のが、ボーリングの始まり。
$ 

この処理ではAWKにおける「先読み」という定石を使っている。リスト20のコードのように1行読み込んで1行前の行を出力するコードを書いて、そこから必要なコードを足す。そうすると、aの出力をその次の行を見て操作できる。

リスト20: 先読みの骨組み

$ cat tmp | awk 'NR!=1{print a}{a=$1}END{print a}'
「コロラド大ダンゴ虫」は、直径20cmに
 (以下略) 
$ 

句読点も含めて20字で収めなければならないなら、話はもう少し複雑になる。コードを次に示す。

リスト21: 解答その3

$ cat Q4.MEMO | gsed 's/./&\n/g' |
awk 'BEGIN{c=0}\
NR!=1{if(c==19 && $1=="、"){print "";printf a;c=1}else{printf a;c++}}\
c==20{print "";c=0}{a=$1}END{print a}'
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転が
し、トゲを抜いた柱状サボテンを倒して遊ん
だのが、ボーリングの始まり。
$ 

最後に

シェルスクリプトでテキスト処理する際につまずきやすい問題をAWKで解決する方法を解説した。このような類いの処理は無数にあるが、先読みが使えるかどうかなどパターンはそんなにないので、慣れてしまうとさまざまなテキストを処理を記述できるようになる。特に先読みは多くの集計処理で利用できる。

Software Design 2013年5月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【17】端末上で扱いづらいテキストの対処法―AWKで乗り切れ!」より加筆修正後転載

last modified: 2014-03-17 20:03:17