自然言語処理でよく使うシェルの機能


はじめに

記事の内容

本記事では初心者向けに自然言語処理分野の研究をするうえで知っておくと良いシェルのコマンドや機能を紹介します。研究を進める過程で自然に色々なテクニックを覚えていくという学習プロセスを経た方は私含めて多いと思いますが、最初からある程度定石的なものを知っていれば些細な実装よりも本質的な部分に時間を割けられて良かっただろうなという思いがあり、この記事を書きました。シェルはBashを想定しています。シェルの代表的な機能やコマンド、考え方などを紹介するのみで個別のNLPタスクには深入りしません。

対象

情報系の学科や研究科に入って間もない初心者だが基本的なUnixコマンド(cd, ls, mkdir, echo, catなど)はご存知の方を想定して書いています。中級者の方にとっては既知のことやベターなやり方を知ってることも多いと思いますので知識確認程度に読んでもらえればいいと思います。

筆者

私は大学院修士課程で自然言語処理(特に機械翻訳)をやっていました。自然言語処理についてもUnixについても上級者とは言えませんが実験等はLinuxのサーバ上で行っていたので自然言語処理の研究をするうえで最低限必要なシェル機能の扱いは習得できたかなと思います。実験は機械翻訳関連のことがほとんどだったので記事で取り上げる例も多少その方向にバイアスがかかってるかもしれません。

シェルコマンドをいつ使うか

どの範囲の作業をシェルスクリプトでやるかという話。ざっくり言うと個別のアルゴリズムやモデルの記述、インタラクティブな分析作業にはPython等高級な言語を使い、それ以外をシェルスクリプトで書くのが普通だと思う。

  • データの前処理
  • 実験を通しで実行
  • ちょっとしたデータの確認(シェルコマンドを手打ち)

プロセスの入出力とパイプ

標準入出力とコマンドライン引数

プログラムの多くは入力データを受け取って何かしらの処理や変換をして出力する関数として抽象化できる(ような設計になっている)。

y=f_{\theta}(x)

$\theta$は関数の挙動を調整するパラメータである。
つまりプログラムを走らせるユーザーは情報として$\theta$と$x$をプログラムに渡し、実行結果として$y$を受け取ることになる。
Unix系OSでは$\theta$をコマンドライン引数、$x$をプロセスの標準入力として渡し、$y$をプロセスの標準出力から受けとるのが普通である。sed, awk, grepなど多くのテキスト処理プログラムがそのように設計されているし、あなたもそのようにプログラムを書くべきである(後述)。ちなみにプログラムを実際に起動したものをプロセスと呼ぶが以降ではコマンド・プログラム・プロセスをそんな厳密に区別せずに使う場合がある。

標準入出力のリダイレクトとパイプ

標準入力の与え方は以下の2通り。

# ファイルfileの内容をプロセスAの標準入力に与える場合(リダイレクト)
A < file

# プロセスBの標準出力をプロセスAの標準入力に与える場合(パイプ)
B | A

標準出力は以下の3つの扱い方が基本。

# プロセスAの標準出力をリダイレクトでファイルに保存
A > file   # fileの内容を上書き
A >> file  # fileの元の内容は消さずに末尾に追記

# プロセスAの標準出力をパイプでプロセスBの標準入力に接続
A | B

# プロセスAの標準出力を画面に表示
A

リダイレクトやパイプを明示的に書かなければ3番目のケースのように画面に表示されるのが基本だが、Aを実行しているシェルやブロックの標準出力がリダイレクトまたはパイプされてる場合、Aの標準出力はそのリダイレクトまたはパイプ先に接続される。

# "echo abc"と書かれたシェルスクリプトA.shを実行してリダイレクト
./A.sh > file
# fileの内容は以下になる
# abc

# ブロックを丸ごとリダイレクト(サブシェル)
(
    echo abc
    echo xyz
) > file
# fileの内容は以下になる
# abc
# xyz

# ブロックを丸ごとリダイレクト(ループ)
for c in a b c; do
    echo $c
done > file
# fileの内容は以下になる
# a
# b
# c

標準エラー出力

標準出力はあくまで関数の成果物を出力するためのものである。一方メインの成果物以外にも警告やログ、処理の進捗などをユーザーに渡したい場合がある。そのような場合プログラムは標準出力に代わる第2の出力ストリームとして標準エラー出力を利用する。

