Wikipedia全文書から一般語フィルタを作る【python】

#概要
ある文書集合から特徴語などを抽出するときに、一般的によく使われる単語は含めたくない場合があります。そのようなときに、一般的な文書集合（例えばWikipediaや青空文庫）から単語の逆文書頻度（IDF：単語の希少さのようなもの）を計算し、その値によってフィルタするという対応が考えられます。
そこで、日本語Wikipediaのデータを使って単語の逆文書頻度を求めるプログラムをpythonで書きました。
#環境
macOS Catalina 10.15.4
python 3.8.0
また形態素解析ライブラリであるMeCabの辞書として、mecab-ipadic-neologdを利用しています。下記コードで明には指定していません。
#Wikipedia全文のダウンロードとテキスト変換
Wikipedia全文をダウンロードし、テキストデータに変換します。

##XMLのダウンロード
curlでxmlをダウンロードします。

```
$ curl -O https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2
```
##テキストデータの抽出
`wikiextractor`でテキストデータのみを抽出します。textというフォルダが作られ、その中にAA、AB、AC、・・・といった感じで、ファイルが置かれていきます。

```
git clone https://github.com/attardi/wikiextractor.git
cd wikiextractor/
mv ../jawiki-latest-pages-articles.xml .
python setup.py install
python WikiExtractor.py jawiki-latest-pages-articles.xml
mv text ../
cd ..
```

下記を参考にしました。
[Wikipedia（日本語版）全文データをテキストファイルへ変換](https://qiita.com/oyaryo/items/203837d6f23a5495f2bb)
[Wikipediaから日本語コーパスを利用してSentencePieceでトークナイズ(分かち書き)](https://qiita.com/oyaryo/items/203837d6f23a5495f2bb)

この時点でのファイル構成は下記のようになります

```
.
├── wikiextractor
└── text
     ├── AA
     ├── AB
     ├── AC
     ...
```

#逆文書頻度の計算

```Python
import pandas as pd
import MeCab
import re
import mojimoji
import math
import os
import json
import math
import sys

#表記ゆれを吸収するための関数
def formatText(text):
  text = mojimoji.zen_to_han(text,kana=False) #英字、数字を半角に
  text = mojimoji.han_to_zen(text,digit=False,ascii=False) #かな文字を全角に
  text = text.upper()
  return text

#形態素解析用のMeCabオブジェクト
m = MeCab.Tagger()
#入力str、出力list(str)、文書から単語の原型を抽出して返す
def extractWords(text):
  words = []
  for token in m.parse(text).splitlines()[:-2]:
    if '\t' not in token: continue

    surface = token.split('\t')[0]
    pos = token.split('\t')[1].split(',')
    #原型を取得する。原型が取得できていない場合はsurfaceで代用
    stem = pos[-3]
    if stem == '*': stem = surface
    words.append(stem)
  return words   

#文字列がjsonフォーマットかどうかの判定
#https://qiita.com/utsuki/items/ae3971d063715b70831e より拝借
def isJsonFormat(line):
  try:
    json.loads(line)
  except json.JSONDecodeError as e:
    print(sys.exc_info())
    print(e)
    return False
  # 以下の例外でも捕まえるので注意
  except ValueError as e:
    print(sys.exc_info())
    print(e)
    return False
  except Exception as e:
    print(sys.exc_info())
    print(e)
    return False
  return True

#入力str、出力dict、出力wiki文書から文字列データ（<doc>タグ）のみを抜き出し（逆に言うとタイトルを除き）、文書頻度(Document Frequency)を逐次更新していく。最後に逆文書頻度を計算して返す。
def calcWikiInvDocFreq(root_dirpath='text'):
  #文書頻度、文書数、すでに解析したフォルダ
  doc_freq, doc_num, done_dir = {}, 0, []
  #もし途中経過のファイルがあればその情報で上書きする。
  tmp_info_path = 'tmp_info.json'
  if os.path.isfile(tmp_info_path):
    with open(tmp_info_path,'r') as f:
      raw = f.read()
      if isJsonFormat(raw):
        tmp_info = json.loads(raw)
        doc_freq = tmp_info.get('doc_freq',{})
        doc_num = tmp_info.get('doc_num',0)
        done_dir = tmp_info.get('done_dir',[])
  #引数で指定したフォルダに置かれているファイル名をすべて取得。AA、AB、AC、・・・などのフォルダ名が取得できる想定
  dirs = os.listdir(root_dirpath)
  dirs = [dir for dir in dirs if os.path.isdir(os.path.join(root_dirpath,dir))] #フォルダのみ残す
  #すでに解析したフォルダ以外をループ対象とする
  dirs = [v for v in dirs if v not in done_dir]
    
  
  #<doc></doc>の中身を正規表現で抽出するためのオブジェクト
  #formatTextで英単語を大文字化しているので合わせる
  re_doc = re.compile(r'<DOC[\s\S.]*?>([.\s\S]+?)</DOC>')
  #この文字数以下の文書は解析対象としない
  threshold = 100

  for dirname in dirs:
    #途中経過の書き出し    
    with open(tmp_info_path,'w') as f:
      tmp_info = {'doc_num':doc_num, 'done_dir':done_dir, 'doc_freq':doc_freq}
      json.dump(tmp_info,f,indent=2,ensure_ascii=False)

    print(dirname) #進捗確認用の出力
    #dirname内のファイル名リストを取得
    dirpath = os.path.join(root_dirpath,dirname)
    files = os.listdir(dirpath)
    #取得した各ファイルに対して処理を行う
    for filename in files:
      print(filename, end=',') #進捗確認用の出力
      filepath = os.path.join(dirpath,filename)
      raw_text = ''
      with open(filepath,'r',encoding='utf8') as f:
        raw_text = f.read()
        raw_text = formatText(raw_text)

      #<doc>タグの中身を抽出
      docs = re_doc.findall(raw_text)
      #print(raw_text)
      for doc in docs:
        if len(doc) < threshold: continue
        words = set(extractWords(doc))
        for w in words:
          doc_freq[w] = doc_freq.get(w,0)+1
        doc_num += 1 #文書数をインクリメント
    #途中経過保存用に解析済みファイルに追加
    done_dir.append(dirname)
    print('')
  inv_doc_freq = {w: math.log(doc_num/df) for w,df in doc_freq.items()}
  return inv_doc_freq
    
inv_doc_freq = calcWikiInvDocFreq('text')
#保存
with open('wiki_idf.json','w') as f:
  json.dump(inv_doc_freq, f, indent=2,ensure_ascii=False)        

#中身をいくつか確認
cnt = 0
for k,v in inv_doc_freq.items():
  print(k,v)
  cnt+=1
  if cnt>20:break         
```

##出力

```
2 1.508531117713344
平沢 8.271767930969862
学徒 7.744287872298734
軍事革命 9.374204316076375
)。 1.4857361701654532
1972年 3.648030725818081
を 0.12057107367508631
1948年 4.003245569536726
1977年 3.745756619432409
行政官 7.226728216351399
共産 6.870321764829368
任 4.673828448951696
1959年 3.994201030332426
司令部 5.403478751173138
韓 6.119207182794179
咸鏡南道 8.302778167712422
) 0.0617846678944001
7月 2.7459653069310197
就任 2.8542436066371377
生まれる 2.465713675355061
卒業後 3.793044153717306
```

#補足
* 100MBくらいのファイルができるのでメモリには注意してください
* 出力はWikipediaデータのダウンロード時期や形態素解析の辞書などによって結果が変わりえます。