エムスリーテックブログ

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

GiNZAと患者表現辞書を使って患者テキストの表記ゆれを吸収した意味構造検索を試した

f:id:abctail30:20210217022649j:plain

エムスリーエンジニアリンググループ AI・機械学習チームの中村(@po3rin) です。 好きな言語はGo。仕事では主に検索周りを担当しています。

最近「医療言語処理」という本を読んで、医療用語の表記ゆれ吸収や意味構造検索などについて学びました。

医療言語処理 (自然言語処理シリーズ)

医療言語処理 (自然言語処理シリーズ)

そこで今回はElasticsearchと患者表現辞書を使った意味構造検索がどのくらい実戦投入できるかを簡単に試したので、概要と実装方法を簡単にご紹介します。

患者テキストの表記ゆれ

この節では医療言語処理における患者テキストの表記ゆれ問題について言及し、MEDNLPから公開されている患者表現辞書を紹介します。

患者テキストの表記ゆれとは

医療言語処理における表記ゆれと聞いてまず思い浮かべるのは医療用語の揺れでしょうか。「癌、ガン」「産じょく、産褥」「片頭痛、偏頭痛」など日本語では漢字、カタカナ、平仮名の混合表記があり、表記ゆれが非常に大きいのが課題になっています。

一方で患者が記述する文書に対する言語処理の壁として挙げられるのが患者テキストの表記ゆれです。患者は医療用語を使わずに非医療用語を使うため、医療用語の揺れ以上に患者テキストではより大きなギャップが存在します。例えばICD-10(国際疾病)コードのR098で定義されている胸部不快感という病名を訴える場合「胸が苦しい」と報告する患者もいれば「胸がムカムカする」と表現する患者もいます。

f:id:abctail30:20210217232630p:plain

もちろん、患者テキストの検索というタスクでも表記ゆれは大きな課題です。弊社ではAskDoctorsという医師Q&Aサービスを運用しており、Q&Aを検索できる機能があり、まさに患者テキストを元に、検索を実施するサービスです。

www.askdoctors.jp

例えば、トークンベースでの基本的なヒット判定では「胸が苦しい」で検索した時に「胸がムカムカ」がヒットしません。医師への質問で同じ病気を別の表現で説明している場合、検索意図にあったドキュメントが返せず検索の適合率が下がってしまいます。

単語の表記ゆれに対してはシノニムが対策として挙げられますが、症状を表現する場合は単語レベルでのゆれではないので難しいところです。例えば「苦しい」と「ムカムカ」を類似語として設定するのはやり過ぎに感じます。そのため患者テキストの表記ゆれは検索においてはすぐに解決できる問題ではありません。

MEDNLPの患者表現辞書

患者テキストの表記ゆれに対する手札としてMEDNLPで公開されている患者表現辞書が挙げられます。 sociocom.jp

患者表現辞書は、患者が用いる表現に関する辞書であり、患者の表現に対応する、標準病名や部位、ICD-10コードなどが列挙されています。

f:id:abctail30:20210216204348p:plain
患者表現辞書

この辞書でユーザーの検索クエリの全てを処理できる訳ではありませんが、これを使えば「胸が苦しい」や「胸がムカムカ」のような表記ゆれを吸収できそうです。

トークンによる検索の課題と対策の検討

この節ではトークンによる検索の課題を紹介し、その対策となりうる意味構造検索を紹介します。

主語が違うのにヒットしちゃう?

トークンマッチで検索する場合、普通にやると「頭が痛い」というクエリは「頭 痛い」に形態素解析され「頭は正常だが胃が痛い」というドキュメントがヒットしてしまいます。これは検索意図を汲んでいないので検索ノイズと言えます。

f:id:abctail30:20210216211333p:plain
検索意図とずれた検索

フレーズマッチの場合でも、例えばslop=0に設定した場合、「頭が痛い」というクエリで「頭がズキズキと痛い」にヒットしてくれないのでフレーズマッチでは完璧に解決しきれない問題です。

この問題を解決するためには単語間の係り受け解析を用いた意味構造検索が役立ちます。

意味構造検索

意味構造検索とはテキストに対して係り受け解析を行い文章の構造を捉えて検索することを指します。係り受け解析では下記のように文章の単語間の関係を解析します。

f:id:abctail30:20210216212122p:plain
係り受け解析

この係り受け解析により、「頭が痛い」と「頭は正常だが胃が痛い」では痛いに繋がる主語名詞の関係が一致しないと判断でき、検索ノイズが減らせます。

係り受け解析と患者表現辞書を使った意味構造検索の実装

ここまでで患者テキストのゆれや、意味構造解析の概要を説明しました。ここからは実際にElasticsearch上で係り受け解析を使った意味構造検索を実装してみます。

