専門用語抽出、辞書もLLMも使わず形態素解析器のみでどこまでいける？

## はじめに
大規模言語モデル（LLM）の登場によって、自然言語処理の精度はこれまでにないレベルへと大きく進化しました。とはいえ、整備不要で高精度な処理を実現しようとすると、ChatGPT-4oのような大規模モデルの活用が前提となりがちです。これには課金が伴ううえ、API経由でのデータ送信が企業ポリシーに抵触するなど、導入に慎重にならざるを得ない場面も多く見受けられます。

また、LLMを用いて特定のタスクに最適化を図る際には、固有表現や名詞句といった専門用語の認識が求められる場面が多く、それらの用語を含む文章や表現をもとに、タスクに特化した学習データを構築していく必要があります。

**【例】** 製品名、病症名、イベント名、用語の使われ方(主述関係)

専門用語やその文中での用法を抽出するタスクは、ChatGPTのような大規模LLMであれば容易に処理できますが、コストや実行環境の制約から小規模モデルを用いた場合、精度面で課題が残ることも多くあります。

従来の形態素解析器による用語抽出では、「辞書登録」や「結合ルール」の設計に頼る必要があり、複雑な表現に対応しようとすると実装がパズルのように入り組み、汎用的に扱うことが困難でした。

しかし近年では、標準辞書の充実(例：NEologd辞書)に加え、深層学習を取り入れた解析アルゴリズム(例：GiNZA)も登場し、形態素解析器自体も着実に進化しています。
そこで本稿では、LLMを用いずに形態素解析器のみで、どこまで専門用語の抽出が可能かを検証してみます。

## [1] 対象文章
本記事では、日本語医療記事からの専門用語抽出の一例として、以下の通常の形態素解析処理では専門用語の抽出が難しそうな文章を対象に検証します。この一文からは、治験フェーズ、試験名といった専門用語を自然言語処理で抽出可能です。

