エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

LLRを使った複合語分割で医療用語辞書を検索特化させたい

f:id:abctail30:20211024012754p:plain

エムスリーエンジニアリンググループ AI・機械学習チームでソフトウェアエンジニアをしている中村(@po3rin) です。好きな言語はGo。情報検索系の話が好物です。

今回はネット上に公開されている医療用語辞書を検索特化させるために統計的複合語分割を試したお話です。

医療用語辞書を検索で使う際の問題

辞書の複合語分割問題

現在公開されている医療用語辞書には様々なものがあります。例えばComeJisyoは形態素解析での用途を想定した医療用語辞書です。しかし、これをそのまま検索用の辞書として利用すると、辞書に登録されているそのままの表現でないと検索クエリにヒットしないという問題が発生します。

例えば、Elasticsearchでkuromojiを利用してユーザー辞書としてComeJisyoを利用できますが、ComeJisyoをそのままの形で登録して使うと、その表現で必ずドキュメントが形態素解析されてしまいます。つまり「じんま疹」というクエリに対して、ComeJisyoに定義されている「アレルギー性じんま疹」を含むドキュメントがヒットしないという現象が発生します。

特に日本語の医療用語は複合語が非常に多く、医療自然言語処理では1つの壁として立ちはだかります。医療用語の複合語の構成要素を決定するための研究も存在し、医療用語の構成語の意味的カテゴリーを分類する試みがあります。

辞書による複合語分割の指定

この問題の1つの解決策としてユーザー辞書内で半角スペースを使って複合語の分割を指定する方法があります。

# このままだと「アレルギー性のじんま疹」というクエリでヒットしない
アレルギー性じんま疹,アレルギー性じんま疹,アレルギーセイジンマシン,カスタム名詞

# 「アレルギー性のじんま疹」というクエリでヒットする
アレルギー性じんま疹,アレルギー 性 じんま疹,アレルギー セイ ジンマシン,カスタム名詞

最近弊社では形態素解析器をSudachiに移行して辞書の表現がより豊かになり、分割指定も柔軟にできるようになりました。Sudachiでの単語分割に興味がある方は是非弊社のブログをご覧ください。

https://www.m3tech.blog/entry/sudachi-es

分割単位をどのように決めるか問題

辞書に分割情報を入れる方法を紹介しましたが、医療用語辞書の登録語は数が多く、それを1つ1つ目視で分割していくのはかなり辛いので、分割単位をどのように自動で決定するかが今回の本題になります。

弊社では現在、複合語分割の自動化に対応する方法として、Sudachiによる形態素解析結果をそのまま分割情報として辞書に登録しています。しかしSudachiのAモードは短単位での分割なので、分割しすぎることが問題になります。例えば「受容体」などの単語はSudachiのAモードだと「受容」と「体」に分割されます。この結果「体」というクエリで「受容体」がヒットしてしまいます。「体」と「受容体」は全く意味が違うので、ヒットしないのが望ましい動作でしょう。そのため複合語を検索用途に合うようにより適切に分割、結合する必要があります。「受容体」の例では「受容/体」と分割されないような判断を何かしらの方法で自動で行う必要があります。

対数尤度比を使った複合語分割

そこで統計的に複合語の分割を決める手法を採用し、医療辞書の複合語を検索用に最適に分割する方法を検討します。今回は対数尤度比(LLR)を使って医療用語の分割を試みます。

対数尤度比とは

対数尤度比 (Log-Likelihood Ratio) はある帰無仮説に従って得られる結果に対して、対立仮説で得られる結果から帰無仮説の結果の「尤もらしさ」を意味する指標です。対数尤度比は自然言語処理の分野においても多く利用され、複合語判定だけでなく、コロケーション抽出、Query Understandingなどでも利用されます。

コロケーション抽出においてLLRを利用する方法については下記の書籍に解説があります。