標準エラー出力のファイルへのリダイレクト

A 2> error_file

標準エラー出力のパイプ

A 2>&1と書くことでAの標準エラー出力を「Aの標準出力がつながってる先(ファイルや別のプロセスの標準入力)」につなげることができる。

標準エラー出力を標準出力と同じファイルにリダイレクト
A > file 2>&1
# 以下のように書いても同じ(こっちの方が楽)
A &> file

# 以下のように書くと意図した動作にならない
A 2>&1 > file
# 理由:
# リダイレクト記法は左から順に評価されるので2>&1は>fileよりも先に評価される。
# 2>&1が評価された時点ではAの標準出力はfileにつながれてない(基本的には画面出力につながれてる)
# だから標準エラー出力はfileにつながれない
標準エラー出力をパイプ(使うことはほとんどない)
A 2>&1 | B

パイプを使ったプロセスのチェーン(パイプライン)

良くないプログラム

データXをデータYに変換するプログラムの書き方として標準入出力を使わずにコマンドライン引数だけで済ます方法もあり得る。以下のような使い方をするプログラムである。

command data_x_filename data_y_filename

プログラムcommandは入力ファイル名と出力ファイル名をコマンドライン引数で受け取り、プログラム内でdata_x_filenameをopenして読み込んで処理をして結果をdata_y_filenameに保存する。
原則としてこのようなプログラム設計は推奨されない。先に述べたようにコマンドライン引数は関数$f_\theta(x)$のパラメータ$\theta$を渡すにとどめ、データフローは標準入出力を使うべきである。その理由は二つある。

  • コマンドライン引数のフォーマットを覚えるという認知負荷を低減(些細な理由)
  • パイプライン化ができる(重要な理由)

パイプライン

データxに対してA, B, Cという三つの関数を順に適用して最終結果yを得る合成関数$(C\circ B\circ A)(x)$は以下のようにパイプを繋げて書ける。

A < file_x | B | c > file_y

# 以下でも同じ
cat file_x | A | B | C > file_y

自然言語処理のデータ処理は特にこのようなパイプラインと相性が良い。例えば日英翻訳では以下のようなパイプラインが考えられる。

cat 生の日本語データ(1行1文).ja \ 
    | 単語分割 --dict 辞書名 \
    | サブワード化 --model 日本語サブワード語彙ファイル \
    | 文頭・文末トークンの付与 \
    | ニューラル機械翻訳 --beam-size 5 \
    | サブワードの結合 --model 英語サブワード語彙ファイル \
    | 英語の句読点等の結合 \
    > 英語の翻訳結果データ.en

パイプラインで書くメリット

  • 中間ファイルが不要で書きやすい
    • teeコマンドを挿入して中間ファイルを残すことも可能
  • 読みやすい
  • マルチコアCPUを有効活用し高速に処理できることがある↓

複数のコマンドがパイプで結合されたコマンドを実行すると即座にすべてのコマンドが起動され、並列で処理が行われる。自然言語処理では各行を独立に扱えるようなデータやタスクが多く、そのような場合各プロセスは前段のプロセスから1行受け取りそれを処理したら結果を直ちに後段のプロセスに渡してまた次の一行を受け取る・・・という繰り返しでデータを処理することができる。すなわち各プロセスは前のプロセスがすべてのデータを処理しきるのを待つことなく自分の仕事を開始することができ、同時並列に各プロセスの処理を進められる。
結果として処理全体にかかる時間は各プロセスの実行時間の合計ではなく、最も時間のかかる処理(上の翻訳の例ではニューラル機械翻訳)の所要時間程度となる。
※各行を独立に扱えないタスクはコーパス全体の単語のカウントとかファイルの行のソートとか。

自分でプログラムを書く場合

コーパスのcleaningとかタスクの実行のような何かしらの処理を自分でpython等で書くことは多いと思う。そのようなときに上で述べた点に留意すると利便性が高く効率の良いプログラムを書ける。

標準入出力を使う

データファイル名をコマンドライン引数で指定するのではなく、標準入出力を使う設計にすることでプログラムをパイプラインに挿入することができるようになる。