```text:入力文章 (input.txt)
65歳以上を対象とした海外第IIIb/IV相試験（FIM12；ランダム化二重盲検実薬対照試験）
```
（出典：[高齢者向け高用量インフルエンザワクチンが登場｜日経メディカル](https://medical.nikkeibp.co.jp/leaf/all/series/drug/update/202501/587257.html)）
引用文は研究・技術解説を目的としており、著作権法第32条に基づく「適法な引用」として出典を明記の上で掲載しています。

## [2] 正解の定義
LLMによる抽出精度との比較を行うため、まずはChatGPTを用いて理想的な正解データを作成してみます。
```text:プロンプト
以下の文章から固有表現や名詞句を抽出してください。

#文章
65歳以上を対象とした海外第IIIb/IV相試験（FIM12；ランダム化二重盲検実薬対照試験）
```
#### ChatGPT 処理結果
|固有表現|種類（ラベル）|結果の評価|
|:-|:-|:-|
|<font color="Red">65歳以上</font>|年齢表現（AGE）または数量（CARDINAL）|専門用語ではないので不採用|
|<font color="Red">海外</font>|地域（GPEなど）|専門用語ではないので不採用|
|<font color="Red">第IIIb/IV相試験</font>|医療・臨床試験（EVENTなどの可能性）|専門用語だが範囲が欠けているため不採用|
|<font color="blue">**FIM12**</font>|試験名・コード（MISCまたはORGなど）|採用|

|名詞句|結果の評価|
|:-|:-|
|<font color="Red">65歳以上</font>|専門用語ではないので不採用|
|<font color="blue">**海外第IIIb/IV相試験**</font>|採用|
|<font color="blue">**FIM12**</font>|採用 ※固有表現でも同じ用語が得られている|
|<font color="blue">**ランダム化二重盲検実薬対照試験**</font>|採用|

概ね期待通りに用語が抽出されました。この中から、専門用語として<font color="blue">**青字**</font>で示した3語を理想的な正解として採用します。おそらく人手でアノテーションを行った場合でも、同様の結果になると考えられます。
それでは、これらの用語が形態素解析器によってもうまく抽出されるかを確認してみましょう。


:::note info
形態素解析器について
形態素解析器には、代表的なものとして **MeCab、JUMAN、GiNZA、Sudachi**などが挙げられます。
それぞれに特長があり、たとえば処理速度の速さ、述語項構造解析器との連携、辞書の品詞体系の違い、語の分割単位を柔軟に選べる辞書の有無など、用途に応じて使い分けが求められます。
:::

本記事では、適用事例も多く、私自身が過去のプロジェクトでよく利用してきたMeCab(+NEologd辞書) と GiNZA、そして GiNZAの拡張機能 に焦点を当て、実践的な形で検証を進めていきます。

## [3] MeCabで形態素解析
まずは、[MeCab](https://taku910.github.io/mecab/)+[NEologd辞書](https://github.com/neologd/mecab-ipadic-neologd/blob/master/README.ja.md)で形態素解析してみます。
```bash:MeCab+NEologd 実行
$ mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd/ < input.txt
```
※-d で指定している NEologd 辞書は、標準辞書であるipadicではカバーしきれない複合名詞や固有表現に強みを持つ拡張辞書です。

#### MeCab+NEologd辞書 処理結果
|表層形|原型|品詞|品詞詳細|
|:-|:-|:-|:-|
|65歳|65歳|名詞|固有名詞 一般|
|以上|以上|名詞|非自立 副詞可能|
|を|を|助詞|格助詞 一般|
|対象|対象|名詞|一般|
|と|と|助詞|格助詞 一般|
|し|する|動詞|自立|
|た|た|助動詞||
|海外|海外|名詞|一般|
|第|第|接頭詞|数接続|
|IIIb||名詞|一般|
|/||記号|一般|
|IV|IV|名詞|固有名詞 一般|
|相|相|名詞|接尾 一般|
|試験|試験|名詞|サ変接続|
|（|（|記号|括弧開|
|FIM|FIM|名詞|固有名詞 一般|
|12||名詞|数|
|；|；|記号|一般|
|ランダム|ランダム|名詞|一般|
|化|化|名詞|接尾 サ変接続|
|<font color="Red">**二重盲検実薬対照試験**</font>||名詞|数|
|）|）|記号|括弧閉|

正解として想定した「<font color="blue">**ランダム化二重盲検実薬対照試験**</font>」に対して、抽出されたのは「<font color="Red">**二重盲検実薬対照試験**</font>」のみで、一部が欠けた結果となりました。
また、この用語は「名詞-数」といった品詞として扱われているため、後続の処理で「ここが専門用語である」と明確に認識させるのは難しそうです。
NEologd辞書は、企業名や製品名などの汎用的な専門用語には強い一方で、今回のようなニッチかつ複雑な用語の抽出には限界があるといえそうです。

## [4] GiNZAで形態素解析
続いて、[GiNZA](https://megagonlabs.github.io/ginza/)で形態素解析してみます。(モデルはja_ginzaを用います)
```bash:GiNZA 実行
$ ginza < input.txt
```
#### GiNZA 処理結果
|表層形|原型|品詞|品詞詳細|
|-|-|-|-|
65|65|NUM|名詞-数詞
歳|歳|NOUN|接尾辞-名詞的-助数詞
以上|以上|NOUN|名詞-普通名詞-副詞可能
を|を|ADP|助詞-格助詞
対象|対象|NOUN|名詞-普通名詞-一般
と|と|ADP|助詞-格助詞
し|する|VERB|動詞-非自立可能
た|た|AUX|助動詞
海外|海外|NOUN|名詞-普通名詞-一般
第|第|NOUN|接頭辞
IIIb|iiib|NOUN|名詞-普通名詞-一般
/|/|SYM|補助記号-一般
IV|IV|NOUN|名詞-数詞
相|相|NOUN|接尾辞-名詞的-一般
試験|試験|NOUN|名詞-普通名詞-サ変可能
（|(|PUNCT|補助記号-括弧開
FIM|fim|NOUN|名詞-普通名詞-一般
12|12|NUM|名詞-数詞
；|;|SYM|補助記号-一般
ランダム化|ランダム化|NOUN|名詞-普通名詞-一般
二|二|NUM|名詞-数詞|NumType=Card
重|重|NOUN|名詞-普通名詞-助数詞可能
盲検|盲検|NOUN|名詞-普通名詞-一般
実|実|NOUN|名詞-普通名詞-一般
薬|薬|NOUN|接尾辞-名詞的-一般
対照|対照|NOUN|名詞-普通名詞-サ変可能
試験|試験|NOUN|名詞-普通名詞-サ変可能
）|)|PUNCT|補助記号-括弧閉

この時点では、あくまで通常の形態素解析結果であるため、定義した正解と一致する専門用語はひとつも抽出されていません。

## [5] GiNZAで固有表現・名詞句抽出
次に試すのは、GiNZAが備える**固有表現抽出**や**名詞句抽出**の機能です。これらを用いることで、どのような結果が得られるのかを確認していきます。

```python:ginza_get_entity.py
import spacy

nlp = spacy.load("ja_ginza")

with open("input.txt", "r", encoding="utf-8") as f:
    text = f.read()

doc = nlp(text)

# 固有表現の抽出
print("固有表現:タグ")
for ent in doc.ents:
    print(f"{ent.text}\t{ent.label_}")

# 名詞句の抽出
print("\n名詞句")
for chunk in doc.noun_chunks:
    print(f"{chunk.text}")
```

```bash:GiNZA 固有表現＆名詞句抽出 実行
$ python3 ginza_get_entity.py
```

#### GiNZA 固有表現 処理結果
|固有表現|タグ|
|:-|:-|
|65歳以上|Age|
|FIM|Product_Other|
|<font color="blue">**ランダム化二重盲検実薬対照試験**</font>|Age|
#### GiNZA 名詞句抽出 処理結果
|名詞句|タグ|
|:-|:-|
|対象|-|
|<font color="blue">**海外第IIIb/IV相試験**</font>|-|

定義した正解3語のうち、「<font color="blue">**海外第IIIb/IV相試験**</font>」と「<font color="blue">**ランダム化二重盲検実薬対照試験**</font>」の2語を抽出することができました。
ただ、「<font color="blue">**ランダム化二重盲検実薬対照試験**</font>」のタグが「固有表現:Age」となってしまっているため、扱う際には注意が必要です。
この結果から、GiNZAの固有表現抽出や名詞句抽出の機能を活用することで、専門用語の抽出にも十分な効果が期待できることがわかります。

## [6] GiNZAの形態素解析結果に固有表現・名詞句をマージする
[5] で得られた結果は、形態素解析のように入力文章全体を網羅した出力ではなく、抽出された固有表現や名詞句のみを対象とした断片的な情報です。そのため、文章全体を扱う処理に用いるには、ひと工夫が必要となります。
そこで今回は、[4] で得た GiNZA の形態素解析結果に、固有表現および名詞句の抽出結果をマージし、形態素解析と同様の形式で一括して出力できるよう整えてみます。


```python:ginza_get_morph_with_entity.py
import spacy
from dataclasses import dataclass
from typing import List

nlp = spacy.load("ja_ginza")

# Morph構造体
@dataclass
class Morph:
    surface: str
    reading: str
    lemma: str
    pos: str
    pos_detail: str
    start: int
    end: int

# 読みを安全に取得（拡張属性がない場合に対応）
def get_reading(token) -> str:
    return token._.reading if token.has_extension("reading") else "*"

# トークンが範囲に含まれているか確認
def is_covered(token: Morph, spans: List[Morph]) -> bool:
    for span in spans:
        if token.start >= span.start and token.end <= span.end:
            return True
    return False

# TSV形式で出力
def print_tsv(tag: str, morphs: List[Morph]):
    print(f"\n[{tag}]")
    print("surface\treading\tlemma\tpos\tpos_detail\tstart\tend")
    for m in morphs:
        print(f"{m.surface}\t{m.reading}\t{m.lemma}\t{m.pos}\t{m.pos_detail}\t{m.start}\t{m.end}")

# 入力ファイル読み込み
with open("input.txt", "r", encoding="utf-8") as f:
    for line_no, line in enumerate(f, 1):
        line = line.strip()
        if not line:
            continue

        print(f"\n=== Line {line_no}: {line} ===")
        doc = nlp(line)

        # 形態素解析を行いmphに格納
        mph = []
        for token in doc:
            mph.append(Morph(
                surface=token.text,
                reading=get_reading(token),
                lemma=token.lemma_,
                pos=token.pos_,
                pos_detail=token.tag_,
                start=token.idx,
                end=token.idx + len(token.text)
            ))

        # 固有表現抽出を行いnerに格納
        ner = []
        for ent in doc.ents:
            print("[ent] >>> ", ent)
            ner.append(Morph(
                surface=ent.text,
                reading=''.join(get_reading(t) for t in ent),
                lemma=''.join(t.lemma_ for t in ent),
                pos=ent.root.pos_,
                pos_detail=f"固有表現:{ent.label_}",
                start=ent.start_char,
                end=ent.end_char
            ))
        # 固有表現以外の形態素を補完
        for token in mph:
            if not is_covered(token, ner):
                ner.append(Morph(
                    surface=token.surface,
                    reading=token.reading,
                    lemma=token.lemma,
                    pos=token.pos,
                    pos_detail=token.pos_detail,
                    start=token.start,
                    end=token.end
                ))
        ner.sort(key=lambda m: m.start)

        # 名詞句抽出を行いnckに格納
        nck = []
        for chunk in doc.noun_chunks:
            print("[chunk] >>> ", chunk)
            nck.append(Morph(
                surface=chunk.text,
                reading=''.join(get_reading(t) for t in chunk),
                lemma=''.join(t.lemma_ for t in chunk),
                pos=chunk.root.pos_,
                pos_detail="名詞句",
                start=chunk.start_char,
                end=chunk.end_char
            ))
        # 名詞句以外の形態素を補完
        for token in mph:
            if not is_covered(token, nck):
                nck.append(Morph(
                    surface=token.surface,
                    reading=token.reading,
                    lemma=token.lemma,
                    pos=token.pos,
                    pos_detail=token.pos_detail,
                    start=token.start,
                    end=token.end
                ))
        nck.sort(key=lambda m: m.start)

        # 固有表現・名詞句を補完した形態素解析結果を作成

        # 品詞の優先度
        source_priority = {
            "固有表現": 0,
            "名詞句": 1
        }

        def get_priority(m: Morph) -> int:
            if m.pos_detail.startswith("固有表現"):
                return source_priority["固有表現"]
            elif m.pos_detail == "名詞句":
                return source_priority["名詞句"]
            else:
                return source_priority.get(m.pos_detail, 2)

        # 全候補をまとめてマージ
        candidates = mph + ner + nck

        # スパン重複確認
        def overlaps(a_start, a_end, b_start, b_end):
            return not (a_end <= b_start or a_start >= b_end)

        merged = []
        covered_ranges = []

        # 形態素範囲の長さ、品詞の優先度順にソート
        candidates.sort(key=lambda m: (-(m.end - m.start), get_priority(m)))

        # 固有表現・名詞句を補完した形態素解析結果を作成
        for m in candidates:
            if any(overlaps(m.start, m.end, s, e) for (s, e) in covered_ranges):
                continue
            merged.append(m)
            covered_ranges.append((m.start, m.end))

        # 形態素の出現順にソート
        ex_mph = sorted(merged, key=lambda m: m.start)

        # 結果の出力
        print_tsv("mph：形態素解析結果", mph)
        print_tsv("ner：固有表現抽出結果", ner)
        print_tsv("nck：名詞句抽出結果", nck)
        print_tsv("ex_mph：固有表現・名詞句を補完した形態素解析結果", ex_mph)
```

```bash:ginza 固有表現＆名詞句抽出（形態素解析書式） 実行
$ python3 ginza_get_morph_with_entity.py
```

#### 処理結果
以下に、処理結果から固有表現および名詞句をマージした形態素解析結果のみを抜粋して示します。
|表層形|品詞|品詞詳細|開始|終了|
|:-|:-|:-|:-|:-|
|65歳以上|NOUN|固有表現:Age|0|5|
|を|ADP|助詞-格助詞|5|6|
|対象|NOUN|名詞句|6|8|
|と|ADP|助詞-格助詞|8|9|
|し|VERB|動詞-非自立可能|9|10|
|た|AUX|助動詞|10|11|
|<font color="blue">**海外第IIIb/IV相試験**</font>|NOUN|名詞句|11|24|
|（|PUNCT|補助記号-括弧開|24|25|
|FIM|NOUN|固有表現:Product_Other|25|28|
|12|NUM|名詞-数詞|28|30|
|；|SYM|補助記号-一般|30|31|
|<font color="blue">**ランダム化二重盲検実薬対照試験**</font>|NOUN|固有表現:Age|31|46|
|）|PUNCT|補助記号-括弧閉|46|47|

固有表現抽出と名詞句抽出の結果を組み合わせることで、辞書登録や結合ルールに頼ることなく、専門用語をカバーした形態素解析結果を得ることができました。

## [7] 課題
定義した正解のうち、抽出できなかった「<font color="blue">**FIM:12**</font>」については、辞書登録や spaCyの[DependencyMatcher](https://spacy.io/api/dependencymatcher)を活用して形態素結合ルールを定義することで対応可能と考えられます。
ただし、こうした辞書登録やルール設計に依存し始めると、これまでうまく抽出できていた部分との整合性が崩れやすく、実装が複雑化・属人化していく「沼」に陥るリスクもあるため注意が必要です。

今回はうまく抽出できたケースを中心に紹介しましたが、GiNZA による名詞句抽出では、前後の形態素（特に助詞など）を含めた広いスパンでの結合が行われることがあり、かえって文章的すぎる用語として出力されてしまうケースも少なくありません。
この課題に対しては、名詞句を構成する形態素の情報から、例えば助詞などを分割の起点とする後処理を加えることで、適切な粒度への分割が可能になると考えられます。これは、DependencyMatcherによる結合とは真逆のアプローチになります。

## [8] まとめ
冒頭でも述べたとおり、LLMであれば、専門用語の抽出は高精度で行うことが可能です。しかし、利用に制約がある環境では、小規模な言語モデル(SLM)を代替手段として検討せざるを得ないケースもあります。
とはいえ、モデルの規模が小さくなるほど、標準のままでは専門用語の抽出精度が大きく低下することは想像に難くありません。そのため、あらかじめ専門用語の範囲をアノテーションし、モデルに学習させる必要があります。

特に専門分野における用語は構造が複雑であることが多く、アノテーション作業にも相応のコストがかかります。
こうした背景を踏まえると、今回紹介した「形態素解析＋固有表現抽出＋名詞句抽出」の結果をアノテーション作業の下支えとして活用することは、効率化の観点からも有効なアプローチと言えるでしょう。

抽出成功・失敗のパターンを丁寧に分析し、影響の大きい課題から段階的に対応していくことで、汎用的かつ安定的な専門用語抽出の実現に一歩近づけるはずです。今回の手法を土台に、用途に応じた精度調整・工夫を重ねていくことで、さまざまな自然言語処理タスクに応用の幅を広げてみてはいかがでしょうか。