【転職会議】Pythonで自然言語処理を用いてクチコミを要約してみる

## 前置き
LivesenseAdventCalendar2017 の13日目を担当する @naotaka1128 です。現在、転職会議のチーフプロダクトマネージャーとして開発・マーケティングを統括しております。

去年、[転職クチコミを自然言語処理して会社分類する](https://qiita.com/naotaka1128/items/2c4551abfd40e43b0146) 記事をAdventCalenderに投稿しましたが、今年も自然言語処理を用いて転職に役立ちそうな機能のプロトタイプを作ってみました。

## 課題と解決策
転職会議は国内最大規模の転職クチコミサイトということもあり、企業によっては数百件、数千件のクチコミがあります。ぶっちゃけ全部読むのタルいし雰囲気をまずざっくり知りたいというニーズもあり、課題となっています。

そこで今回は自然言語処理の技術を用いてクチコミを要約し、会社の雰囲気が一発で分かるような機能のプロトタイプを、文章要約技術を学びながら作ってみようと思います。

## 解決方法
### 自然言語処理による文章要約
今回は、PageRankの考え方を文章要約に利用した "LexRank" という技術を用いました。これは複数の"文章" (転職会議ならとある企業に投稿された複数のクチコミの集合体) から、重要と考えられる複数の文を「抽出」することで要約を生成します。

PageRankは「多くのサイトからリンクされているサイトはきっと良いサイトだろう」という考え方ですが、LexRankの場合は「文章の中で似ている文が多い文はきっと文章で言いたい内容に近いんだろう」という考え方です。誤解を恐れずざっくりいうと「大事なことなので(以下略」っていうアレですね笑。

LexRankの算出は具体的には以下のような処理を行うため、これをどう実現したかを書きます。

* 文同士の類似度を計算 (PageRankで言うとバックリンク検出)
* 文同士の類似度から関連グラフを作る
* 関連グラフから各文のPageRankを計算する

関連グラフイメージ ([原論文](https://www.aaai.org/Papers/JAIR/Vol22/JAIR-2214.pdf)より / d2s3: Document#2 の Sentence#3 を指す)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/99986/1bebed92-3a6a-827e-54ea-cb026376a750.png)

### 使う技術
* python+gensim: クチコミによる [会社分類](https://qiita.com/naotaka1128/items/2c4551abfd40e43b0146) や [ラーメン屋分類](https://qiita.com/naotaka1128/items/87d717961bd0c34e7a64) での使い方と同じ
* networkx: PageRankを算出するのに使う

(その他、省力化のためsklearnとか使ってますが本筋と外れるので省略します)

## 実装概要
論文ではいくつかの手法が提案されていましたが、今回はとりあえず結果見てみたかったので以下のような流れで ContinuousLexRank のみを実装しました。

* 前処理
  * 転職会議の大量のクチコミからTFIDFモデル生成
  * 要約対象企業のクチコミをDBから読込 → 単文に分解
* 要約実行
  * 各文のTFIDFベクトル取得
  * LexRank計算
      * 文同士の類似度計算
      * 各文のLexRank(=PageRank)計算
  * 冗長性排除しつつ抽出要約

## 実装詳細
### 前処理
TFIDFモデル生成等の前処理は以下のような感じで行っています。

```py3:main.py(前半)
# Gensim用のモデル
from model.tfidf import TfidfModel

# 自作のデータ読み込み&前処理用ライブラリ
from lib.db import load_docs_for_training, load_reviews_and_split_to_sentences

# TF-IDFモデル生成用のクチコミ読み込み → モデル生成
## docs: 10万件程度のクチコミの Array
docs = load_docs_for_training()

## TFIDFモデル生成
tfidf = TfidfModel()
model, dictionary = tfidf.generate(docs)

# 要約したい企業のクチコミ読み込み → 単文への分割
# sentences_unfiltered: 対象企業のクチコミ全文を単文に分割した Array
sentences_unfiltered = load_reviews_and_split_to_sentences(target_company_id)
```

TFIDFモデル生成のコードは以下の通りです。辞書は準備していませんが、頻出単語やめったに使われない単語を無視するなどの最低限の処理は行いました。

```py3:model/tfidf.py
import gensim

class TfidfModel(object):
    def __init__(self):
        # 自動生成辞書設定(このあたりは適宜調整)
        self.no_below = 10  # XX回以下しか出てこない単語は無視
        self.no_above = 0.05  # 頻出単語も無視
        self.keep_n = 10000  # 使用単語数に上限設定

    def generate(self, docs):
        dictionary = gensim.corpora.Dictionary(docs)
        dictionary.filter_extremes(no_below=self.no_below,
                                   no_above=self.no_above,
                                   keep_n=self.keep_n)
        corpus = [dictionary.doc2bow(doc) for doc in docs]
        tfidf_model = gensim.models.TfidfModel(corpus)
        return tfidf_model, dictionary
```

### 要約実行
準備が整ったら、以下のように要約を実行していきます。

```py3:main.py(後半)
# 要約アルゴリズム本体
from summarize import summarize

# 要約, 文抽出
summary_sentences = summarize(
    sentences_unfiltered, model, dictionary,
    max_characters=70, use_mmr=True, sent_limit=50
)
for sentence in summary_sentences:
    print(sentence.strip())
```

```summarize.py``` では次のようなことを行っています。

* 最大文字数以上の文の排除
    * これを行わないと、すごく長くて色々なことが書かれている文が要約に出やすかったため
* 文のTFIDFベクトル取得
    * 分かち書き(名詞/形容詞/動詞のみ, 辞書なし)を行った後
    * Gensimのモデルに突っ込むということを行っています
    * [クチコミによるラーメン屋分類](https://qiita.com/naotaka1128/items/87d717961bd0c34e7a64#4-%E5%BA%97%E3%81%AE%E5%88%86%E9%A1%9E-%E3%83%88%E3%83%94%E3%83%83%E3%82%AF%E5%88%86%E3%81%91) での使い方とほぼ同じなのでそちら参照下さい
* LexRank計算: ```algorithm/lexrank.py``` を呼び出しています
* 抽出要約
    - ↑で計算したLexRankを用いて文を降順ソート
    - 上位X件を要約文として抽出します
    - ここで、同じような文の抽出を避けるためにMMRというロジックを利用しました
        - ざっくりいうと: 既に抽出した文と似ている文のLexRank(等)のスコアにペナルティを掛ける
        - [ロジック理解の参考](http://www.cs.bilkent.edu.tr/~canf/CS533/hwSpring14/eightMinPresentations/handoutMMR.pdf), [Pythonコード参考](https://www.quora.com/Where-can-I-find-a-maximum-marginal-relevance-algorithm-in-Python-for-redundancy-removal-in-two-documents)

コードは以下の通りです。

```py3:summarize.py
from lib.stems import stems
from lib.ordered_set import OrderedSet
from algorithm.lexrank import lexrank


def summarize(sentences_unfiltered, model, dictionary,
              max_characters=70, use_mmr=True, sent_limit=10):
    """
    max_characters: XX文字以上の単文は要約対象外
    use_mmr: 要約実行時にMMR(冗長性排除)を行うか否か
    sent_limit: XX文まで抽出する
    """
    # 最大文字数以上の文をフィルタリング
    sentences = list(filter(lambda s: len(s) < max_characters, sentences_unfiltered))

    # GensimのTFIDFモデルを用いた文のベクトル化
    sent_vecs = [model[dictionary.doc2bow(stems(sent))]
                 for sent in sentences]

    # LexRank算出 → ソート
    sentence_scores, sim_mat = lexrank(sent_vecs)
    if use_mmr:
        # MMRを用いて冗長性排除しつつ降順ソート
        lambda_ = 0.7 # 適当
        indexes = mmr_sort(lambda_, sentences, sent_limit, sentence_scores, sim_mat)
    else:
        # 普通の降順ソート
        indexes = sort(sentence_scores, sent_limit)

    # 抽出
    summary_sents = [sentences[i] for i in indexes]

    return summary_sents


def sort(sentence_scores, sent_limit):
    indexes = OrderedSet()
    num_sent = 0
    for i in sorted(sentence_scores, key=lambda i: sentence_scores[i], reverse=True):
        num_sent += 1
        if sent_limit is not None and num_sent > sent_limit:
            break
        indexes.add(i)
    return indexes


def mmr_sort(lambda_, sentences, sent_limit, sentence_scores, sim_mat):
    indexes = OrderedSet()
    sentence_ids = set(range(len(sentences)))
    while len(indexes) < sent_limit and set(indexes) != sentence_ids:
        remaining = sentence_ids - set(indexes)
        mmr_score = lambda x: (lambda_*sentence_scores[x] - (1-lambda_)*max([sim_mat[x, y] for y in set(indexes)-{x}] or [0]))
        next_selected = argmax(remaining, mmr_score)
        indexes.add(next_selected)
    return indexes


def argmax(keys, f):
    return max(keys, key=f)
```

最後に、ようやく、要約本体の ```algorithm/lexrank.py``` のコードです。(冗談っぽくなった…)
こちらは [リクルートテクノロジーズさん](https://github.com/recruit-tech/summpy/blob/master/summpy/lexrank.py) のコードをかなり参考にしました。

* 文同士のコサイン類似度の計算
* 類似度グラフの生成 (networkxを利用)
* PageRank(LexRank)計算
    * DumpingFactor: 論文と同じく0.85に設定


```py3:algorithm/lexrank.py
import numpy
import networkx
from sklearn.metrics.pairwise import cosine_similarity


def lexrank(sent_vecs, alpha=0.85, max_iter=100000):
    # comvert to sparse matrix
    sent_vecs_sparse = convert_to_sparse_vector(sent_vecs)

    # 文同士のコサイン類似度の計算
    sim_mat = cosine_similarity(sent_vecs_sparse)
    linked_rows, linked_cols = numpy.where(sim_mat > 0)

    # 類似度グラフの生成
    graph = networkx.DiGraph()
    graph.add_nodes_from(range(sent_vecs_sparse.shape[0]))
    for i, j in zip(linked_rows, linked_cols):
        if i == j:
            continue
        graph.add_edge(i, j, {'weight': sim_mat[i, j]})

    # PageRank計算
    scores = networkx.pagerank_scipy(graph, alpha=alpha, max_iter=max_iter)

    return scores, sim_mat
```


## テスト結果
準備が整ったので、上記のコードと転職会議のクチコミを用いて行ったテスト結果を書いていきます。今回は、今年Switchが大ヒットした任天堂さんのクチコミを使ってテストを行ってみました。

要約精度の評価は ROUGE という指標が良く使われますが、正解データを作っていないため今回は精度評価は行いませんでした。ROUGEについては [こちら](https://qiita.com/icoxfog417/items/65faecbbe27d3c53d212) の記事が分かりやすかったです。

代わりと言っては何ですが、MMRあり/なしで冗長性排除の効果を見ておきます。

クチコミのページはこちらから → [任天堂さん](https://jobtalk.jp/company/2985/reputations)

#### MMRあり
最初に、MMRありの場合の要約です。
見るからに待遇良さそうで羨ましいですw

    ただゲームをやるのと作るのは全く違うのでゲーム好きだからという理由だけで入るのは続かないかもしれません
    さすがの大企業だけに福利厚生はとても満足のいくものです
    開発部署においては、女性はデザイナーである事が多く、専門的な技術を持つ人が多いため、女性の出産、育児休暇後の復職率は高いです
    同業界の他会社を知らないのでなんとも言えませんが、休暇制度や福利厚生は整っている会社だと思います
    昇進については一般的な企業とそれほど変わらないと思っていただいていいとおもいます
    風通しの良い社風で、社員の方もいい人が多かったです
    自分はあまり成功した方ではありませんが同期と比べてボーナスも変化はなかったのでそちらに影響はでないようです
    ワークライフバランスについては、やはり大手企業なので、プライベートと仕事の時間が多く取れました
    学歴重視はありますが、面接ではちゃんと人をみてくれていると感じました
    待遇面においては全く不満がありませんでした

#### MMRなし
次に、冗長性排除しなかった場合の要約です。
さすがゲーム会社だけあって、「ゲーム」だらけで参考になりませんw

    ただゲームをやるのと作るのは全く違うのでゲーム好きだからという理由だけで入るのは続かないかもしれません
    ゲーム専用機業界もそろそろ下火かもしれません
    ゲーム好きにはたまらんでしょうねえ
    アルバイトでゲームのデバックをしていました
    今ゲーム業界全体が下火になっています
    ゲームへの愛がないと厳しい
    ゲームが好きならとにかく楽しい仕事だと思います
    元々ゲームが好きで、昔から任天堂に入りたいという夢がありました
    入社理由は、任天堂のゲームが好きだから
    みたいな(笑)でもやりたくないゲームや好きじゃないゲームをやるのがちょっと辛かったかも |

## 今後の課題
昨年から課題感があまり変わっていないのですが、上記の手法では「残業多い」「残業少ない」などを考慮しません。よって、残業が多い会社でも「残業が少ない」という言及がある文がたまたま要約に入ってきたりすると転職希望者さまの判断にミスリードをしてしまう可能性があります。

このあたり、文の極性判定などで比較的簡単に処理できる可能性もあるので、そのような可能性も含めて今後改善していきたいと思います。

## まとめ
いかがだったでしょうか？今回は、自然言語処理を用いて転職会議内に存在する大量のクチコミからサマリーを抽出するという手法をご紹介しました。

このあたりを是非仕事でやってみたい、というエンジニアやプロダクトマネージャーの方がいらっしゃいましたら、是非、[コチラ](https://recruit.livesense.co.jp/job/)から採用情報をご覧頂くか、[コチラ](https://twitter.com/naotaka1128)からカジュアルにご連絡いただければ幸いです。