[Awkでデータ解析のすゝめ] gawkでカスタムソートを使ってみる


2020/02/08

ビッグデータ解析の分野において、とりわけPythonとRが有名で、選ばれている要因の色々な解析のためのツールやライブラリなどが充実しているので、初学者にも敷居が低いことなどが選ばれている要因の一つになっていると思います。

ただし、高度な統計処理をしないのであれば、動作環境を選ばない圧倒的にAwkが使いやすいですし、かなり高速にデータを処理できます。

著者個人的には株式データや機械学習訓練用データのような時系列データを捌くのに日常的に利用しているので、もはやAwkによるデータ解析は手放せないものになっている程です。

下は以前に記した関連テーマのリンクです。ご参考までに。

複雑なデータ処理に欠かせないのが、データの順番を自由に並び替える
カスタムソートについてのお話です。

Shellでソートをする場合、とにかくsortコマンドを使うことが多いと思いますが、実はawkでも高度なソート機能が備わっているため、わざわざsortコマンドとawkコマンドを併用させて使うまでもなく目的の処理をawkだけで実装できる場合が多くあります。

今回は知っていると得をするかもしれないawkでのカスタムソートの作成方法と使い方をご紹介します。


はじめに ~ mawkからgawkに移行しよう

ほとんどのLinuxディストリビューションではほぼGNU awk通称gawkがデフォルトでプリインストールされているため、今日日awkというとgawkを使っているつもりになってしまいます。

しかしながら、Debian系は慣習として
mawkを使っているディストリビューションもまだまだ存在します(RaspberryPi OSなんかがそうです)。

なのでmawkとgawkの違いを気にせずに使っていると、手持ちの既存のawkスクリプトが他の環境に移したときに上手く動作してくれない場合があります。

この厄介なmawkとの互換性ですが、そもそもmawkは軽量で高速な動作をウリにしている代わりに、gawkと比べて高機能な関数を使えないようなプログラムです。

そしてmawkの一番の問題として20年以上にも渡りほぼメンテナンスされていない状態であり、もはやawkで大量のファイルを捌いたり、極限の処理速度を求める方以外は使う機会もないのではないかと思います。

            
            $ awk -W version
mawk 1.3.3 Nov 1996, Copyright (C) Michael D. Brennan

compiled limits:
max NF             32767
sprintf buffer      1020
        
ということで、mawkが標準である場合、gawkがデフォルトで使えるように設定し直しを当初から考えておいたほうがあとあと幸せになれると思います。

なお、gawkは安定してメンテナンスがされており、(既にgawk5がリリースされている段階ではありますが...)手元のgawk4は

            
            $ gawk --version
GNU Awk 4.2.1, API: 2.0 (GNU MPFR 4.0.2, GNU MP 6.1.2)
Copyright (C) 1989, 1991-2018 Free Software Foundation.

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see http://www.gnu.org/licenses/.
        
となっています。

Debian系のOSにgawkを導入する場合にはaptコマンドでほぼ一発導入することができます。

            
            $ sudo apt-get install gawk
$ gawk --version
GNU Awk 4.2.1, API: 2.0 (GNU MPFR 4.0.2, GNU MP 6.1.2)
Copyright (C) 1989, 1991-2018 Free Software Foundation.
#👇aptでパッケージインストールするとデフォルトのawkも自動で置き換わる
$ awk --version
GNU Awk 4.2.1, API: 2.0 (GNU MPFR 4.0.2, GNU MP 6.1.2)
Copyright (C) 1989, 1991-2018 Free Software Foundation.
        
注意されたいのは、aptパッケージマネージャからgawkをインストールすると、awkコマンドが自動でgawkと紐付けされるようなので、mawkをどうしてもawkコマンドにしたい方はgawkのソースビルドから使う方が良いでしょう。

とにかくこれで
sortiのような高機能な関数を臆すこと無く利用することができるようになります。


awkでカスタムソート

mawkとgawkの違いの前置きが長くなりましたが、それでは本題のカスタムソートの実装法を具体的にやっていきます。もちろんここでのawkとはgawkを指していますので以降ご留意ください。

配列のカスタムソートといってもおもに2つのやり方があります。返ってくる結果としては同じですので、どちらのやり方が適しているかはコーダーの判断になりますが、早速、配列のカスタムソートを解説していきます。


PROCINFO["sorted_in"]でカスタムソート

最初にPROCINFOというシステム変数の設定を変更することで、for ... inの配列ループでイテレーションさせる順番を変えるやり方をやってみます。

まずは例題として、どこかの学校の生徒ごとのテスト点数をまとめたデータ
EXAM_SCOREを昇順でソートするプログラムとして、以下のスクリプトを1.shとして保存してみてください。

            
            #!/bin/bash

