文書の特徴タグの自動生成【Python】

#概要
[pixivコミック作品のタグが自動生成されるまでの軌跡](https://inside.pixiv.blog/pawa/2349)を参考に、文書から重要語をタグとして抽出するpythonプログラムを書きました。

#環境
macOS Catalina 10.15.4
python 3.8.0
また下記コードで明には指定していませんが、MeCabの辞書にはmecab-ipadic-neologdを使用しています


#コード

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

#文書集合
#一要素一文書として、文書のリストを作ってください。ここでは例として自分の過去記事から適当に抜粋した文章を使っています。
documents = [
  'MeCabとPythonで日本語の発音をカナで取得する関数を作りました。例えば、「今日はよく寝ました」と入力したら「キョーワヨクネマシタ」と返すようなものです。',
  '日本語（カタカナ文字列）をモーラ単位で分かち書き（モーラ分かち書き）するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「５・７・５」を数えるときの区切り方で、長音（ー）、促音（ッ）、撥音（ン）も１拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。',
  '日本語（カタカナ文字列）を音節単位で分かち書き（音節分かち書き）するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「５・７・５」を数えるときの区切り方で、長音（ー）、促音（ッ）、撥音（ン）も１拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。「バーン」のように長音、促音、撥音が連続した場合は３以上のモーラ数で１音節となります。',
  'Pythonで、単語（品詞）の単位ではなく、文節単位で分かち書き（下記表の一番下の行）する関数をつくりました。',
  '「100兆の階乗の25兆桁目の数字を求めよ(表記は10進法）」という問題をpythonで解いてみました。（元ネタは「100兆の階乗の右から数えて25兆番目にある数字は偶数であるか奇数であるか」という有名な問題です)',
  'MeCabのデフォルト辞書をmecab-ipadic-NEologdに変更するために、自分の環境（macOS）でやったことのメモです。多くの方がすでに書いてくださっているように、デフォルト辞書はmecabrcというファイルを編集すると変更できるのですが、自分の環境だとmecabrcが複数あってどれを編集すればよいか迷ったので、どのファイルを編集すべきかの調べ方も含めて、実際にうまくできた方法について書いています。'
]

#表記ゆれを吸収するための関数
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

re_kana = re.compile('[a-zA-Z\u3041-\u309F]')
re_num = re.compile('[0-9]+')
re_url = re.compile(r"(https?|ftp)(://[-_.!=*'()a-zA-Z0-0;/?:@&=+$,%#]+)")
m = MeCab.Tagger()

#入力str、出力list、文章から重要語を重複ありの登場順で抽出し、リストで返す。
def extractWords(text):
  words = []
  for token in m.parse(text).splitlines()[:-1]:
    if '\t' not in token: continue
    
    surface = token.split('\t')[0]
    pos = token.split('\t')[1].split(',')
    
    ok = (pos[0]=='名詞' and pos[1] in ['一般','固有名詞','サ変接続','形容動詞語幹'])
    ok = (ok or (pos[0]=='形容詞' and pos[1] == '自立'))
    ok = (ok or (pos[0]=='副詞' and pos[1] == '一般'))
    ok = (ok or (pos[0]=='動詞' and pos[1] == '自立'))

    if ok == False: continue

    stem = pos[-3]

    if stem == '*': stem = surface
    if stem == 'ー': continue
    if re_kana.fullmatch(stem) is not None and len(stem)==1: continue
    if re_num.fullmatch(stem) is not None: continue
    if stem == '': continue
    
    words.append(stem)
  return words

#入力list(list)、出力dict、各文書に含まれる単語リストのリストからそれぞれの単語のdocument frequencyを出力する
def calcDocFreq(words_list):
  df = {}
  for w in words_list:
    for w2 in set(w):
      df[w2]=df.get(w2,0)+1
  return df

#入力list(list)、出力list(dict)、各文書に含まれる単語リストのリストからそれぞれの単語のinversed document frequencyを出力する
def calcInvDocFreq(words_list):
  doc_freq = calcDocFreq(words_list)
  doc_num = len(words_list)
  inv_doc_freq = {w:math.log(doc_num/df) for w,df in doc_freq.items()}
  return inv_doc_freq

#入力list、出力dict、単語リスト中の各単語の登場回数（term frequency）を返す
def calcTermFreq(words):
  obj = {}
  for w in words: 
    obj[w] = obj.get(w,0)+1
  return obj

#入力list(str)とdict、出力dict、文書のリストを入力とし、文書集合と一般文書集合にidfから、一般文書集合のidfで補正した注目文書集合における各単語のbm25を計算する
def calcBM25(documents, general_idf = {}):
  #各文書から抽出された単語リストのリスト
  words_list = [extractWords(d) for d in documents]
  #文書頻度、逆文書頻度の計算
  doc_freq = calcDocFreq(words_list)
  inv_doc_freq = calcInvDocFreq(words_list)
  
  #平均文書長（単語数）
  ave_doc_len = sum([len(words) for words in words_list])/len(words_list)
  #チューニングパラメータ
  k1, b = 2.0, 0.75

  bm25 = {}

  for words in words_list:
    doc_len_ratio = len(words)/ave_doc_len #平均文書長（単語数）に対する注目文書長（単語数）の比
    term_freq = calcTermFreq(words) #term frequency
    for w,tf in term_freq.items():
      df, idf, gidf = doc_freq[w], inv_doc_freq[w], general_idf.get(w,1)
      
      #bm25に加算するスコアの計算
      numerator = idf*gidf*tf*(k1+1) #分子
      denominator = tf+k1*(1-b+b*doc_len_ratio) #分母      
      bm25[w] = bm25.get(w,0) + numerator/denominator

  return bm25

def getBM25BasedTag(documents, general_idf={}):
  bm25 = calcBM25(documents, general_idf)
  tags = []
  for document in documents:
    words = list(set(extractWords(document)))
    scores = [bm25[w] for w in words] #list of bm25
    
    #pandas DataFrameを使って、スコア順に並べ替え
    data = pd.DataFrame({'word':words,'score':scores})
    data = data.sort_values(['score'],ascending=False)
    data = data.reset_index(drop=True)
    
    data = [(w,s) for w,s in zip(data.word, data.score)]
    tags.append(data)
  return tags
  
tags = getBM25BasedTag(documents)
for tag in tags:
  for w,s in tag:
    print(w+':'+str(round(s,3)),end=', ')
  print('')
```

#出力

```
Python:2.98,MeCab:2.375,発音:2.343,取得:2.343,よく:2.343,返す:2.343,寝る:2.343,入力:2.343,キョーワヨクネマシタ:2.343,作りました:1.981,カナ:1.981,日本語:1.981,関数:1.728,する:1.288,
音節:3.918,モーラ:3.599,撥音:3.034,長音:3.034,促音:3.034,単位:3.033,数える:3.03,分かち書き:2.681,作りました:1.981,カナ:1.981,日本語:1.981,python:1.981,関数:1.728,カタカナ:1.703,なれる:1.703,見なす:1.703,代表:1.703,分割:1.703,単体:1.703,俳句:1.703,単一:1.703,ッ:1.703,直前:1.703,合わせる:1.703,文字列:1.703,区切る:1.703,日本語の音韻:1.703,ある:1.505,する:1.288,
音節:3.918,モーラ:3.599,促音:3.034,撥音:3.034,長音:3.034,単位:3.033,数える:3.03,分かち書き:2.681,python:1.981,作りました:1.981,カナ:1.981,日本語:1.981,関数:1.728,代表:1.703,分割:1.703,見なす:1.703,なれる:1.703,単一:1.703,俳句:1.703,カタカナ:1.703,ッ:1.703,直前:1.703,文字列:1.703,区切る:1.703,日本語の音韻:1.703,合わせる:1.703,単体:1.703,ある:1.505,連続:1.313,なる:1.313,する:1.288,
単位:3.033,Python:2.98,分かち書き:2.681,つくる:2.517,単語:2.517,下記:2.517,文節:2.517,品詞:2.517,行:2.517,関数:1.728,する:1.288,
階乗:3.264,数字:3.264,数える:3.03,偶数:2.343,解く:2.343,元ネタ:2.343,右:2.343,奇数:2.343,有名:2.343,求める:2.343,表記:2.343,python:1.981,ある:1.505,
編集:2.926,ファイル:2.383,辞書:2.383,環境:2.383,できる:2.383,mecabrc:2.383,書く:2.383,デフォルト:2.383,自分:2.383,変更:2.383,MeCab:2.375,調べ:1.531,複数:1.531,含める:1.531,素手:1.531,方法:1.531,mecab-ipadic-NEologd:1.531,うまい:1.531,メモ:1.531,macOS:1.531,やる:1.531,迷う:1.531,ある:1.505,する:1.288,
```
#備考
* 出力結果は形態素解析の辞書の種類などによって異なる可能性があります。
* 一般語フィルタ(general_idf)は本コード以外の場所で別途作成している前提です。
* 単語抽出のポリシーは参考記事と少し異なっています。