今回はLLRの理論的な導出までは触れずに、計算方法だけ簡単にお伝えします。興味のある方は「The Statistics of Word Cooccurrences Word Pairs and Collocations」のChapter3が詳しく解説しているので、そちらをご覧ください。Lemmaにも細かい計算過程が記述されています。

複合語として登録すべきかどうかのスコアをLLRとして算出する方法について解説します。 「受容体」を例に取ったとき、共起情報を下記のように分割表にまとめます。

「体」 「体」以外の単語 合計
「受容」  O_{11}  O_{12}  R_{1}
「受容」以外の単語  O_{21}  O_{22}  R_{2}
合計  C_{1}  C_{2} N

簡単に説明すると

  • 「受容」が出現した後に「体」が続いた数を O_{11}
  • 「受容」が出現した後に「体」以外の単語続いた数を O_{12}
  • 「受容」以外の単語が出現した後に「体」が続いた数を O_{21}
  • 「受容」以外の単語が出現した後に「体」以外の単語続いた数を O_{22}

とします。

対数尤度比( LLR )は下記の式で計算できます。

 E_{ij} = \frac{ R_i C_j }{ N }

 LLR = 2 \sum ( O_{ij} \log \frac{ O_{ij} }{ E_{ij} } )

ここで f(v) = v \log v とすると  LLR は

 LLR = 2  \left( f(O_{11}) + f(O_{12}) + f(O_{21}) + f(O_{22}) + f(N)  - f(R_1) - f(R_2) - f(C_1) - f(C_2) \right)

と簡単な形に書き下せます。よって対数尤度比を算出するためには,分割表の全てのセルにおいて  f(v) を計算していきます。

今回はこの LLR の値を複合語として登録するかどうかを判断するためのスコアとして直接利用します。LLRが高いほど共起する可能性が高い単語なので、辞書登録時に分割しないという判断になります。

医療用語辞書を対数尤度比で複合語分割しない単語を抽出

PythonでLLRを実装しComeJisyoの複合語の分割を決めるスコアを出力してみます。複合語を辞書登録時に分割するかしないかを判断する境界はSudachiのAモードの分割単位とします。今回使用するComeJisyoは2020年7月に公開されているComeJisyoUtf8-2r1を利用します。

まずはモジュールのimportです。複合語の分割候補を決めるためにSudachiを利用します。

import math
from collections import Counter
from typing import List, Dict

import pandas as pd
from sudachipy import tokenizer
from sudachipy import dictionary

まずはデータを読み込みます。

# ComeJisyoにheaderがないのでこちらでカラム名を定義してfilter
df = pd.read_csv('./../resources/ComeJisyoUtf8-2r1.csv',header=None, names=['word','a','b','c','pos','e','f','g','h','i','j','k','l','m'])
df = df[['word', 'pos']]

corpus = df['word'].to_list()
corpus = corpus[5000:10000] # 今回はとりあえず複合語5000件で実験

続いてSudachiで形態素解析する関数と、トークンのリストを受け取って2-gramを返す関数を用意します。2-gramはSudachiの形態素解析結果のトークンの2-gramになっています。例えば「アレルギー性じんま疹」はSudachiで「アレルギー/性/じんま疹」に分割されるので2-gramは「アレルギー/性, 性/じんま疹」になります。

def tokenize(text: str):
    return [str(m.surface()) for m in tokenizer_obj.tokenize(text, mode) ]

def generate_ngrams(tokens: List[str], n_gram=2) -> List[str]:
    ngrams = zip(*[tokens[i:] for i in range(n_gram)])
    return [ngram for ngram in ngrams]

ここで今回のLLRの値を返すターゲットとなる医療用語辞書の2-gramのリストを作成しておきます。これがLLRでスコアを算出する対象のリストになります。

target_tokens = [ tokenize(str(c)) for c in corpus ]
target_bigram = [ n for ts in target_tokens for n in generate_ngrams(ts)]

次にLLRを計算するクラスを実装します。初期化時に渡されたテキストのリストから共起数を取得してDataFrameにしておきます。この共起数のデータからcalculateメソッドでLLRを計算します。

