言語処理100本ノック 2020 第2章: UNIXコマンド

先日，[言語処理100本ノック2020](https://nlp100.github.io/ja/)が公開されました．私自身，自然言語処理を初めてから1年しか経っておらず，細かいことはよくわかっていませんが，技術力向上のために全ての問題を解いて公開していこうと思います．

すべてjupyter notebook上で実行するものとし，問題文の制約は都合よく破っていいものとします．
ソースコードはgithubにもあります．[あります](https://github.com/nymwa/nlp100/blob/master/chapter02.ipynb)．

1章は[こちら](https://qiita.com/nymwa/items/ca8257362f3128401c10)．

環境はPython3.8.2とUbuntu18.04です．

解説記事としては[こちら](https://qiita.com/hi-asano/items/abf450448568e287968d)のほうがわかりやすいと思います．ぜひ著者の方には10章までの解説記事を書いていただきたいですね．

# 第2章: UNIXコマンド
> popular-names.txtは，アメリカで生まれた赤ちゃんの「名前」「性別」「人数」「年」をタブ区切り形式で格納したファイルである．以下の処理を行うプログラムを作成し，popular-names.txtを入力ファイルとして実行せよ．さらに，同様の処理をUNIXコマンドでも実行し，プログラムの実行結果を確認せよ．

必要なデータセットは[ここ](https://nlp100.github.io/ja/ch02.html)からダウンロードしてください．

ダウンロードしたファイルは`data`以下に置くものとします．

## 10. 行数のカウント
> 行数をカウントせよ．確認にはwcコマンドを用いよ．

```python:コード
with open('data/popular-names.txt') as f:
    print(len(list(f)))
```

```python:出力
2780
```

ファイルオブジェクトの長さを求めるだけです．ファイルオブジェクトはイテレータなので，リストにしないといけません．
入力が十分大きいファイルである場合はメモリに乗らないようなこともあるかもしれませんが，そういう場合はfor文で回してカウントしてけばいいだけです．

```shell:コード
wc -l < data/popular-names.txt
```

```shell:出力
2780
```

`wc`コマンドにlオプションを指定して行数を求めます．ファイル名を与えるといろいろ余分なものが表示されるので，標準入力から与えます．

## 11. タブをスペースに置換
> タブ1文字につきスペース1文字に置換せよ．確認にはsedコマンド，trコマンド，もしくはexpandコマンドを用いよ．

```python:コード
with open('data/popular-names.txt') as f:
    for line in f:
        line = line.strip()
        line = line.replace('\t', ' ')
        print(line)
```

```text:出力(先頭10行)
Mary F 7065 1880
Anna F 2604 1880
Emma F 2003 1880
Elizabeth F 1939 1880
Minnie F 1746 1880
Margaret F 1578 1880
Ida F 1472 1880
Alice F 1414 1880
Bertha F 1320 1880
Sarah F 1288 1880
```

ファイルオブジェクトをイテレータとして回して得られる各文字列は，末尾に改行文字がついているので`strip`で取り除きます(`rstrip('\n')`のほうが望ましい場合もあります)．
タブをスペースに置換して出力するだけです．

`strip`で改行文字を取り除かず，`print(line, end='')`するという方法もあります．

```shell:コード
awk '{gsub("\t", " ", $0); print $0}' data/popular-names.txt
perl -pe 's/\t/ /g' data/popular-names.txt
sed 's/\t/ /g'  data/popular-names.txt
expand -t 1 data/popular-names.txt
tr '\t' ' ' < data/popular-names.txt
```

出力はPythonのものと同じなので省略します．（以後も同様です）

UNIXコマンドいろいろあってなかなか覚えられません．

## 12. 1列目をcol1.txtに，2列目をcol2.txtに保存
> 各行の1列目だけを抜き出したものをcol1.txtに，2列目だけを抜き出したものをcol2.txtとしてファイルに保存せよ．確認にはcutコマンドを用いよ．

```python:コード
with open('data/popular-names.txt') as f, \
        open('result/col1.txt', 'w') as g, \
        open('result/col2.txt', 'w') as h:
    for line in f:
        line = line.strip()
        pref, city, _, _  = line.split('\t')
        print(pref, file=g)
        print(city, file=h)
```

素直に書きました．

```text:結果(col1.txtの先頭10行)
Mary
Anna
Emma
Elizabeth
Minnie
Margaret
Ida
Alice
Bertha
Sarah
```

```text:結果(col2.txtの先頭10行)
F
F
F
F
F
F
F
F
F
F
```

```shell:コード
cut -f 1 data/popular-names.txt > col1.txt
cut -f 2 data/popular-names.txt > col2.txt
```

`cut`コマンド使えば簡単です．`awk '{print $1}'`でもいいと思います．

## 13. col1.txtとcol2.txtをマージ
> 12で作ったcol1.txtとcol2.txtを結合し，元のファイルの1列目と2列目をタブ区切りで並べたテキストファイルを作成せよ．確認にはpasteコマンドを用いよ．

2つのファイルを別々にopenすればいいだけですが，せっかくなので`contextlib.ExitStack`を使って任意の数のファイルを扱えるような実装にします．
コンテキストマネージャについては→https://docs.python.org/ja/3/library/stdtypes.html#typecontextmanager



```python:コード
from contextlib import ExitStack
```

```python:コード
files = ['result/col1.txt', 'result/col2.txt']
with ExitStack() as stack:
    files = [stack.enter_context(open(filename)) for filename in files]
    for lines in zip(*files):
        x = [line.strip() for line in lines]
        x = '\t'.join(x)
        print(x)
```

```text:結果(先頭10行)
Mary	F
Anna	F
Emma	F
Elizabeth	F
Minnie	F
Margaret	F
Ida	F
Alice	F
Bertha	F
Sarah	F
```

```shell:コード
paste result/col1.txt result/col2.txt
```

pasteコマンドを使えば簡単ですね．

## 14. 先頭からN行を出力
> 自然数Nをコマンドライン引数などの手段で受け取り，入力のうち先頭のN行だけを表示せよ．確認にはheadコマンドを用いよ．

個人的には標準入力とコマンドライン引数を受け取るのは`argparse`と`fileinput`でやるのが好きですが，今回はすべてのコードをjupyter notebook上で動かせるようにしたいので，コマンドライン引数は用いません．（問題文の「など」に優しさを感じる）

```python:コード
N = 5
with open('data/popular-names.txt') as f:
    lst = range(N)
    for _, line in zip(lst, f):
        print(line, end='')
```

```text:出力
Mary	F	7065	1880
Anna	F	2604	1880
Emma	F	2003	1880
Elizabeth	F	1939	1880
Minnie	F	1746	1880
```

`head`コマンドで同様の処理ができますね．

```shell:コード
head -n 5 data/popular-names.txt
```

## 15. 末尾のN行を出力
> 自然数Nをコマンドライン引数などの手段で受け取り，入力のうち末尾のN行だけを表示せよ．確認にはtailコマンドを用いよ．

標準入力をすべてリストに入れて最後5つの要素を取り出してもいいとは思いますが，大きなファイルだとメモリに乗らないかもしれないので，キューを使っていきます．

```python:コード
from collections import deque
```

```python:コード
N = 5
queue = deque([], 5)
with open('data/popular-names.txt') as f:
    for line in f:
        queue.append(line)
for line in queue:
    print(line, end='')
```

```text:出力
Benjamin	M	13381	2018
Elijah	M	12886	2018
Lucas	M	12585	2018
Mason	M	12435	2018
Logan	M	12352	2018
```

`tail`コマンドで同様の処理ができますね．

```shell:コード
tail -n 5 data/popular-names.txt
```


## 16. ファイルをN分割する
> 自然数Nをコマンドライン引数などの手段で受け取り，入力のファイルを行単位でN分割せよ．同様の処理をsplitコマンドで実現せよ．

ファイルを行単位でN分割する状況っていうのがあまりないからだと思うんですが，splitコマンドの実装によっては行でのN分割がなかったりします．GNU拡張だとあったりします．

```shell:コード(5分割)
split -d -nl/5 data/popular-names.txt result/shell5.
```

```text:結果(wcで行数を確認)
  587  2348 11007 result/shell5.00
  554  2216 11010 result/shell5.01
  556  2224 11006 result/shell5.02
  540  2160 11007 result/shell5.03
  543  2172 10996 result/shell5.04
 2780 11120 55026 total
```

このGNU拡張のコード( https://github.com/coreutils/coreutils/blob/master/src/split.c )と同じ挙動をするようにpythonでも実装してみました．

```python:コード
def split_string_list(N, lst):
    chunk_size = sum([len(x) for x in lst]) // N
    chunk_ends = [chunk_size * (n + 1) - 1 for n in range(N)]
    
    i = 0
    acc = 0
    out = []
    for chunk_end in chunk_ends:
        tmp = []
        while acc < chunk_end:
            tmp.append(lst[i])
            acc += len(lst[i])
            i += 1
        out.append(tmp)
    return out

def split_file(N, filepath, outprefix):
    with open(filepath) as f:
        lst = list(f)
    lst = split_string_list(N, lst)
    for i, lines in enumerate(lst):
        idx = str(i).zfill(2) # 手抜き
        with open(outprefix + idx, 'w') as f:
            f.write(''.join(lines))

split_file(5, 'data/popular-names.txt', 'result/python5.')
```

まずは全体の文字数を数えて，なるべく文字数がそろうように切る位置(`chunk_ends`)を決めます．
そして，chunk_endsの各要素を超えるまで行を取っていき，超えたらファイルに出力します．

```text:結果(wcで行数を確認)
  587  2348 11007 result/python5.00
  554  2216 11010 result/python5.01
  556  2224 11006 result/python5.02
  540  2160 11007 result/python5.03
  543  2172 10996 result/python5.04
 2780 11120 55026 total
```

```text:結果
diff result/python5.00 result/shell5.00
diff result/python5.01 result/shell5.01
diff result/python5.02 result/shell5.02
diff result/python5.03 result/shell5.03
diff result/python5.04 result/shell5.04
```

同じ結果になりました．

## 17. １列目の文字列の異なり
> 1列目の文字列の種類（異なる文字列の集合）を求めよ．確認にはcut, sort, uniqコマンドを用いよ．

```python:コード
names = set()
with open('data/popular-names.txt') as f:
    for line in f:
        name = line.split('\t')[0]
        names.add(name)
names = sorted(names)

for name in names:
    print(name)
```

```python:結果(先頭10行)
Abigail
Aiden
Alexander
Alexis
Alice
Amanda
Amelia
Amy
Andrew
Angela
```

1列目を順に集合に追加していき，ソートして出力しています．(※pythonのバージョンは3.8.2です．)

```shell:コード
cut -f 1 data/popular-names.txt | sort -s | uniq 
```

1列目だけを取り出して，ソートして重複を取り除きます．sortを入れ忘れるとおかしなことになります．また，pythonに合わせて安定ソートとするために，sオプションをつけています．

## 18. 各行を3コラム目の数値の降順にソート
> 各行を3コラム目の数値の逆順で整列せよ（注意: 各行の内容は変更せずに並び替えよ）．確認にはsortコマンドを用いよ（この問題はコマンドで実行した時の結果と合わなくてもよい）．

```python:コード
with open('data/popular-names.txt') as f:
    lst = [line.strip() for line in f]
lst.sort(key = lambda x : -int(x.split('\t')[2]))
    
for line in lst[:10]:
    print(line)
```

```text:出力(先頭10行)
Linda	F	99689	1947
Linda	F	96211	1948
James	M	94757	1947
Michael	M	92704	1957
Robert	M	91640	1947
Linda	F	91016	1949
Michael	M	90656	1956
Michael	M	90517	1958
James	M	88584	1948
Michael	M	88528	1954
```

`sort`関数のキーを指定してあげることで，ソートする基準を指定できます．

```shell:コード
sort -nrsk 3 data/popular-names.txt
```

`sort`コマンドだけでできます．簡単ですね．

## 19. 各行の1コラム目の文字列の出現頻度を求め，出現頻度の高い順に並べる
> 各行の1列目の文字列の出現頻度を求め，その高い順に並べて表示せよ．確認にはcut, uniq, sortコマンドを用いよ．

`collections.Counter`を使うといいです．

```python:コード
from collections import Counter
```

```python:コード
cnt = Counter()
with open('data/popular-names.txt') as f:
    for line in f:
        name = line.split('\t')[0]
        cnt.update([name])
        
lst = cnt.most_common()
lst.sort(key=lambda x:(-x[1], x[0]))

for name, num in lst[:10]:
    print(name)
```

```text:出力
James
William
John
Robert
Mary
Charles
Michael
Elizabeth
Joseph
Margaret
```

`Counter`のオブジェクトにリストをそのまま渡すか，`update()`で少しずつ渡していくかします．`most_common()`で多い順に並べてくれます．

```shell:コード
cut -f 1 data/popular-names.txt | sort | uniq -c | sort -nrsk1 | awk '{print $2}'
```

`uniq`を取る際に`-c`オプションを付けるといくつあるか数えてくれます．最後に個数でソートするとほしい結果が得られます．

## 次は第3章

[言語処理100本ノック 2020 第3章: 正規表現](https://qiita.com/nymwa/items/de70c7a8bc6512c57d26)