今回の実装に利用するモジュールやPythonのバージョンは下記です。

[tool.poetry.dependencies]
python = "^3.9"
spacy = "^2.3.5"
ginza = "^4.0.5"
fastapi = "^0.63.0"
uvicorn = "^0.13.3"
joblib = "^1.0.1"
elasticsearch = "^7.11.0"
pandas = "^1.2.2"
numpy = "^1.20.1"

そして、今回は下記のようなアーキテクチャで実装していきます。

f:id:abctail30:20210218015057p:plain
今回試す検索アーキテクチャ

患者表現辞書を使った係り受け解析

係り受け解析ではGiNZAを利用します。 GiNZAは日本語の自然言語処理ライブラリであり、自然言語処理フレームワークspaCy に対応しています。内部の形態素解析器はSudachiが利用されています。

github.com

GiNZAを使うと下記コードのように日本語の係り受け解析や固有表現抽出が簡単に行えます。

# Jupyter Notebookeでvisualize想定
class DependencyAnalysis:
    def __init__(self):
        self.nlp = spacy.load('ja_ginza')

    def run(self, text):
        doc = self.nlp(text)

        displacy.render(doc, style="dep", jupyter=True, options={'distance': 90})
        displacy.render(doc, style="ent", jupyter=True,)


dependency = DependencyAnalysis()
dependency.run("頭は正常だが胃が痛い")

Jupyter Notebook上で実行すれば係り受け解析の結果と固有表現抽出の結果を確認できます。

f:id:abctail30:20210216215859p:plain
係り受け解析と固有表現抽出の実行結果

固有表現抽出ではANIMAL_PARTタグで動物の体の部位が抽出できています。これを使えば体の部位に紐づく構文のみを取得できそうです。

それでは体の部位の症状に関する係り受け結果だけを返すクラスDependencyAnalysisを作ります。

class DependencyAnalysis:
    def __init__(self) -> None:
        self.nlp = spacy.load("ja_ginza")

    def run(self, text: str) -> dict[str, str]:
        """
        体の部位を含む主語-名詞,関節目的語の構文を抽出します。
        """
        doc = self.nlp(text)

        ent_words = [
            ent.text for ent in doc.ents if ent.label_ == "Animal_Part"]

        deps = []
        for sent in doc.sents:
            for token in sent:
                if (
                    token.dep_ in ["nsubj", "iobj"]
                    and token.lemma_ in ent_words
                    and len(sent) >= token.head.i
                ):
                    deps.append(f"{token.lemma_}->{sent[token.head.i].lemma_}")

        return deps

今回は簡単のために「主語名詞(nsubj)」「関節目的語(iobj)」の関係のみを抽出し、その関係を「主語->名詞」のような形にフォーマットした文字列として扱っていきます。これによってグラフマッチング問題になって重くなるのを回避しています。

続いてDependencyAnalysisを使って患者表現辞書を扱うクラスPatientExpressionCoupusを作成します。PatientExpressionCoupusでは患者表現辞書を係り受け解析に通して、pandas.DataFrameに変換します。これを辞書として病名に紐づく患者表現を全て取得します。

f:id:abctail30:20210217014046p:plain

それではPatientExpressionCoupusのコードを書いていきます。

class PatientExpressionsCoupus:
    def __init__(self) -> None:
        self.df = {}
        self.da = DependencyAnalysis()

    def get_symptom_expressions(self, desease: str) -> list[str]:
        """
        病名に紐づく患者表現一覧を取得する
        """
        expressions = self.df[self.df["標準病名"] == desease]["出現形"].tolist()
        return expressions

    def get_symptom_deps(self, desease: str) -> list[str]:
        """
        病名に紐づく係り受け構文を取得する
        """
        deps = self.df[self.df["標準病名"] == desease]["deps"].tolist()
        return deps

    def get_deseases(self, expression: str) -> list[str]:
        """
        患者表現に紐づく病名を取得する
        """
        deps = self.da.run(expression)
        deseases = self.df[self.df["deps"] == ",".join(deps)]["標準病名"].tolist()
        return deseases

    def analyze(self, text: str) -> str:
        """
        患者表現を係り受け解析して検索のkeyになるようにstringに変換して返す
        """
        deps = self.da.run(text)
        if len(deps) == 0:
            return np.NaN
        return ",".join(deps)

    def load_from_csv(self, file) -> None:
        """
        患者表現辞書をpands.DataFrameに変換
        """
        self.da = DependencyAnalysis()
        with codecs.open(file) as f:
            df = pd.read_table(f, delimiter=",")

        df = df.loc[:, ["出現形", "標準病名"]]
        df["deps"] = df.apply(lambda x: self.analyze(x["出現形"]), axis=1)
        df = df.dropna(how="any")
        self.df = df