def f(v: int) -> float:
    if v == 0:
        return 0
    return v*math.log(v)

class LLR():
    def __init__(self, txt_list: List[str]) -> None:
        tokens_list = [tokenize(str(txt)) for txt in txt_list]
        ngram = [ n for ts in tokens_list for n in generate_ngrams(ts)]
        cnt_pairs = Counter(ngram)
        n_1, n_2, freq = [], [], []

        for (bigram1, bigram2), count in cnt_pairs.items():
            n_1.append(bigram1)
            n_2.append(bigram2)
            freq.append(count)

        self.df = pd.DataFrame({'before': n_1, 'after': n_2, 'freq': freq})

    def get_corpus(self) -> Dict:
        return self.df

    def calculate(self, x,y: str) -> float:
        A = self.df[(self.df['before']==x) & (self.df['after']==y)]['freq'].sum()
        B = self.df[(self.df['before']==x) & ~(self.df['after']==y)]['freq'].sum()
        D = self.df[~(self.df['before']==x) & (self.df['after']==y)]['freq'].sum()
        E = self.df[~(self.df['before']==x) & ~(self.df['after']==y)]['freq'].sum()

        C = A+B
        F = D+E
        G = A+D
        H = B+E
        I = G+H

        return 2*( f(A) + f(B) + f(D) + f(E) + f(I) - f(C) - f(F) - f(G) - f(H))

これでLLRを計算する準備ができました。早速実行してみます。

llr = LLR(corpus)
llr_scores = {n: llr.calculate(*n.split(' ')) for n in set(target_bigram)}
sorted(llr_scores.items(), key=lambda x:x[1], reverse=True)[:20]

下はLLR上位20を表示したものです。

[('症候 群', 708.9854464677628),
 ('アキレス 腱', 260.94084364152513),
 ('アレルギー 性', 178.1249067739409),
 ('気管 支', 150.8144970087742),
 ('アミノ 酸', 145.14973750742502),
 ('ウイルス 性', 127.40252258449618),
 ('アルコール 性', 119.58341157592076),
 ('è¡€ ç—‡', 102.19222720817197),
 ('受容 体', 100.87217141050496),
 ('エックス 線', 92.2298395754915),
 ('基 転移', 89.86543322118814),
 ('不 適合', 85.4975636832678),
 ('å¡©é…¸ å¡©', 84.39067255800182),
 ('インフルエンザ 菌', 84.24880956078414),
 ('è„Šé«„ ç‚Ž', 84.16009997777292),
 ('é…¸ å¡©', 80.97895242991217),
 ('インス ピロン', 79.75638761358277),
 ('水和 物', 77.77895879908465),
 ('アルツハイマー 型', 67.24952941091033),
 ('感染 症', 65.61721853335621)]

上の結果から検索時に複合語として登録されると困る単語を確認できます。例えば「受容体」を「体」でヒットさせないように「受容体」を最小単位として辞書に登録するという判断ができます。そのほかにも「症候/群」「インス/ピロン」など複合語として分割するべきではない単語も発見できました。用途に合わせてLLRの閾値を決めて複合語分割を決めれば良い辞書が作れそうです。

一方で「ウイルス/性」「アルツハイマー/型」など、検索のために分割してindexしておきたい2-gramも含まれてしまっています。これは辞書だけをコーパスとして利用している為、実際の検索用途に特化できなかった結果だと考えることができます。

クエリログも含めたLLR

先ほどの「辞書だけをコーパスとして利用している為、実際の検索用途に特化できなかった」という仮説を受けて、検索ログデータも利用して、実際の検索クエリの共起情報も考慮してLLRを計算してみます。これでユーザーが入力するログの特徴を形になるのでより検索特化の結果を得ることができそうです。

まずはログデータの取得と整形です。実験では弊社サービスAskDoctorsの検索クエリログデータ1ヶ月分を利用します。