#👇1列目がキー、2列目が値として使う
EXAM_SCORE=$(cat << EOF
Ichiro 84
Bob 25
Wakame 76
Hanako 56
Alice 90
Kabao 53
Jam 43
Tarao 25
Cheese 12
Kasuo 47
Piyoko 88
Ikura 29
EOF
)

echo "$EXAM_SCORE" | awk '
function cmp_num_val(i1, v1, i2, v2) {
    if (v1 < v2) {
        return -1;
    } else {
        return 1;
    }
}
{
    data[$1] = $2;
}
END {
    PROCINFO["sorted_in"] = "cmp_num_val";
    for (j in data) {
        printf("data[%s] = %s\n", j, data[j]);
    }
}
'
        
これを実行させますと、

            
            $ chmod +x 1.sh
$ ./1.sh
data[Cheese] = 12
data[Tarao] = 25
data[Bob] = 25
data[Ikura] = 29
data[Jam] = 43
data[Kasuo] = 47
data[Kabao] = 53
data[Hanako] = 56
data[Wakame] = 76
data[Ichiro] = 84
data[Piyoko] = 88
data[Alice] = 90
        
という風にテストの点数を昇順でソートする結果が得られました。

PROCINFO["sorted_in"]を使ったカスタムソートでポイントとなるのが、awkの内部スクリプトで定義して使っていた以下のパートです。

            
            #.......
function cmp_num_val(i1, v1, i2, v2) {
    if (v1 < v2) {
        return -1;
    } else {
        return 1;
    }
}
#.......
{
    PROCINFO["sorted_in"] = "cmp_num_val";
    for (j in data) {
        printf("data[%s] = %s\n", j, data[j]);
    }
}
#.......
        
まず、PROCINFO["sorted_in"]が呼び出された後のfor ... in 配列名では、PROCINFO["sorted_in"]に指定された関数名で記述したルールに乗っ取った結果で、そのループ内の要素が引き出されることになります。

このとき
PROCINFO["sorted_in"]に割り当てることができる関数は関数名(i1, v1, i2, v2)の4つの引数をもっており、それぞれの添字1と2は比較している二つの要素({i1,v1}\{ i_1, v_1 \}{i2,v2}\{ i_2, v_2 \})を表しており、iがインデックス(もしくはキー)で、vが値(もしくはラベル)を意味しています。

そのソート対象の配列の要素の二つを比較したときに、
{i1,v1}\{ i_1, v_1 \}の要素を{i2,v2}\{ i_2, v_2 \}の要素より順序を前にしたい場合は-1(負)、逆に{i1,v1}\{ i_1, v_1 \}の要素を{i2,v2}\{ i_2, v_2 \}の要素より後に回したい場合には1(正)を返す関数になっています。

ちなみに0を返すと、その二つの要素は同じだったということになりますが、-1でもないし1でもなかった順位をつけられない状態と同じですので、0の場合は特に関数に実装してもしなくても結果は同じです。

ということで降順にソートしたい場合には

            
            function cmp_num_val(i1, v1, i2, v2) {
    if (v1 > v2) {
        return -1;
    } else {
        return 1;
    }
}
        
となります。

この程度の例ではカスタムソートで無くてもできますが、文字列ようなキー値を使ってもソートが可能です。例を以下の
2.shに示します。

            
            #!/bin/bash

EXAM_SCORE=$(cat << EOF
Ichiro 84
Bob 25
Wakame 76
Hanako 56
Alice 90
Kabao 53
Jam 43
Tarao 25
Cheese 12
Kasuo 47
Piyoko 88
Ikura 29
EOF
)

echo "$EXAM_SCORE" | awk '
function cmp_str_ind(i1, v1, i2, v2) {
    if (i1 < i2) {
        return -1;
    } else {
        return 1;
    }
}
{
    data[$1] = $2;
}
END {
    PROCINFO["sorted_in"] = "cmp_str_ind";
    for (j in data) {
        printf("data[%s] = %s\n", j, data[j]);
    }
}
'
        
これの実行結果は、

            
            $ chmod +x 2.sh
$ ./2.sh
data[Alice] = 90
data[Bob] = 25
data[Cheese] = 12
data[Hanako] = 56
data[Ichiro] = 84
data[Ikura] = 29
data[Jam] = 43
data[Kabao] = 53
data[Kasuo] = 47
data[Piyoko] = 88
data[Tarao] = 25
data[Wakame] = 76
        
となりキーがアルファベット順で昇順となっています。なお、日本語でも比較可能ですが、ユニコードのコードポイント値で比較されているようです。

ということで、この
PROCINFO["sorted_in"]に割り当てる関数を自由にカスタマイズすることでかなり柔軟なロジックを使って配列をソートさせることが可能となっています。


asort/asorti関数でカスタムソート