PatientExpressionsCoupusの動作確認をしてみましょう。

pec = PatientExpressionsCoupus()
pec.load_from_csv("corpus/D3_20190326.csv")

d = DependencyAnalysis()
dep = d.run("胸が苦しい")
print(dep)

deseases = pec.get_deseases(t)
print(deseases)

expression_list = []
for d in deseases:
    expression_list.extend(pec.get_symptom_expressions(d))
print(expression_list)

corpus/D3_20190326.csvは患者表現辞書をcsvに変換したものです。 これを実行すると係り受け結果、患者表現に紐づく病名、病名に基づく患者表現が取得できています。また、「胸が苦しい」から様々な「胸痛」や「胸部不快感」を表現する患者表現が確認できます。

[{'dep': 'nsubj', 's': '胸', 'h': '苦しい'}]

['胸痛', '胸内苦悶', '胸部不快感', '胸部不快感']

['右の胸が痛む', '右側の腹部が痛むこと', '急に病的に胸が痛い', '胸がいたい', '胸がいたか', '胸がキュッと', '胸がキュンキュン', '胸がキリキリ', '胸がギリギリと', '胸がグッと', '胸がズキズキ', '胸がズキズキする', '胸がチク
チク', '胸が急に痛くなる', '胸が急に痛む', '胸が苦しい', '胸が切ない', '胸が痛い', '胸が痛いこと', '胸が痛い発作', '胸が痛くなる', '胸が痛くなる発作', '胸が痛む', '胸が痛む', '胸が締め付けられる', '胸が突然痛む', '左の胸
が痛い', '左の胸が痛くなる', '左の胸が痛む', '左胸がいたい', '左胸が痛いこと', '左側の胸が痛い', '心臓が痛い', '心臓が痛くなる', '突然胸が痛くなる', '脇腹が痛い', '胸がきつい', '胸がキュー', '胸がキューッ', '胸がきゅっと', '胸がキュッとなる', '胸がくるしい', '胸がしめつけ', '胸がしめつけられる', '胸がしんどい', '胸がつまる', '胸が圧迫感', '胸が押さえつけられる', '胸が押さえつけられるような感じ', '胸が押されるような', '胸が押される感じ', '
胸が詰まる', '胸が苦しい', '胸が締め付け', '胸が締め付けられる', '胸が締め付けられるようだ', '胸や心臓が苦しい', '胸部が押されるような痛み', '心臓がきゅー', '心臓がキュー', '心臓がキューッ', '心臓がきゅっと', '心臓がキュ
ッとなる', '心臓がくるしい', '心臓がしめつけ', '心臓がしんどい', '心臓がつまる', '心臓が圧迫感', '心臓が詰まる', '心臓が苦しい', '心臓が締め付け', '心臓が締め付けられる', '胸がおかしい', '胸がくるしい', '胸がザワザワする', '胸がつかえる', '胸がなんかおかしい', '胸がへん', '胸がムカムカ', '胸がむかむかする', '胸がモヤモヤ', '胸が圧迫される感じ', '胸が押されるような感じ', '胸が気持ち悪い', '胸が苦しい感じ', '胸が苦しくなる', '胸が重い', '胸
が正常ではない感じ', '胸が痛い', '胸が潰れる感じ', '胸が締め付けられているような感じ', '胸が締め付けられるような感覚', '胸が締め付けられるように痛い', '胸が締め付けられる感じ', '胸が締め付けられる感じがする', '胸が不快', '胸の上の方(おっぱいより上)が押しつぶされるような感じ', '胸部が悪い', '心臓が悪い', '胸がおかしい', '胸がくるしい', '胸がザワザワする', '胸がつかえる', '胸がなんかおかしい', '胸がへん', '胸がムカムカ', '胸がむかむかする
', '胸がモヤモヤ', '胸が圧迫される感じ', '胸が押されるような感じ', '胸が気持ち悪い', '胸が苦しい感じ', '胸が苦しくなる', '胸が重い', '胸が正常ではない感じ', '胸が痛い', '胸が潰れる感じ', '胸が締め付けられているような感じ', '胸が締め付けられるような感覚', '胸が締め付けられるように痛い', '胸が締め付けられる感じ', '胸が締め付けられる感じがする', '胸が不快', '胸の上の方(おっぱいより上)が押しつぶされるような感じ', '胸部が悪い', '心臓が悪い']

毎回、患者表現辞書から係り受け解析を通すのは時間がかかるのでpicklejoblibで保存してロードできるようにしておくと良いでしょう。

患者表現辞書の表現をクエリに展開するAPI

ここまできたらあとはElasticsearchを叩くAPIを作るだけです。今回はPythonの軽量WebフレームワークであるFastAPIを使って構築します。

APIはドキュメントの登録(/topics)と検索(/topics/search)だけです。Elasticsearchでインデックスする検索対象データはAskDoctorsの質問タイトルのみだけを想定しています。

fastapi.tiangolo.com

app = FastAPI()
es = Elasticsearch("http://localhost:9200")

pec = PatientExpressionsCoupus()
pec.load("symptoms_expression_dict.joblib")

d = DependencyAnalysis()


@app.get("/topics/search")
def topics(q: str = None):
    deseases = pec.get_deseases(q)

    deps = []
    for d in deseases:
        deps.extend(pec.get_symptom_deps(d))

    expression_queries = [{"match": {"deps": e}} for e in deps]
    if len(expression_queries) == 0:
        return {}

    query = {
        "query": {"bool": {"should": expression_queries, "minimum_should_match": 1}}
    }
    q = json.dumps(query)
    return es.search(index="topics", body=query, size=3)


@app.post("/topics")
def topics(body: dict = Body(None)):
    deps = d.run(body["title"])

    topic = {
        "id": body["id"],
        "title": body["title"],
        "deps": deps,
    }
    return es.create(id=body["id"], index="topics", body=topic)

これで患者テキストの表記ゆれを吸収した意味構造検索の完成です。

動作確認

ドキュメント登録します。

POST localhost:8000/topics
{
    "id": 1111,
    "title": "お腹が痛むが首は正常"
}

データを見ると係り受け結果を含んだデータとして保存できていることを確認できます。

{
    // ...
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 0.8630463,
        "hits": [
            {
                "_index": "topics",
                "_type": "_doc",
                "_id": "1113",
                "_score": 0.8630463,
                "_source": {
                    "id": 1113,
                    "title": "お腹が痛むが首は正常",
                    "deps": [
                        "お腹->痛む",
                        "首->正常"
                    ]
                }
            }
        ]
    }
}