# log data を何かしら素敵な方法で読み込み
df = pd.read_pickle('../resources/askd-log.pkl')
queries = set(df['query'].apply(lambda x: x.replace('\u3000', ' ') if x is not None else None).dropna().to_list()[:100000])

corpusにqueriesをmergeしてLLRを回してみます。

corpus.extend(queries)
llr = LLR(corpus)
lr_scores = {n: llr.calculate(*n.split(' ')) for n in set(target_bigram)}
sorted(llr_scores.items(), key=lambda x:x[1], reverse=True)[:20]

結果は下記になります。

[('痛 み', 230244.12042105198),
 ('症候 群', 62833.73194822669),
 ('ワクチン 接種', 44557.57735911012),
 ('自律 神経', 25344.664280727506),
 ('ç³–å°¿ ç—…', 24263.850860863924),
 ('後遺 症', 23335.373733341694),
 ('リンパ 節', 20688.205953404307),
 ('予防 接種', 20032.058486014605),
 ('統合 失調', 17938.972710222006),
 ('茶 色', 17011.504313260317),
 ('失調 症', 16543.08399012685),
 ('抗生 物質', 16477.49778163433),
 ('緑 内障', 15988.700599074364),
 ('気管 支', 15578.936073839664),
 ('石灰 化', 15196.354923009872),
 ('異 形成', 15081.088995113969),
 ('血 小板', 14301.756585016847),
 ('脳 梗塞', 13817.224607855082),
 ('性 食道', 13459.363096505404),
 ('食道 炎', 13029.133651405573)]

クエリのデータが入ったことでComeJisyoだけでは共起データが少なかったゆえに出ていなかった複合語も捉えることができました。

最初の結果と比べて「〇〇性」という単語が上位から消えました。「ウイルス性」などはクエリ「ウイルス」でもヒットしてほしいのでこの結果は検索にとっては嬉しい結果です。

一方で「ワクチン 接種」に関しては複合語として登録すると「ワクチン接種」にクエリ「ワクチン」でヒットしないのでこれは良くない結果です。検索クエリログに検索時に分割したい複合語である「ワクチン接種」が多く出現するためにこのような結果になったようです。

ちなみにスコアが1番高い「痛み」に関してはsudachiで下記のような形態素解析がされていました。

echo '痛み' | sudachipy -m A -s core
痛 形容詞,一般,*,*,形容詞,語幹-一般    痛い
み 接尾辞,名詞的,一般,*,*,*    味
EOS

「痛 み」の「痛」は正規化すると「痛い」にできるので「痛み」で「痛い」がヒットできます。その為「痛み」で1つの単語とするかは悩みどころです。

結論、クエリを含んだ結果の方が良さそうに見えますが、まだまだ課題があります。

複合語は無限に存在するため、ゼロから複合語を1つ1つ分割すべきかを考えると時間がかかりますが、この手法で複合語候補を絞って人間が目で確認するという方法で良い辞書が作れそうです。クエリに対してクリックしたドキュメントのタイトルをさらにデータとして利用したり、ドキュメント全体をある程度ランダムサンプリングして利用するなどして、より用途に特化したLLR計算ができると考えています。

まとめ

LLRのスコアを出せたので閾値を決めて、複合語の分割をするかしないかをある程度自動で決めることができそうです。一方で、共起情報のみを利用しているので、完全に検索特化の複合語になっているかどうかは人が結果を見て判断する必要がありそうです。

また、ここでいよいよLexical Search(文字列トークンベースでヒットするかを判定する方法)の限界も感じているので、ここまで辞書をチューニングし始めたらSemanticな検索手法も検討していくべきでしょう。

We're hiring !!!

エムスリーでは検索&推薦基盤の開発&改善を通して医療を前進させるエンジニアを募集しています! 社内では日々検索や推薦についての議論が活発に行われています。

「ちょっと話を聞いてみたいかも」という人はこちらから! jobs.m3.com