Iterator的な書き方をする

データの各行を独立に扱えるようなタスク(例えば各行について行中の単語数を数えるとか、各行に対して何かしらのラベル付けをするとか)であれば1行読み込み処理して出力することを繰り返す(iterate)設計にすることで先述したようにパイプライン中で並列処理の恩恵を受けられる。
また、iteratorを使った設計にすることでメモリ使用量を抑えることができる。

各行の単語数を出力(パイプラインで並列実行されない実装)
import sys
#一旦データを全て読み込んでしまう。データが大きいとメモリがたくさん食われる。
counts = [len(line.split()) for line in sys.stdin] 
for count in counts:
    print(count)
各行の単語数を出力(パイプラインで並列実行される実装)
import sys
# データを少しずつ読み込んでは出力するを繰り返す
for line in sys.stdin: 
    print(len(line.split()))

コマンド置換

`コマンド群`または$(コマンド群)と書くとこの部分が実行時にコマンド群の実行結果に置き換えられる。このとき改行はスペースに置き換えられる。

for i in $(seq 3); do
    echo $i
done
# 1
# 2
# 3

`コマンド群`だと入れ子にできないが$(コマンド群)なら入れ子にできるので後者を使うことが多い。

プロセス置換

読み込み用のプロセス置換

コマンドライン引数としてファイル名を受け取り、そのファイルをopenして内容を読み込むプログラムがたまにある。通常はこのようなプログラムを起動するときユーザーは実在するファイルの名前を指定することになる。

# ファイルa.txtの内容↓
a
b
c
# ファイルx.txtの内容↓
x
y
z

# ファイル名を引数として受け取って、ファイルの内容を横に並べるpasteというコマンドを使う
$paste a.txt x.txt
# 出力↓
a    x
b    y
c    z

しかしコマンドに読み込ませたい内容が実在するファイルではなく別のプロセスの実行結果であるケースもある。こういう場合愚直にやるなら中間ファイルを利用することになる(pasteコマンドは標準入力を持たないからパイプで入力することはできない)。

$A > temp_a.txt    # 中間ファイルにコマンドAの結果を書き出す
$X > temp_x.txt    # 中間ファイルにコマンドXの結果を書き出す

$paste temp_a.txt temp_x.txt
a    x
b    y
c    z

読み込み用のプロセス置換<(コマンド群)を用いると「コマンド群の実行結果」をファイルとして扱うことができる。これを利用して上のような処理は中間ファイルを使わずに書ける。

$paste <(A) <(X)
a    x
b    y
c    z

たまに二つの成果物ファイルをちょっといじって並べて閲覧したいときがあって使う。
原理をもう少し詳しく書くと、<(A)と書くことでコマンド群Aの標準出力のストリームを指し示す文字列(仮想的なファイル名)が返される。この文字列をファイル名と見て読み込みモードでopenするとある特定のファイルではなくプロセスAの標準出力に接続される。どんな文字列が返されてるかはecho <(echo) <(echo) <(echo)みたいなのを実行してみると分かる(調べると割と奥が深い)。

書き込み用のプロセス置換

読み込み用とは逆の書き込み用のプロセス置換>(A)というのがある。コマンド群Aの標準入力を指し示す仮想的なファイル名が取得できる。

使う事例

パイプによる通常のパイプラインは一本のデータストリームしか扱えない。一方自然言語処理では途中で分岐したい場合がたま~にある。翻訳用のパラレルコーパスを作る例を以下に示す。

  • 日英のパラレルコーパス./original/train.en./original/train.jaがある
    • 2ファイルの行数は等しく、1行1文で対応している
  • この二つから以下のフィルター処理を経て./cleaned/train.en./cleaned/train.jaを作りたい
    • どちらか一方でも空行であるような文対を削除
    • どちらか一方でも512単語以上の超長文であるような文対を削除
    • 各文を文頭・文末トークン(と)で挟む
  • 以下のような処理になる
paste ./original/train.en ./original/train.ja \
    | 空文を含む文対を削除 \
    | 長文を含む文対を削除 \
    | tee >(
        cut -f 1 \
        | 文頭文末トークンの付与 \
        > ./cleaned/train.en) \
    | cut -f 2 \
    | 文頭文末トークンの付与 \
    > ./cleaned/train.ja