次にPROCINFO["sorted_in"]とは別のカスタムソートもやってみます。

値をソートする ~ asort

まずは配列の値でソートさせるawkのビルドイン関数であるasortを使った例を以下にあげます。

asort関数は以下の用法でカスタムソート出来ます。

            
            asort(ソートする元の配列, ソート結果配列, ソート処理関数)
        
以下のスクリプトを3.shとして保存しておきます。

            
            #!/bin/bash

EXAM_SCORE=$(cat << EOF
Ichiro 84
Bob 25
Wakame 76
Hanako 56
Alice 90
Kabao 53
Jam 43
Tarao 25
Cheese 12
Kasuo 47
Piyoko 88
Ikura 29
EOF
)

echo "$EXAM_SCORE" | awk '
function cmp_num_val(i1, v1, i2, v2) {
    if (v1 < v2) {
        return -1;
    } else {
        return 1;
    }
}
{
    data[$1] = $2;
}
END {
    asort(data, sorted_data, "cmp_num_val");
    for (j in sorted_data) {
        printf("sorted_data[%s] = %s\n", j, sorted_data[j]);
    }
}
'
        
これを実行しますと、

            
            $ chmod +x 3.sh
$ ./3.sh
sorted_data[1] = 12
sorted_data[2] = 25
sorted_data[3] = 25
sorted_data[4] = 29
sorted_data[5] = 43
sorted_data[6] = 47
sorted_data[7] = 53
sorted_data[8] = 56
sorted_data[9] = 76
sorted_data[10] = 84
sorted_data[11] = 88
sorted_data[12] = 90
        
これは前節での1.shの実行結果と並び替えた順序は同じですが、ソートされた配列のキーは1から始まる配列要素のインデックスになっていることに注意してください。

また、ソート元の指定した配列(ここではdata[*])のキー情報はソート後の配列には反映されないので、元の配列のキーと一緒に平行処理したい場合には、
PROCINFO["sorted_in"]を利用したカスタムソートの方が向いています。

キーをソートする ~ asorti

配列のキーに作用するソート操作の関数はasortiが利用出来ます。

            
            asorti(対象配列, ソート結果配列, ソート方法)
        
これを上節の2.shと同等のスクリプトで仕立て直ししたものを4.shとして以下のような内容で編集します。

            
            #!/bin/bash

EXAM_SCORE=$(cat << EOF
Ichiro 84
Bob 25
Wakame 76
Hanako 56
Alice 90
Kabao 53
Jam 43
Tarao 25
Cheese 12
Kasuo 47
Piyoko 88
Ikura 29
EOF
)

echo "$EXAM_SCORE" | awk '
function cmp_str_ind(i1, v1, i2, v2) {
    if (i1 < i2) {
        return -1;
    } else {
        return 1;
    }
}
{
    data[$1] = $2;
}
END {
    asorti(data, sorted_data, "cmp_str_ind");
    for (j in sorted_data) {
        printf("sorted_data[%s] = %s\n", j, sorted_data[j]);
    }
}
'
        
これを実行させてどうなるかというと、

            
            $ chmod +x 4.sh
$ ./4.sh
sorted_data[1] = Alice
sorted_data[2] = Bob
sorted_data[3] = Cheese
sorted_data[4] = Hanako
sorted_data[5] = Ichiro
sorted_data[6] = Ikura
sorted_data[7] = Jam
sorted_data[8] = Kabao
sorted_data[9] = Kasuo
sorted_data[10] = Piyoko
sorted_data[11] = Tarao
sorted_data[12] = Wakame
        
という風に元の配列のキー値が新たに値として並び替えられた配列として出力されています。また、この結果として得られた配列のキーは1から始まるインデックスが割り振られていることにも注意が必要です。

先ほどのasort関数でキーの内容が反映されなかったときと同様で、asorti関数を使った場合に元配列の値(ラベル)の情報は反映されませんので、ソートした配列の要素の内容を全て残しておきたい場合には、
PROCINFO["sorted_in"]を使ったカスタムソートを使った方が賢明と言えるでしょう。


まとめ

awk内部のソートする工程でも、今回のようなテクニックを駆使すると、かなり自由度の高い並び替えが効率良く仕込めることが分かりました。

これでデータをソートするときに逐一awkのコマンドの外に出して、カスタムソート用のsortコマンドでパイプして、再びawkにパイプする...というコマンドを繋ぐような処理もawkだけで完結するかと思います。

今回は
PROCINFO["sorted_in"]を利用した方法と、asort/asorti関数を利用した方法の2つを具体例を交えながら説明しました。

これらのカスタムソートの方法には若干ソートした返す結果が異なりますので、その都度目的に適した方法を選択していく必要があります。

参考サイト

The GNU Awk User’s Guide - 12.2.1 Controlling Array Traversal

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。