【Awkでデータ解析のすゝめ】gawk(GNU AWK)でカスタムソートを使ってみる


※ 当ページには【広告/PR】を含む場合があります。
2020/02/08
2023/06/26
【シェルスクリプトで機械学習】Awkで機械学習で使える高速データ処理〜ガウス分布ノイズの生成方法
【Awkでデータ解析のすゝめ】Awkのみで2つのファイルを効率的に結合させる方法

ビッグデータ解析の分野において、とりわけ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のような高機能な関数を臆すこと無く利用することができるようになります。

Macユーザーの注意点〜nawkからgawkに移行する場合

MacOSで標準となっているのは、先程説明したようなmawk同様に、軽量で簡素なAWKの拡張実装である
「nawk」になっています。

このnawkについては深くは言及しませんが、当然ながらモダンなgawkの機能と一部コンパチブルではないので、OS間を通じて同じシェルスクリプトを実行する際に困ってしまいます。

ということで、MacOSでもデフォルトのawkコマンドをgawkコマンドに変えておくほうが賢明といえます。

MacOSにgawkを導入するのは簡単で、
「HomeBrew」から一発導入することが可能です。

            
            $ brew install gawk
        

参考|Homebrew Formulae

MacOSへのgawkの導入に関しては、HomeBrewが環境変数のパスをよしなにやってくれてそのままawkコマンドに置き換わるときと、そうでないときで見解が分かれてそうな気がします。

手元の環境では、自動でawkコマンドが置き換わることはなかったので、ホームディレクトリの
「.zshrc(.bashrc)」、もしくは「.zprofile(.bash_profile)」にgawkのパスを手動設定します。

            
            $ AWK_PATH=$(which gawk)

#👇.zshrcに書き込む場合
$ echo 'PATH="'$AWK_PATH'/opt/gawk/libexec/gnubin:$PATH"' >> ~/.zshrc
$ source ~/.zshrc
        
で、awkコマンドがgawkに置き換わっていれば設定完了です。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】シェルスクリプトをこれから学びたい人のためのオススメ書籍&教材特集

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