技研のまつけんです。

 

前回前々回に引き続き、クレンジングの話をしたいと思います。今回は、前々回の記事で予告した「整合性チェック」です。前々回の記事の中でも、ファイル数や命名規則などのチェックを行いましたが、今回は「重複」と「欠け」などのチェックを行います。

「重複」というのは、全く同じデータが複数あることです。操作ミスで複製してしまったり、別名で保存してしまうことがあります。「欠け」のチェックは、例えば、毎月データを取得した筈なのに、そうなっていない場合に、それを検出するということです。

重複のチェック

データの単位がファイルであるか、フォルダであるか、によって扱いが異なります。

ファイル単位の場合

ファイルの場合は、直接比較しなくても、チェックサム (例えばMD5) を比較することでチェックできます (MD5が偶然一致する確率は無視しています)。単に重複するファイルがあるかどうか、だけで良ければ、以下の1行でチェックできます:

find -type f | xargs md5sum | sort | uniq -cd -w 32

重複するファイルがあれば、

3 5c9597f3c8245907ea71a89d9d39d08e ./abc.txt

のようになります。./abc.txtのMD5が5c9597f3c8245907ea71a89d9d39d08eであり、そのMD5を持つファイルが合計3個あることがわかります。しかし、これでは代表として./abc.txtだけしか表示されません。重複ファイルの完全なリストを得るには、以下のようにします:

find -type f | xargs md5sum > sums.txt
cat sums.txt | sort | uniq -cd -w 32 | awk '{print $2}' | xargs -n 1 -I{} grep {} sums.txt | sort

結果は以下のようになります:

5c9597f3c8245907ea71a89d9d39d08e ./abc.txt
5c9597f3c8245907ea71a89d9d39d08e ./abc-copy.txt
5c9597f3c8245907ea71a89d9d39d08e ./abc-copy2.txt

しかし、この方法だと一旦全てのファイルのMD5を計算することになり、時間が掛かります。そこで、チェックサムを比較したのと同じ要領で「同じファイルサイズのファイルが複数個あるファイル」だけMD5を計算するのが良さそうです:

find -type f | xargs wc -c > sizes.txt
cat sizes.txt | awk '{print $1}' | sort | uniq -cd | awk '{print $2}' | xargs -n 1 -I{} grep "^ *{} " sizes.txt | awk '{print $2}' | xargs md5sum > sums.txt

フォルダ単位の場合

全く同じ内容のフォルダを除去するmove_duplicated_folders.shを作成しました:

find . -type d | cut -c 3- > folders.txt
cat folders.txt | xargs -n 1 basename | sort | uniq -d | xargs -n 1 -I{} grep "{}" folders.txt > folders_with_identical_names.txt
mkdir -p duplicated_items
N=0
cat folders_with_identical_names.txt | while read LINE
do
if [ $N -eq 0 ]
then
SRC="$LINE"
N=1
else
RESULT=`diff -r --brief $SRC $LINE | wc -l`
if [ $RESULT -eq 0 ]
then
echo "$SRC == $LINE"
else
echo "$SRC != $LINE"
fi
N=0
fi
done > duplicated_items/list.txt
grep == duplicated_items/list.txt
grep != duplicated_items/list.txt
grep == duplicated_items/list.txt | cut -d = -f 3 | while read P
do
P2=`echo $P | sed -e 's#/#-#'`
echo "$P -> duplicated_items/$P2"
mv "$P" "duplicated_items/$P2"
done

これを用いることで、同一内容のフォルダがあった場合、一方をduplicated_itemsの中に移動します。こちらは実際の業務で用いたもので、同名のフォルダ同士のみ比較し、「同名で同一内容」と「同名だが異なる内容」をチェックする仕様になっています。

欠けのチェック

数字部分の取り出し

欠けのチェックについても、bashでcut/sed/awk/dirname/basename/paste/xargs/prinfなどで行うことも可能ですが、Python + Pandasを使った方が楽なので、そのようにします。以下のライブラリを利用します:

import glob
from pathlib import Path
import numpy as np
import pandas as pd

