技術研究所のまつけんです。

 

今回はデータクレンジングの話です。データクレンジングというのは、データを機械学習に流し込む前に全体的に眺めて、必要なら整形する作業です。KaggleやSIGNATEなどのコンテストではクレンジングされた状態のデータが提供されますが、実際の業務では、必ずしもそうとは限りません。そして、それはデータの中身に限った話ではありません。フォルダやファイルの名前が一定の規則に従ってない場合、それを規則的な名前にするところから始まります (規則的な名前でないとループなどで処理したりするのに不便です)。

機械学習に使う大きなデータをよそから転送する際、たいてい、DVDやアップローダなどで受け取ります。後者の場合はzipなどでアーカイブ (および圧縮) されていることが多いのですが、zipファイルが数十個ある場合もあります。今回の記事では、転送したデータを確認し、フォルダ名、ファイル名などを扱いやすい形に変換する過程について、説明します。データ数が膨大な場合を想定していますので、多くはbashスクリプトにより自動化しています。といっても、UNIX/Linuxをインストールする必要があるわけではありません。今から紹介する手法はcygwin上で行うことを想定しています (スクリプトは全てcygwinのbashで動作確認しています)。

今回は、機械学習用のデータについて、フォルダ構成を確認し、必要に応じて整形する手順を紹介したいと思います。

なお、今回の記事では、幾つかのコマンド (ls、find、cat、cut、wc、grep、sed、sort、less、tar、rsync) などを使いますが、個別の解説は省略します。これらのコマンドのオプションなど詳細を調べたい場合は、検索エンジンなどで「linux ls」や「linux cat」を検索してみてください (「cat」だけで検索すると猫関連が出てきてしまいますからね)。また、たくさんのスクリプトが登場しますので、「なぜ、それで動くのか」についても解説しません。ご興味を持たれた場合は、途中にechoやprintfを入れたりして、途中経過を確認してみてください。

アーカイブの展開

アーカイブされたファイルを受け取った場合、まず最初にやることは展開です。3つくらいならば手作業でも良いのですが、数十、数百の圧縮ファイルを展開するのであれば、自動化は必須でしょう。

また、複数のzip/tar/tgzに分かれている場合は、異なるフォルダに展開します。なぜなら例えば、a.tgzとb.tgzの双方に「data/data_001.csv」というファイルが含まれていた場合、上書きしてしまうからです。これも必要に応じて自動化が必要です (数十個のtgzファイルを別のフォルダに展開するのは手作業は現実的ではないので)。複数のtgzファイルを別々のフォルダに展開するには、以下のスクリプト:

ls *.tgz | while read F
do
B=`basename "$F" .tgz`
echo "$B"
mkdir -p "$B"
( cd "$B" ; tar xfz ../"$F" )
done

を実行します。ただし、このスクリプトは、tgzファイルのファイル名にスペースが含まれていると正しく動作しません。その場合は、スペースを「_」などに置換したいところですが、「data 123.tgz」と「data_123.tgz」が両方とも存在する場合、上書きしてしまうことになります。そこで、まずはこちらのスクリプトを実行します:

ls *.tgz | grep " " | while read SRC
do
DST=`echo "$SRC" | sed -e 's# #_#g'`
if [ -e "$DST" ]
then
echo "$DST exists."
else
echo "$SRC -> $DST"
# mv -i "$SRC" "$DST"
fi
done

もしも、スペースを「_」に置き換えた名前のファイルが存在する場合は、その旨を表示してくれます。その場合は、「sed -e ‘s# #_#g’」を「sed -e ‘s# #__#g’」や「sed -e ‘s# #_space_#g’」などに置き換えて再実行します。重複が確認されなくなったら、「# mv -i $SRC $DST 」の「#」を外して再実行すれば、名前にスペースを含むファイルはrenameされます。このように「実際の変更はせず、変更後の姿を表示する」ことをdry runと呼びます。UNIX/Linuxのコマンドのうち、パラメタを間違えるとダメージが大きいもの(rsyncやsvnなど)にはdry runオプションが実装されています。念のため、mvには-iを指定することで上書きを防ぎます。

data_000.tgz、data_001.tgz、data_002.tgzという3つのファイルを展開した場合、以下のようになります:

$ ls
data_000/ data_000.tgz data_001/ data_001.tgz data_002/ data_002.tgz

