自然言語処理100本ノック：83の「処理を工夫」に挑戦してみた

# 背景
自分が書いた前の記事、「[自然言語系サービス色々試してみた](https://qiita.com/silverbox/items/048804eba4a4c429bfe8)」の中でも引用させて頂いた「[言語処理100本ノック 2015](http://www.cl.ecei.tohoku.ac.jp/nlp100/)」。
各種自然言語サービスをもっと試す前に、基礎を固めておこうと思ってこの100本ノックを少しずつ進めている状況。ここで、問題83に関して気になる表現がある。

> なお，問題83を素直に実装すると，大量（約7GB）の主記憶が必要になる． メモリが不足する場合は，処理を工夫するか，1/100サンプリングのコーパスenwiki-20150112-400-r100-10576.txt.bz2を用いよ．

この100本ノックを進めている理由の一つは自然言語処理の感覚をつかむ事。必要なデータの規模も肌で感じておくべき点の一つ。ここは標準のデータで進めたい。

# 問題80~82ダイジェスト

### 問題80：コーパスの成型
記号などの不要文字除去。正規表現使って処理。入力ファイル221.9MB
<details><summary>ソース</summary><div>

```python:
import re

FNAME_80_WORD = 'enwiki-20150112-400-r10-105752-word.txt'

def filteredWords(s_line):
    wkwords = s_line.split(' ')
    wkcaredwords = []
    for wkword in wkwords:
        wkcaredword = re.sub('^[ \.,!\?;:()\[\]\'"]*', '', wkword)
        wkcaredword = re.sub('[ \.,!\?;:()\[\]\'"]*$', '', wkcaredword)
        wkword2 = wkcaredword.lower()
        if len(wkword2) > 0:
            wkcaredwords.append(wkword2)
    return wkcaredwords


def lesson80():
    f_wikiorg = open('enwiki-20150112-400-r10-105752.txt', 'rt')
    f_wikiword = open(FNAME_80_WORD, 'wt')
    try:
        for s_line in f_wikiorg:
            wkwords = filteredWords(s_line.strip())
            if len(wkwords) > 0:
                wkline = '\n'.join(wkwords)
                f_wikiword.writelines(wkline + '\n')

    finally:
        f_wikiorg.close()
        f_wikiword.close()
```
</div></details>

### 問題81：複合語からなる国名への対処
連結語処理。webページをあさって作った国名リストのファイル（countrynames_sort.txt）を作成。そのファイルを読んで、複合語マッチ用のツリー的なディクショナリ作成。
入力ファイル725.8MB
<details><summary>ソース</summary><div>

```python:
TREE_END_KEY = '_end_'

def getCountryNameTree():
    retmap = {}
    f_countryNameOrg = open('countrynames_sort.txt', 'rt')
    try:
        for s_line in f_countryNameOrg:
            if len(s_line) > 0:
                wkwdlist = s_line.split(' ')
                wkwdmap = retmap
                lastidx = len(wkwdlist) - 1
                for idx, word in enumerate(wkwdlist):
                    wkwd = word.rstrip().lower()
                    if not wkwd in wkwdmap.keys():
                        wkwdmap[wkwd] = {}
                    wkwdmap = wkwdmap[wkwd]
                    if idx == lastidx:
                        wkwdmap[TREE_END_KEY] = 'dmmy'

    finally:
        f_countryNameOrg.close()
    # print(retmap['the'])

    return retmap


FNAME_81_WORD = 'enwiki-20150112-400-r10-105752-81.txt'

def lesson81():
    countryNameTree = getCountryNameTree()
    f_wikiword = open(FNAME_80_WORD, 'rt')
    f_wikijoin = open(FNAME_81_WORD, 'wt')
    # f_wikiword = open('enwiki-20150112-400-word.txt', 'rt')
    # f_wikijoin = open('enwiki-20150112-400-81.txt', 'wt')
    try:
        poollist = []
        wkTree = countryNameTree
        for wordorg in f_wikiword:
            word = wordorg.strip()
            wkwd = word.lower()
            if wkwd in wkTree.keys():
                poollist.append(word)
                wkTree = wkTree[wkwd]
                if TREE_END_KEY in wkTree.keys():
                    f_wikijoin.writelines('_'.join(poollist) + '\n')
                    poollist = []
                    wkTree = countryNameTree
            else:
                if len(poollist) > 0:
                    for poolwd in poollist:
                        f_wikijoin.writelines(poolwd + '\n')
                    poollist = []
                    wkTree = countryNameTree

                f_wikijoin.writelines(word + '\n')

        if len(poollist) > 0:
            for poolwd in poollist:
                f_wikijoin.writelines(poolwd + '\n')
    finally:
        f_wikiword.close()
        f_wikijoin.close()
```
</div></details>

### 問題82：文脈の抽出
ランダムで最大前後５単語とのセットを作成する問題。11語の読み込みプールを使用し、シーケンシャルに処理できるようにして対応。入力ファイルは725.8M。出力ファイルが8.7G!!
<details><summary>ソース</summary><div>

```python:
import random

MAX_REL_SPAN = 5

def appendRelWord(f_wikirel, wordorg, poollist):
    span = random.randint(1, MAX_REL_SPAN)

    for wkidx in range(MAX_REL_SPAN - span, MAX_REL_SPAN):
        if len(poollist[wkidx].strip()) < 1:
            continue
        f_wikirel.writelines(wordorg + '\t' + poollist[wkidx] + '\n')
    for wkidx in range(MAX_REL_SPAN + 1, MAX_REL_SPAN + span + 1):
        if len(poollist[wkidx].strip()) < 1:
            continue
        f_wikirel.writelines(wordorg + '\t' + poollist[wkidx] + '\n')

FNAME_82_PAIRS = 'enwiki-20150112-400-r10-105752-82.txt'

def lesson82():
    f_wikijoin = open(FNAME_81_WORD, 'rt')
    f_wikirel = open(FNAME_82_PAIRS, 'wt')
    try:
        poollist = []

        # 冒頭に５個分空白ダミーセット
        for idx in range(1, MAX_REL_SPAN + 1):
            poollist.append('')
        print(poollist)

        r_wdcnt = 0
        for wordorg in f_wikijoin:
            word = wordorg.strip().lower()
            if len(word) < 1:
                continue

            # pool words.
            poollist.append(word)
            if r_wdcnt < MAX_REL_SPAN:
                r_wdcnt += 1
                continue

            appendRelWord(f_wikirel, poollist[MAX_REL_SPAN], poollist)

            poollist.pop(0)
            r_wdcnt += 1

        # 残り分出力
        for idx in range(1, MAX_REL_SPAN + 1):
            poollist.append('')
            appendRelWord(f_wikirel, poollist[MAX_REL_SPAN], poollist)
            poollist.pop(0)

    finally:
        f_wikijoin.close()
        f_wikirel.close()
```
</div></details>

# 今回の主題、問題83

> 83. 単語／文脈の頻度の計測
> 82の出力を利用し，以下の出現分布，および定数を求めよ．
>
> $f(t,c)$ : 単語tと文脈語c の共起回数
> $f(t,∗)$ : 単語t の出現回数
> $f(∗,c)$ : 文脈語c の出現回数
> $N$      : 単語と文脈語のペアの総出現回数

まず問題の理解から。1番目は、82で出力したペアで同じものをカウントするらしい。tとcが逆になった場合も含めるのか迷ったが、一旦分けた。2,3番目は単純に各単語のカウント、4番目は読み込んだデータ件数という事と思われる。

### まずは様子見

まずは1000行分とりだしたファイル作成。それで試してみる。
ファイルを順次、読み込み、ディクショナリオブジェクトを使用。単語をキーに、値に回数を保持する。全部カウント終わってから、そのディクショナリを出力するロジック。（※この時のソースを取っておかなかった。反省。）

<details><summary>出力結果抜粋</summary><div>

```console:t&c出現回数
Anarchism	Anarchism	2
Anarchism	is	2
Anarchism	a	3
Anarchism	political	1
Anarchism	philosophy	1
Anarchism	that	1
is	Anarchism	2
```

```console:c出現回数
Anarchism	31
is	68
a	92
political	19
philosophy	40
that	42
advocates	6
stateless	8
```
</div></details>

うまく行ってそう。フルファイルで実行してみる。

はい、**強制終了**の文字がコンソールに出力されました。Ubuntu18.04、4GBのVMWare仮想マシンで実行。リソースモニタつけながら実行すると、当然の如くメモリ使用量が増えていき、4GBぐらいの所で強制終了。単語数が極端に多くなければメモリはそんなに使用しないだろうと踏んでいたけど、甘かった。
**素直に実装** というのが全データをリストに保持してCount機能で数えるという実装かと思っていたが、そうではなかった様子。

### 工夫とやらを考えてみる

単語毎の件数保持オブジェクトですら耐えられない状況。
シーケンシャルにファイルを読み込み、シーケンシャルに結果出力する事が求められている。
確かに課題80からシーケンシャル処理は意識していた。おそらく読み込みファイルのデータ量に比例するメモリオブジェクトがあるロジック使っていたらその時点で問題出ていたと思われる。
この問題では、ファイル中に散らばっている単語をカウントする為に、カウント数をそれぞれの単語毎に保持する必要があった。

### 問題が発生する原因を消す

シーケンシャルに処理する為には、**ファイル中にワードが散らばっている**という条件を無くさなくてはいけない。ソートが必要。とはいえ、普通に読み込んでソート出力してたら結局そこでメモリが耐えられない。メモリ耐えられる程度にファイル分割してそれぞれソートしてからマージするしかない。あまりそこに時間かけたくない・・・・
ふと、Linuxのsortコマンドって行けるんじゃね？と思って調べてみたら「[sort コマンドで大容量テキストをソートする](https://qiita.com/ymoch/items/ec6abe80064fa60f6b46)」という記事を発見。やはり大容量ファイルに耐えられるらしい。

```bash:単語tと文脈語cそれぞれにソートファイルを作成
sort -f enwiki-20150112-400-r10-105752-82.txt > enwiki-20150112-400-r10-105752-82-st.txt
sort -f -k 2 enwiki-20150112-400-r10-105752-82.txt > enwiki-20150112-400-r10-105752-82-sc.txt
```
ちなみに、このソート処理にそれぞれ40分ぐらいかかった。8.7Gだしね・・・

<details><summary>結果出来上がったソース</summary><div>

```python:
    # 文中にマルチバイトや特殊文字が含まれていたので除外用
    re_check = re.compile(r'[A-Za-z]')

    N = 0

    # f(t,c) : 単語tと文脈語c の共起回数
    # f(t,∗) : 単語t の出現回数
    wkPairCnt = 0
    wkWordCnt = 0
    wkPairWd = ''
    wkWordWd = ''
    f_wikirel = open('enwiki-20150112-400-r10-105752-82-st.txt', 'rt')
    f_wikifreq_tc = open('enwiki-20150112-400-r10-105752-83-tc.txt', 'wt')
    f_wikifreq_t = open('enwiki-20150112-400-r10-105752-83-t.txt', 'wt')
    try:
        for wordorg in f_wikirel:
            pair = wordorg.rstrip()
            elems = pair.split('\t')
            if not re_check.match(elems[0]) or not re_check.match(elems[1]):
                continue

            if pair == wkPairWd:
                wkPairCnt += 1
            else:
                if wkPairCnt > 0:
                    f_wikifreq_tc.writelines('{0:s}\t{1:d}'.format(wkPairWd, wkPairCnt) + '\n')
                wkPairWd = pair
                wkPairCnt = 1

            word = elems[0]
            if word == wkWordWd:
                wkWordCnt += 1
            else:
                if wkWordCnt > 0:
                    f_wikifreq_t.writelines('{0:s}\t{1:d}'.format(wkWordWd, wkWordCnt) + '\n')
                wkWordWd = word
                wkWordCnt = 1
            N += 1

        if wkPairCnt > 0:
            f_wikifreq_tc.writelines('{0:s}\t{1:d}'.format(wkPairWd, wkPairCnt) + '\n')
        if wkWordCnt > 0:
            f_wikifreq_t.writelines('{0:s}\t{1:d}'.format(wkWordWd, wkWordCnt) + '\n')

    finally:
        f_wikirel.close()
        f_wikifreq_tc.close()
        f_wikifreq_t.close()
    print('N={0:d}'.format(N))

    # f(∗,c) : 文脈語c の出現回数
    wkRelCnt = 0
    wkRelWd = ''
    f_wikirel_c = open('enwiki-20150112-400-r10-105752-82-sc.txt', 'rt')
    f_wikifreq_c = open('enwiki-20150112-400-r10-105752-83-c.txt', 'wt')
    try:
        for wordorg in f_wikirel_c:
            pair = wordorg.rstrip()
            elems = pair.split('\t')
            if not re_check.match(elems[0]) or not re_check.match(elems[1]):
                continue

            word = elems[1]
            if not re_check.search(pair):
                continue
            if word == wkRelWd:
                wkRelCnt += 1
            else:
                if wkRelCnt > 0:
                    f_wikifreq_c.writelines('{0:s}\t{1:d}'.format(wkRelWd, wkRelCnt) + '\n')
                wkRelWd = word
                wkRelCnt = 1

        if wkRelCnt > 0:
            f_wikifreq_c.writelines('{0:s}\t{1:d}'.format(wkRelWd, wkRelCnt) + '\n')

    finally:
        f_wikirel.close()
        f_wikifreq_c.close()
```
</div></details>

こちらの処理もそれぞれ40分ぐらい。出力されたファイルサイズは、f(t,c)用のファイルは1.9G、f(*.c)、f(t,c)用のファイルはそれぞれ18M。N=674,574,652。

そして後で思い出す。100本ノックの問題19を。
> 各行の1コラム目の文字列の出現頻度を求め，出現頻度の高い順に並べる

uniq --count で行けた課題だったかも。Python内でシェルコマンド打てるしね。100ノックで「第2章: UNIXコマンドの基礎」があるのは重要な意味があったという事ですね。

### 補足
変なコントロール文字が入っているのか、OSのソート順と、pythonでの文字列比較が異なるのか（[参考](https://rcmdnk.com/blog/2015/08/10/computer-linux/)）、本来スペース区切りで２つの文字に分けられているべき「a$600 million」とか出てきたり「a–a」とかが複数件出てきたりしてた（ソートされていれば複数回出てこないはずのもｍの）。この手のワードは後続処理には影響しないと思われ、今回は無視。

# ついでに問題84も
> 84. 単語文脈行列の作成
> 83の出力を利用し，単語文脈行列Xを作成せよ．ただし，行列Xの各要素Xtcは次のように定義する．
>
>   $f(t,c)≥10$ ならば，$Xtc=PPMI(t,c)=max \\{ log\frac{N×f(t,c)}{f(t,∗)×f(∗,c)},0 \\} $ 
>   $f(t,c)<10$ ならば，$Xtc=0$
>
> ここで，PPMI(t,c) はPositive Pointwise Mutual Information（正の相互情報量）と呼ばれる統計量である．
> なお，行列Xの行数・列数は数百万オーダとなり，行列のすべての要素を主記憶上に載せることは無理なので注意すること．
> 幸い，行列Xのほとんどの要素は0になるので，非0の要素だけを書き出せばよい．

### とりあえずフィルタ
出現回数が10回以下は無視という事。f(t,c)で10回出てくるt,cの単語はf(*.c)、f(t,*)それぞれ10以上で出現するので、こちらもフィルタ。まず、Linuxコマンドにより回数で逆ソートして、その後フィルタ。
<details><summary>10回以下を除外するソース</summary><div>

```python:
import subprocess

FNAME_83_TC = 'enwiki-20150112-400-r10-105752-83-tc.txt'
FNAME_83_C = 'enwiki-20150112-400-r10-105752-83-c.txt'
FNAME_83_T = 'enwiki-20150112-400-r10-105752-83-t.txt'
FNAME_84_SORTED_TC = 'enwiki-20150112-400-r10-105752-84-tc-wk.txt'
FNAME_84_SORTED_C = 'enwiki-20150112-400-r10-105752-84-c-wk.txt'
FNAME_84_SORTED_T = 'enwiki-20150112-400-r10-105752-84-t-wk.txt'
FNAME_84_FILTERED_TC = 'enwiki-20150112-400-r10-105752-84-tc-wk2.txt'
FNAME_84_FILTERED_C = 'enwiki-20150112-400-r10-105752-84-c-wk2.txt'
FNAME_84_FILTERED_T = 'enwiki-20150112-400-r10-105752-84-t-wk2.txt'

def sortBaseFile(input, output, idx):
    f_wiki_matrix = open(output, 'wt')
    try:
        commandlist = []
        commandlist.append('sort')
        commandlist.append('-n')
        commandlist.append('-k')
        commandlist.append(str(idx))
        commandlist.append(input)
        commandlist.append('--reverse')
        subprocess.check_call(commandlist, stdout=f_wiki_matrix)
    except Exception as e:
        print(e)
        print("subprocess.check_call() failed")
    finally:
        f_wiki_matrix.close()

sortBaseFile(FNAME_83_TC, FNAME_84_SORTED_TC, 3)
sortBaseFile(FNAME_83_T, FNAME_84_SORTED_T, 2)
sortBaseFile(FNAME_83_C, FNAME_84_SORTED_C, 2)


def filterLowFreqWord(input, output, idx):
    f_wiki_tc_wk = open(input, 'rt')
    f_wiki_tc_flt = open(output, 'wt')
    try:
        for wordorg in f_wiki_tc_wk:
            pair = wordorg.rstrip()
            elems = pair.split('\t')
            count = int(elems[idx])
            if count < 10:
                break

            f_wiki_tc_flt.write(wordorg)

    finally:
        f_wiki_tc_wk.close()
        f_wiki_tc_flt.close()

filterLowFreqWord(FNAME_84_SORTED_TC, FNAME_84_FILTERED_TC, 2)
filterLowFreqWord(FNAME_84_SORTED_T, FNAME_84_FILTERED_T, 1)
filterLowFreqWord(FNAME_84_SORTED_C, FNAME_84_FILTERED_C, 1)
```
</div></details>

フィルタ後、f(t,c)用のファイルは87M、f(*.c)、f(t,*)用のファイルは8M前後。件数確認してみる。

```console:
$ cat enwiki-20150112-400-r10-105752-84-c-wk2.txt | wc -l
598055
$ cat enwiki-20150112-400-r10-105752-84-t-wk2.txt | wc -l
680966
$ cat enwiki-20150112-400-r10-105752-84-c-wk.txt | wc -l
1408618
$ cat enwiki-20150112-400-r10-105752-84-t-wk.txt | wc -l
1406929
```

うーん、課題83の中で、勝手にアルファベットで開始しない文字を除外したりしたからかな？フィルタ前でも140万件程度。問題文によれば数百万程度になるはずなのだが・・・途中経過がまちがってる可能性もあり。100本ノックは固定した答えはなさそうで、答え合わせをするのが難しい。

### ロジック考察
どのような出力をすると後続処理で都合が良いか不明だが、行列情報の出力なので、行、列のインデックス及び値を出力することにする。
この処理では、３つの情報が必要になる。f(t,c)、f(*.c)、f(t,*)の３つ。f(t,c)をシーケンシャルに処理する時、f(t,*)に関しても同じ順序で取得できるのでこの２つは大丈夫。しかし、f(t,c)に関して使用される文脈語cは同一単語t内ではシーケンシャルだが、全体的に見るとほぼランダム。
その度にフルファイルスキャンがかかり現実的でない。メモリを使用しないとすると、キー単語毎に対応するファイルシステム上位置を保持してそこに直接読みに行くぐらいの事をしないとまともな速度にはならない。
・・・ん？まんまKey-Value型DBの特性。[自分の以前の記事](https://qiita.com/silverbox/items/18036bb08f16618c21f5)でも使用したLocalDynamoDBがよさそう。100本ノックでは「第7章: データベース」で学習する。第7章は別件でやっている事なので飛ばしたけど、こういう事も考えてこの順番になってるわけですね。考えられてます。自然言語処理の現場でも状況に応じてKVSとRDBが併用されているだろうと勝手に想像。
まぁ、今回はメモリで処理できるサイズなので普通にメモリ展開して処理。

<details><summary>ソース</summary><div>

```python:
def getCountMap(fname):
    ret = {}
    f_wiki_t_wk = open(fname, 'rt')
    try:
        idx = 0
        for wordorg in f_wiki_t_wk:
            pair = wordorg.rstrip()
            elems = pair.split('\t')
            info = {}
            info['cnt'] = int(elems[1])
            info['idx'] = idx
            ret[elems[0]] = info
            idx += 1
        return ret

    finally:
        f_wiki_t_wk.close()


def lesson84():
    map_t = getCountMap(FNAME_84_FILTERED_T)
    map_c = getCountMap(FNAME_84_FILTERED_C)
    f_wiki_tc = open(FNAME_84_FILTERED_TC, 'rt')
    f_wiki_matrix = open(FNAME_84_OUTPUT, 'wt')
    try:
        for datawk in f_wiki_tc:
            data = datawk.rstrip()
            elems = data.split('\t')
            t = elems[0]
            c = elems[1]
            tc_cnt = int(elems[2])
            t_inf = map_t[t]
            t_cnt = t_inf['cnt']
            c_inf = map_c[c]
            c_cnt = c_inf['cnt']
            calcwk = (N_84 * tc_cnt) / (t_cnt * c_cnt)
            if calcwk >= 1: # log(calcwk) >= 0
                outstr = '{0:d}\t{1:d}\t{2:f}\n'.format(t_inf['idx'], c_inf['idx'], math.log(calcwk))
                f_wiki_matrix.write(outstr)

    finally:
        f_wiki_tc.close()
        f_wiki_matrix.close()
```
</div></details>

<details><summary>出力結果抜粋</summary><div>

```console:
0	1	0.532074
1	0	0.531856
3	0	0.170953
0	3	0.170485
・・中略
5	10	0.019082
95	9	3.239342
9	96	3.239448
7	18	1.391988
18	7	1.391669
17	4	0.317417
・・中略
5	56348	0.310076
5	92731	1.067564
5	46752	0.016477
491254	0	2.304729
680965	19	5.516587
```
</div></details>
# 教訓

Linuxコマンドを有効活用すべし。高速なロジック使ってるはずだし、安定しているし、多分Cで書かれてて高速なはずだし。（OS依存性は強くなりますが）
このような学習コースをやっていて、詰まった時には、一度コース中で既に学習した部分を見返すと何か解るかも。

# 参考にさせてもらったページ
[sort コマンドで大容量テキストをソートする](https://qiita.com/ymoch/items/ec6abe80064fa60f6b46)

[sort/lsコマンド出力での記号文字の順番のLC_ALL等への依存](https://rcmdnk.com/blog/2015/08/10/computer-linux/)

[Qiitaの数式チートシート](https://qiita.com/PlanetMeron/items/63ac58898541cbe81ada)