処理対象は、前々回の記事で利用した

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_001/20160202-184511_ax_681738386619089223387821743816
data_001/20160202-184511_g_681738386619089223387821743816
data_001/20160513-112921_ac_469145420997159190987004614138
data_001/20160513-112921_ax_469145420997159190987004614138
(略)
data_002/20170525-054622_ax_947488923267857733102498243262
data_002/20170525-054622_b_947488923267857733102498243262
data_002/20180709-170442_a_984879919960470666771475692433

という形式のツリーとします(「YYYYMMDD-HHMMSS_無線LANタイプ_何らかの管理番号」という命名規則)。まずは、処理対象の一覧を取得して、PandasのDataFrameに変換します:

paths = glob.glob('data_???/*')
df = pd.DataFrame(paths, columns=['path'])

続いて、YYYYMMDD、HHMMSS、無線LANタイプを取り出して、列として追加します:

df['stem'] = [Path(p).stem for p in paths]
df['date'], tmp = zip(*[s.split('-') for s in df['stem']])
df['time'], df['type'], _ = zip(*[t.split('_') for t in tmp])

これにより、多彩なソートや集計が可能になります。なお、「このような処理をいきなりリスト内包表記で記述するのはハードルが高い」という場合は、

dic = {}
for p in paths:
s = Path(p).stem
dic['stem'] = s
dic['date'], tmp = s.split('-')
dic['time'], dic['type'], _ = tmp.split('_')
print(dic)
break

のように「練習」するのがオススメです。

クロス集計

ソートや集計の例としては、

df = df.sort_values(['date', 'time', 'type'])
pd.crosstab([df['date']], [df['type']]) # 日付と無線LANタイプのクロス集計

などが考えられます。また、yearとmonthの列を追加することで、

df['year' ] = [d[ : 4] for d in df['date']]
df['month'] = [d[4 : 6] for d in df['date']]
pd.crosstab([df['year']], [df['month']])

 

month 01 02 03 04 05 06 07 08 09 11 12
year                      
2010 7 5 8 4 3 7 5 7 7 4 7
2011 6 3 8 7 2 1 9 8 1 7 7
2012 4 5 3 9 5 5 3 4 5 3 6
2013 0 2 8 4 4 3 6 9 2 6 6
2014 8 3 4 4 6 3 3 8 5 6 7
2015 2 4 3 6 8 7 4 3 5 1 4
2016 6 2 2 5 8 2 5 2 5 7 1
2017 1 7 4 2 3 5 8 0 2 5 1
2018 1 5 4 9 9 3 2 6 1 7 3
2019 3 9 4 7 2 7 8 2 8 8 3
2020 5 2 4 8 8 1 8 6 6 7 7

のように年と月のクロス集計を行うことも可能です。このようにすることで、2013年1月と2017年8月のデータが無いことが見て取れます。

ただし、この方法ですと、表が大きい場合は目視チェックするのが大変です。以下のようにして、データが無い年と月の組み合わせを一覧表示することも出来ます:

df['year-month'] = ['-'.join([d[: 4], d[4 : 6]]) for d in df['date']]
set(['%d-%02d' % (y, m) for y in range(2010, 2021) for m in range(1, 13)]) - set(df['year-month'])

これは、2010-01から2020-12までの文字列のリストを生成し、集合として比較を行うことで実現しています。このような「リスト同士の総当たりの組を作る処理」は、たびたび使うので、

def cross_sets(a, b):
return [(a_resp, b_resp) for a_resp in a for b_resp in b]

という形でルーチン化しています。これを使えば、先程の処理は

set(['%d-%02d' % c for c in cross_sets(range(2010, 2021), range(1, 13))]) - set(df['year-month'])

と記述できます。

クロス集計以外の集計 (単純な欠番のチェック)

独立した通し番号など、クロス集計する必要が無く、単に連続性 (欠番が無いか) だけチェックすれば良い場合もあります。例えば、