なお、while/do/doneなどを使って複数行にわたるコマンドを、エディタ (「メモ帳」など) でシェルスクリプトをファイルとして保存してから実行する場合、改行コードに注意する必要があります。エラーとなる場合は、dos2unixなどで改行コードを変換します。cygwinのviなどで編集するか、「cat > ファイル名」とctrl-dの組み合わせでファイル化すれば、この問題は起きません。

DVDなどで、圧縮されていない状態で受け取った場合は、適当なフォルダにコピーするだけです。その場合は、何らかの事情で中断した場合に途中からやり直せるよう「rsync -au コピー元/ コピー先/」がオススメです(コピー元の後ろに「/」を付けるか付けないかで挙動が変わるので注意が必要です)。

データ数の確認

続いて、データ数を確認します。機械学習において、データの量と質が性能を左右します。また、今後、作業中に誤ってデータを消してしまったとしても、記録が残っていれば確認や復旧が楽になります。

フォルダを右クリックして「プロパティ」を表示すれば、フォルダ数とファイル数を確認できますが、そのうちの、幾つがjpgファイルで、幾つがcsvファイルなのか、などはわかりません。それには、findコマンドを使います。受け取ったデータが主にjpgとcsvであることが分かっている場合は、

find . -type d | wc -l # フォルダの数
find . -type f -iname "*.jpg" | wc -l # jpgファイルの数
find . -type f -iname "*.csv" | wc -l # csvファイルの数

のようにしてカウントします (UNIX/Linuxでは、フォルダは「ディレクトリ」と呼ぶので、フォルダ検索はfではなくdです)。予めデータ数が分かっている場合は、その数値と照合します。そして、念のため、jpgとcsv以外が含まれていないかチェックしたい場合は、以下のコマンドを実行します:

find . -not -iname "*.jpg" -and -not -iname "*.csv" | head

jpgとcsv以外のファイルが大量にあった場合に備えて、最後にheadかlessを付けます。

また、フォルダの階層構造は、tree -dで一覧表示することが出来ます。

さて、フォルダ構造やファイル構成が全くわからない場合は、どうすれば良いのでしょうか? そういうときのためにフォルダのサマリを表示するシェルスクリプトsummarise_folder.shを作成しました:

if [ $# -eq 0 ]
then
TGT=.
else
TGT=`echo "$1" | sed -e 's#/$##'`
fi
echo "=== $TGT ==="
echo "--- Folders ---"
LEVEL=0
while read N
do
echo "Level $LEVEL: $N"
LEVEL=$(($LEVEL + 1))
done < <( find "$TGT" -type d | sed -e 's#[^/]##g' | sort | uniq -c | sed -e 's#[^0-9]##g' )
echo -n "Total: "
find "$TGT" -type d | wc -l
echo -n "Empty: "
find "$TGT" -type d -empty | wc -l
echo "--- Files ---"
EXTS=()
while read EXT
do
EXTS+=("$EXT")
done < <( find $TGT -type f | xargs -n 1 basename | grep -F . | rev | cut -f 1 -d . | rev | sort --ignore-case )
for EXT in "${EXTS[@]}"
do
echo "$EXT"
done | uniq -c | sed -e 's# *\([0-9]\+\) \+\(.*\)#*.\2: \1#' | head -n 100
TOTAL=`find $TGT -type f | wc -l`
echo No-Ext: $(($TOTAL - ${#EXTS[@]}))
echo Total: $TOTAL
echo -n "Empty: "
find "$TGT" -type f -empty | wc -l

調べたいフォルダを指定して実行するとフォルダの階層の深さや拡張子ごとのファイル数などを表示します。実行結果の例はこちらです:

$ ./summarise_folder.sh data_20191218
=== data_20191218 ===
--- Folders ---
Level 0: 1
Level 1: 10
Level 2: 1
Level 3: 2
Level 4: 1
Total: 15
Empty: 3
--- Files ---
*.avi: 7
*.jpeg: 10
*.JPG: 2
*.jpg: 6
*.mp4: 4
*.png: 112
*.txt: 3
No-Ext: 2
Total: 146
Empty: 0

のようになります。このフォルダの場合は、最大4階層 (level 0はdata_20191218自身なので常に1ですが、念のため表示しています) で、15個のフォルダ (うち3個は空) があり、動画ファイル (AVI/MP4) が7+4=11個、静止画ファイル (jpeg/JPG/jpg/png) が10+2+6+112=130個、テキストファイル (txt)が3個、拡張子なしファイルが2個、合計146個のファイル (空のファイルは無し) があることがわかります。

なお、「| xargs -n 1 basename」は、data.org/dataのような場合に備えて、「| head -n 100」で制限しているのは、data.2021-01-01のような名前のファイルが大量にある場合に備えてのことです。

命名規則の把握

次は、ファイルやフォルダがどのような規則で命名されているか調べます。ファイルの命名規則は、各データをループで処理したい場合やファイル名の一部をラベルとして使いたい場合などに、重要となります。

命名規則が判明している場合は自分で調べる必要はありませんが、確認は必要でしょう。

find -type d | cut -c 3- | sort > folders.txt
less folders.txt

find -type f | cut -c 3- | sort > files.txt
cat files.txt | xargs ls -l > files-info.txt
less files.txt

で、大まかに眺めて傾向を掴みます。findはソートされていない結果を返すため、別途ソートします。また、ソートは時間が掛かることが多いので、結果を保存して、それをlessで閲覧する形にします。例えば、フォルダ一覧が

data_000
data_000/20101121-090928_a_138275156280588515576727326950
data_000/20101121-090928_b_138275156280588515576727326950
data_000/20101121-090928_g_138275156280588515576727326950
data_000/20101121-090928_n_138275156280588515576727326950
data_000/20110301-011826_a_782630723795512393381575689977
data_000/20110301-011826_g_782630723795512393381575689977
data_000/20110301-011826_n_782630723795512393381575689977
data_000/20110502-195236_a_189833214755410099143750472105
data_000/20110502-195236_b_189833214755410099143750472105
data_000/20110707-085504_a_723056903458619668124944545243
data_000/20110707-085504_g_723056903458619668124944545243
data_000/20110707-085504_n_723056903458619668124944545243
data_000/20120407-180406_a_144626821933208943569751697639
data_000/20120407-180406_b_144626821933208943569751697639
data_000/20120407-180406_g_144626821933208943569751697639
data_000/20120509-133753_g_601292848447262840752490847720
data_000/20120509-133753_n_601292848447262840752490847720
data_000/20120513-204704_g_205649298616841769814667483102
data_000/20120513-204704_n_205649298616841769814667483102
data_000/20120725-104143_b_936448291128674474403385360480
data_000/20120725-104143_g_936448291128674474403385360480
data_000/20120725-104143_n_936448291128674474403385360480
data_000/20120824-024826_a_340888122819573512521452706487
data_000/20120824-024826_n_340888122819573512521452706487
data_000/20121116-160047_a_304287279551888383844148152203
data_000/20121116-160047_b_304287279551888383844148152203
data_000/20121116-160047_g_304287279551888383844148152203
data_000/20121116-160047_n_304287279551888383844148152203

のような感じだった場合、「YYYYMMDD-hhmmss_x_nnn…」であると予想できます。つまり、年月日、時分秒、アルファベット1文字、30桁の数字が並んでるということです (勘の良い方は「a/b/g/nなのでアルファベットは2文字も有り得る」ことにお気付きかと思いますが)。そして、同じ年月日時分秒を持つものが複数(見たところ2~4個)あるようです。残りのフォルダ(data_001やdata_002)も同じ感じなのか、チェックしましょう。lessで閲覧していれば、スペースキーなどでスクロールできますが、フォルダ数が数千、数万ある場合は、data_001やdata_002を検索してジャンプします (検索するには「/」を押します)。

だいたいの傾向を見たところで、本当に「YYYYMMDD-hhmmss_x_nnn…」なのか確認を行います。この仮定が正しければ文字数は一定なので、各行の文字数をカウントして集計すれば良さそうです。が、それをそのままスクリプトにすると複雑になりますし、そこまでする必要はありません (数万行であれば、実行時間もそれなりに掛かります)。

$ cat folders.txt | sed -e 's#.#x#g' | uniq | sort | uniq | head
xxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

で、十分です。例によって、保険として最後にheadを付けていますが、これの実行結果が1行だけならば、すべて行の文字数が同一ということになります。今回の例では、folder.txtには、「data_000」などの第1階層のフォルダ名も含まれていますから、出力は最低でも2行になります。

なお、「| uniq | sort | uniq」の部分は「 | sort | uniq」でも同じ結果が得られますが、一旦、隣り合う行同士の重複を削除してからソートした方が負荷が低減できるので、そのようにしています。

予想に反して、出力は3行です。より、詳しく調べるには、sedのオプションを変更します:

$ cat folders.txt | sed -e 's#[0-9]#0#g' -e 's#[A-Z]#X#g' -e 's#[a-z]#x#g' | uniq | sort | uniq | head
xxxx_000
xxxx_000/00000000-000000_x_000000000000000000000000000000
xxxx_000/00000000-000000_xx_000000000000000000000000000000

どうやら、「YYYYMMDD-hhmmss_x_nnn…」以外に「YYYYMMDD-hhmmss_xx_nnn…」が存在するようです。そこの部分について詳しく調べるには、再び、sedのオプションを変更します。小文字アルファベットをxに置換する「-e ‘s#[a-z]#x#g’」を抜いて再実行します:

$ cat folders.txt | sed -e 's#[0-9]#0#g' -e 's#[A-Z]#X#g' | uniq | sort | uniq | head
data_000
data_000/00000000-000000_a_000000000000000000000000000000
data_000/00000000-000000_ac_000000000000000000000000000000
data_000/00000000-000000_ax_000000000000000000000000000000
data_000/00000000-000000_b_000000000000000000000000000000
data_000/00000000-000000_g_000000000000000000000000000000
data_000/00000000-000000_n_000000000000000000000000000000

確認できました。アルファベットの部分はa/b/g/n/ac/axの6種類ということがわかりました。無線LANの規格の名前(802.11a/802.11b/…)のようです。長さが統一されていた方が何かと便利なので、統一したいと思いますが、ここで少なくとも2つの案が浮かびます:

  • ac/axをc/xまたはC/Xに置換
  • a/b/g/nの前か後ろに「_」または「-」を一つ挿入

私の場合は、恐らく後者を選びます。なぜかというと、ソートしたときにa/b/g/n/ac/axの順(規格の古い順)に並んでくれた方が、今後の都合が良い可能性が高いと予想されるからです。「」と「-」は両方とも小文字アルファベットよりも前に来るので、a/b_/g_/n_/ac/axや-a/-b/-g/-n/ac/axならば古い順にソートされます。ただし、余計な文字が1文字増えるというデメリットがあります (古い順にソートされるようにするには、a/b/g/n/ac/axをA/B/G/N/c/xに置換するという方法もありますが、今後、baやbbなどが出現したときに対応できなくなります)。アスキーコード表を見ればわかりますが、ファイル名に使っても比較的安全な文字は、#、$、%、+、-、数字、=、@、大文字、_、小文字、~の順に並んでいますので、どのようにソートしたいかに応じて使い分けます。

それでは、a/b/g/nをa_/b_/g_/n_に置換してみましょう。まずは、dry runします:

cat folders.txt | grep -v ^data_00[0-2]$ | while read SRC
do
DST=`echo $SRC | sed -e 's#\(_[abgn]_\)#\1_#'`
if [ "$SRC" = "$DST" ]
then
echo "$SRC"
else
if [ -e "$DST" ]
then
echo "$DST exists."
else
echo "$SRC -> $DST"
# mv -i "$SRC" "$DST"
fi
fi
done

問題が無さそうならば、mvの前の「#」を外して再実行します。今回の例では「_[abgn]_」にヒットするのは「無線LAN規格」の部分だけでしたが、そうでない場合は前後も含めるようクエリを変更するか、別途工夫します。

まとめ

今回は、機械学習用のデータについて、フォルダ構成を確認し、必要に応じて整形する手順を紹介しました。次回は、フォルダ名・ファイル名の短縮や、重複データを検出して削除・移動などについて紹介したいと思います。また、可能な範囲での整合性チェックについても紹介できればと考えております。

おまけ (メイキング)

このようなブログを書く上で最も神経を使うのは、架空の事例作りです。この記事は、所謂「実話を基にしたフィクション」なので、実際にありがちなフォルダ構成を再現したような架空のフォルダ構成を作りました。年月日時分秒から末尾の数値に至るまで全て乱数です。Excelで「TEXT(RAND() * (max – min + 1) + min, fmt)」という基本式で生成し、CONCATで結合しています。例えば、年月日の「月」ならmin=1、max=12、fmt=”00″です。末尾の30桁の数字は、min=1e+9、max=1e+10、fmt=”0000000000″を3つ連結しました。Excelと乱数をうまく使えば、それらしいダミーデータが作れるのは便利ですね。

今回の記事のスクリプトを試してみたけど、適当なデータが手元に無いという方は、以下のスクリプト:

SD=`date +%d%H%M%S` # random seed
rand () {
SD=$(($1 * 1234 + 4567))
SD=$(($SD % 100000000))
echo $SD
}
make_dir_and_files () {
NAME=`printf "%08d_$1_%08d" $2 $3`
mkdir -p "$DATADIR/$NAME"
touch "$DATADIR/$NAME/$NAME.jpg"
touch "$DATADIR/$NAME/measurements.csv"
}
for N1 in 0 1 2
do
DATADIR=`printf "data_%03d" $N1`
mkdir -p "$DATADIR"
for N2 in seq 10
do
SD=`rand $SD` ; R1=$SD
SD=`rand $SD` ; R2=$SD
if [ $(($SD % 3)) -eq 0 ]
then
make_dir_and_files a $R1 $R2
fi
if [ $(($SD % 5)) -eq 0 ]
then
make_dir_and_files b $R1 $R2
fi
if [ $(($SD % 3)) -eq 1 ]
then
make_dir_and_files g $R1 $R2
fi
if [ $(($SD % 7)) -eq 0 ]
then
make_dir_and_files n $R1 $R2
fi
if [ $(($SD % 5)) -eq 1 ]
then
make_dir_and_files ac $R1 $R2
fi
if [ $(($SD % 3)) -eq 2 ]
then
make_dir_and_files ax $R1 $R2
fi
done
(cd "$DATADIR" ; tar cfz ../"$DATADIR".tgz *)
done

で簡易的に作ることが出来ますので、適当な回数だけ実行してダミーのフォルダ構造(および、それを圧縮したアーカイブ)を作って試してみてください。

おまけ2 (プロセス置換)

summarise_folder.shの中で「< <(コマンド)」という記法を利用しています。これは「プロセス置換」という記法で

コマンド1 > tmpfile
コマンド2 tmpfile
rm -f tmpfile

コマンド2 <( コマンド1 )

と等価に、また、

コマンド1 > tmpfile
コマンド2 < tmpfile
rm -f tmpfile

コマンド2 < <( コマンド1 )

と等価になります。他にも

$ paste <( echo date: ) <( date ) -d ' '
date: Fri Jun 4 11:27:58 JST 2021

$ diff <( tar tf data.tar | sort | sed -e 's#/$##' ) <( find data | sort ) | grep "^> " # data.tar作成後に追加されたアイテムを表示する (tar dfでは出来ない)

のようなことが出来ます。詳しくは、「プロセス置換」や「process substitution」で検索してみてください (このような記法は基本的に「<」や「|」などの記号なので、記法の名称がわからないと検索が難しいのが困りますね)。

なお、パイプを使った書き方:

ls *.tgz | while read F

も併用していますが、whileの中で変数を作成または更新しない場合は、こちらにしています。この記法ではwhile内部が別プロセスになってしまうため、内部で変数を更新しても効果が得られません。例えば、1から10の総和を計算しようとしても

SUM=0
seq 10 | while read N
do
SUM=$((SUM + $N))
done
echo $SUM

では55とならず、0となってしまいます。正しく計算するには、ループ部分を

while read N
do
SUM=$((SUM + $N))
done < <( seq 10 )

または

for N in `seq 10`
do
SUM=$((SUM + $N))
done

とします。ただし、後者の「for 変数 in コマンド」では、「コマンド」の出力に半角スペースが含まれるような場合には期待通りの動作をしません。

おまけ3 (.bashrc)

今回の記事のようなファイル操作を行うことが多いので、~/.bashrc (または~/.bash_aliases) には

function mktar() { # tar cf dir.tar dir → mktar dir
D=`echo $1 | sed -e 's#/\+$##'` # 末尾のスラッシュ(複数も有り得る)除去
tar cf "$D".tar "$D"
}
function mktgz() { # tar cfz dir.gz dir → mktgz dir
D=`echo $1 | sed -e 's#/\+$##'` # 末尾のスラッシュ(複数も有り得る)除去
tar cfz "$D".tgz "$D"
}
alias diffr='diff -r --brief'
alias allf='find -type f'
alias alld='find -type d'
alias alljpg='allf -iname "*.jpg" -or -iname "*.jpeg"'
alias q='sed -e "s#.*#'\''\0'\''#"' # 行頭と行末に'を付ける
alias qq="sed -e 's#.*#\"\0\"#'" # 行頭と行末に"を付ける

を追記しています。最後の「q」と「qq」は

alljpg | q | xargs コマンド

のようにすることで、検索結果のファイル名に半角スペースや特殊文字が入っていてもエラーにならないように出来ます。

もしよろしかったら、使ってみてください。