次に検索です。結果を見ると、「お腹が痛い」と「お腹がチクチクする」の表記ゆれを抑えて検索ができています。

curl localhost:8000/topics/search?q=お腹がチクチクする
{
    // ...
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 0.8630463,
        "hits": [
            {
                "_index": "topics",
                "_type": "_doc",
                "_id": "1113",
                "_score": 0.8630463,
                "_source": {
                    "id": 1113,
                    "title": "お腹が痛むが首は正常",
                    "deps": [
                        "お腹->痛む",
                        "首->正常"
                    ]
                }
            }
        ]
    }
}

一方で、「首」も「痛い」もtitleにあるので、「首 痛い」というトークンでAND検索を行った場合「お腹が痛むが首は正常」がヒットしてしまいますが、係り受け解析を行なっているので「痛い」の主語を正しく判断し、ヒットを回避しています。

curl localhost:8000/topics/search?q=首が痛い
{
    // ...
    "hits": {
        "total": {
            "value": 0,
            "relation": "eq"
        },
        "max_score": null,
        "hits": []
    }
}

これで患者テキストの表記ゆれを吸収した意味構造検索ができました。

実戦投入までの課題

実際の検索クエリでは今回のように助詞が必ず含まれているわけではなく、ほとんどの場合は「発熱 咳 ない 鼻 出る」など助詞が省略された形でクエリを処理する必要があります。デフォルトのGiNZAでは助詞がないと精度良く係り受け解析ができません。

また、患者表現辞書だけではユーザーのクエリを全て捌ける訳ではないので、いつもの検索ロジックと合わせて使う必要がありそうです。

そして今回は体の部位を含む簡単な文章だけを対象にしましたが、さらにサポート範囲を広くしていくのであればチューニングはもちろん、医療用語辞書の導入、延いては患者表現辞書の独自拡張も検討していくと良さそうです。

まとめ

今回はGiNZAと患者表現辞書を使ってElasticsearchによる意味構造検索を試してみました。これからも検索改善に繋がる技術を実験していければと思います。

We're hiring !!!

エムスリーでは検索基盤の開発&改善を通して医療を前進させるエンジニアを募集しています! 社内では最近、検索チームを中心に「Elasticsearch & Lucene コードリーディング会」が発足し、検索の仕組みに関する議論も活発です。

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

jobs.m3.com

Reference

患者表現辞書 sociocom.jp

GiNZA+Elasticsearchで係り受け検索の第一歩 acro-engineer.hatenablog.com

医療言語処理

医療言語処理 (自然言語処理シリーズ)

医療言語処理 (自然言語処理シリーズ)