data/data-001/preprocessed/224/data_0000-000000.csv
data/data-001/preprocessed/224/data_0000-000001.csv
data/data-001/preprocessed/224/data_0000-000002.csv
data/data-001/preprocessed/224/data_0001-000003.csv
data/data-001/preprocessed/224/data_0001-000004.csv
data/data-001/preprocessed/224/data_0001-000005.csv
data/data-001/preprocessed/224/data_0001-000006.csv
data/data-001/preprocessed/224/data_0001-000008.csv
data/data-002/preprocessed/224/data_0002-000010.csv
data/data-002/preprocessed/224/data_0002-000011.csv
data/data-002/preprocessed/224/data_0002-000012.csv
data/data-002/preprocessed/224/data_0003-000013.csv
data/data-003/preprocessed/224/data_0004-000014.csv
data/data-003/preprocessed/224/data_0004-000015.csv
data/data-003/preprocessed/224/data_0004-000016.csv
data/data-003/preprocessed/224/data_0004-000017.csv
data/data-003/preprocessed/224/data_0005-000018.csv
data/data-003/preprocessed/224/data_0005-000019.csv
data/data-003/preprocessed/224/data_0005-000020.csv

のような場合、

def extract_digits(strings):
    NOT_DIGITS    = '[^0-9]+'
    DIGITS        = '[0-9]+'
    DIGITS_SELECT = '(' + DIGITS + ')'
    lengths = []
    digitss = []
    for i in range(100):
        digits = []
        query = '^'
        for j in range(i):
            query += NOT_DIGITS + DIGITS
        query += NOT_DIGITS + DIGITS_SELECT + NOT_DIGITS + '.*'
        #print(query)
        replaced = [re.sub(query, '\\1', s) for s in strings]
        if replaced == strings:
            break
        lengths.append(len(replaced))
        digitss.append(([int(d) for d in replaced]))
    patterns = [re.sub(DIGITS, '###', s) for s in strings]
    return patterns, digitss, lengths
    
patterns, digitss, lengths = extract_digits(strings)
patterns = list(set(patterns))
if 1 < len(patterns) or 1 < len(set(lengths)):
    print('multiple patterns')
pattern = patterns[0]
for digits in digitss:
    digits = na(sorted(set(digits)))
    w = np.where(1 < np.diff(digits))
    cont = sorted(set([digits[0]] + list(digits[w]) + [digits[-1]]))
    #print(cont)
    pattern = pattern.replace('###', str(cont), 1) # 強調しない場合
    #pattern = pattern.replace('###', '\033[7m\033[31m' + str(cont) + '\033[0m', 1) # 色を付けて強調する場合
print(pattern)

で、それぞれの番号について連番になっているかどうか、確認できます。実行結果は

data/data-[1, 3]/preprocessed/[224]/data_[0, 5]-[0, 6, 8, 20].csv

となり、path中の数値は順に、

  • 1~3 (欠番なし)
  • 224のみ
  • 0~5 (欠番なし)
  • 0~20 (6と8の後ろに欠番あり)

となっていることがわかります。

パターンが複数ある場合は、multiple patternsと表示されます。path中にtrainとtestが含まれるというようなことが予めわかっている場合は、extract_digits()の呼び出しの直後に

patterns = [p
.replace('/train/', '/[train, test]/')
.replace('/test/', '/[train, test]/')
for p in patterns]

のように置換をいれておくことで、パターンを一つに纏めることが出来ます。

まとめ

今回は、重複データを取り除いたり、欠けをチェックする方法について紹介しました。次回は、このPython + Pandasの欠けチェックを発展させたデータセットの管理について紹介したいと思います。

おまけ (多次元配列の変数名)

digitssという変数名が登場しましたが、これは私のオリジナルルールで「複数形の複数形」、つまり、2次元配列であることを示しています。このように次元が増えるごとに変数名の末尾にsを追加していくと

for valuess in vaulesss: # 3次元配列に対するループ
for values in vauless: # 2次元配列に対するループ
for value in vaules: # 1次元配列に対するループ

または

for i, valuess in enumerate(vaulesss): # 3次元配列に対するループ
for j, values in enumerate(vauless): # 2次元配列に対するループ
for k, value in enumerate(vaules): # 1次元配列に対するループ

のように、多重ループにしたときに、sが一つずつ少なくなるようになります。あまりsの数が多くなると数えるのが大変ですが。