teeコマンドの引数にファイル名ではなくプロセス置換を指定している。
※ この例はawkコマンドを使えばプロセス置換なしでもう少し簡潔に書ける。

アスタリスクとブレースの展開

アスタリスクの展開

コマンドライン引数の中のパス文字列の中にアスタリスク*を含めると、その文字列は実行時に*をワイルドカードとしてマッチするような存在するパス全てに展開される(意味不明)

# カレントディレクトリにはa.txtとb.txtとc.txtがある
$echo ./*.txt
# ./a.txt ./b.txt ./c.txt

# forと一緒に使うことが多い
for f in ./*.txt; do
    # $fに対する処理
done

ブレース展開

文字列中に{〇〇〇,×××,△△△}があると、{}の部分が〇〇〇と×××と△△△の3パターンに置き換わった3つの文字列に展開される(語彙力)(例を見た方がはやい)
非常に便利。

$echo {1,2,3}
1 2 3

$echo A{1,2,3}B
A1B A2B A3B

# 組み合わせ展開
$echo {abc,xyz}_{1,2,3}
abc_1 abc_2 abc_3 xyz_1 xyz_2 xyz_3

# 入れ子にできる
$echo {a,b,c{1,2}}
a b c1 c2

# よくある使い方
$for f in ./{train,dev,test}.{en,ja}; do
>    echo $f
>done
./train.en
./train.ja
./dev.en
./dev.ja
./test.en
./test.ja

アスタリスク展開とブレース展開は組み合わせられる

# カレントディレクトリにtrain.en, test.en, train.ja, test.jaがある場合
$echo ./*.{en,ja}
./train.en ./test.en ./train.ja ./test.ja

変数展開

知っていれば生産性が上がるという程度。詳しくは以下を参照。
https://qiita.com/t_nakayama0714/items/80b4c94de43643f4be51

よく使うコマンド

当たり前なやつ

echo, cat, less, head, tail, wc
tailはtail -f log_fileという形で進行中の実験のlogファイルを監視するのによく使う。
wcはwc -lでコーパスの行数を、wc -wで単語数を数えられる。

sed

  • 色々できるらしいが自分の場合ほとんど置換用途でしか使わない
  • sed -r -e 's/パターン/置換後/g'という使い方が9割。
  • 正規表現がjavascriptとかpythonと若干違ってて面倒くさい

awk

  • すごく汎用性が高い。
  • 1行1レコードでスペースやタブで区切られたデータの扱いに強く、記述が簡単
  • 処理速度が非常に速い(pythonやperlなどに比べて)のでせっかちな人向け
  • ただし速いのはmawkという実装のawkであってgawk実装は若干遅いらしい。お手元のawkコマンドがどっちかはman awkでわかる。

具体的な仕様は他サイトを見てもらうとして、以下には色々使い道があるという証拠だけ書いておく。

使用例

1行1文のコーパスで各行の単語数を取得する

awk '{print(NF)}' < corpus > result

1行1文のコーパスで空文(0単語の文)を除去する(この処理はgrepでも書ける)

awk '$1' < corpus > filtered

1行1文のコーパスで単語数200以上の長文を除去する

awk 'NF < 200' < corpus > filtered

1行1文のコーパスで書く文の前後に文頭・文末トークン(<BOS>, <EOS>)を付与する

awk '{print("<BOS> " $0 " <EOS>"}' < corpus > wrapped

小文字化

awk '{print(tolower($0))}' < corpus > lowered

コーパスから語彙のリストを作る。リストは語彙サイズ分の行からなり、各行は単語w[タブ]単語wの出現数で、先頭から出現数の多い単語順に並んでいる。

cat corpus \
    | awk '{print(tolower($0))}' \
    | awk '{for(i=1;i<=NF;i++) a[$i]++} END{for(w in a) print(w "\t" a[w])}' \
    | sort -r -k 2,2 -g \
    > vocabulary

# vocabularyは↓みたいなやつになる
the     185354
and     132642
to      110778
of      102215
a       93526
that    84160

100万行のコーパスをtrain/dev/testに分割。先頭から10000行をtest、次の10000行をdev、それ以降をtrainとする。

cat corpus \
    | awk '{
        if(NR <= 10000) print($0) > "test.txt"
        else if(NR <= 20000) print($0) > "dev.txt"
        else print($0) > "train.txt"
    }'

日英翻訳のソース文データsource.jaと参照訳データreference.enと翻訳モデルの出力データoutput.enから、よくあるソースと参照訳とモデル出力を並べて見やすくしたあれを作る

paste source.ja reference.en output.en \
    | awk -F '\t' '{print("[src] " $1); print("[ref] " $2); print("[out] " $3); print("")}' \
    > summary.txt

# ↓みたいなのがsummary.txtに保存される。
# [src] 吾輩は猫である。
# [ref] I am a cat.
# [out] I'm a cat.
#
# [src] 名前はまだない。
# [ref] I have, as yet, no name.
# [out] There's no name yet.
#
# ...

モデルAとモデルBを用いて1000個の文に対してスコア付けをした。モデルAのスコアはA.scoreに、モデルBのスコアはB.scoreに、1行1スコア×1000行の形式でそれぞれ保存されている。モデルAのスコア > モデルBのスコアとなっているような文の数を数えたい。

paste {A,B}.score \
    | awk '$1 > $2' \
    | wc -l

# 以下のようにwcを使わずawkだけで書くこともできる
paste {A,B}.score \
    | awk '$1 > $2 {a++} END{print(a)}'

1000行10列のタブ区切りデータtable.txtを入力とし、$i (1\leq i\leq 10)$列目をファイルcol_i.txtへ保存したい(列の分配)。

cat table.txt \
    | awk -F '\t' 'for(i=1;i<=10;i++) print($i) > "col_" i ".txt"'

※ これはcat table.txt | cut -f $i > col_$i.txtをi=1,..,10で繰り返すことでも実現できるが、テーブルデータが前段のプロセスからパイプで送られてくるものでありtable.txtのような具体的なファイルとしては存在しない場合は一時ファイルが必要になる。awkを使えばそのようなケースでも中間ファイルが不要。

grep

文字列検索コマンド。私は実験で使ったことはあまりないが取り組んでるタスクによってはよく使うと思うので知っといた方がいい。

paste

複数の同じ行数のテキストファイルを横方向にタブ文字をはさんでつなげる。翻訳だと2言語のパラレルデータを扱うのでよく使う。pythonでいうところのzip()と同じ機能。

# こんな感じでパラレルコーパスを一つにまとめたいときに使うことが多い。
paste train.{en,ja} > train.txt

cutやawkとセットで使うことが多い。

cut

タブ区切りのテーブルデータから所望の列だけ抜き出す。awkの下位互換。

# train.txtはpasteの例で作ったもの。
cat train.txt | cut -f 1 > train.en
cat train.txt | cut -f 2 > train.ja

# 同じことはawkで以下のように書ける。
cat train.txt | awk -F '\t' '{print($1) > "train.en"; print($2) > "train.ja"}'

tee

  • 標準入力をコマンドライン引数で指定されたファイルに保存し、さらに標準出力に送る。
  • パイプライン中で途中出力をファイルに保存するのに使う。

↓テストデータを翻訳して翻訳精度(BLEU)を測る例。最終的に欲しいのはBLEUだが、途中の翻訳文なども保存する。

cat 日本語1行1文データ \
    | 単語分割 \
    | サブワード化 \
    | 日英翻訳 \
    | tee output.subword \    ← サブワード形式の出力をファイルoutput.subwordに保存
    | サブワード結合 \
    | tee output.token \    ← モデルの出力文をファイルoutput.tokenに保存
    | bleuを測るスクリプト reference.token \
    | tee bleu_score.txt    ← BLEUスコアをbleu_score.txtに保存
# 最後はリダイレクトしないのでBLEUが画面に表示される

find

ファイル検索コマンド。自分は大量のファイルからなるデータを扱うときに使った。
例えばディレクトリに数万個規模のYYYY-MM-DD-XXXXXX.xmlのような形式のファイルがあり、そのファイル名の一覧をパイプラインに流したいとき、ls *.xml | ...を起点とするとコマンド実行時に数万個のファイル名がlsのコマンドライン引数としてすべて展開されることになり無駄だし遅い。
find . -name '*.xml' | ...をパイプラインの起点とすると軽い。