🏯

大規模言語モデル入門のコードをベースに生成型LLMで固有表現認識を解く

2024/12/21に公開

はじめに

サイバーエージェント AI Lab NLPチームでリサーチサイエンティストの山田康輔です。4月に入社し、テキスト埋め込みや情報抽出などの自然言語処理に関連した研究に従事しています。

https://research.cyberagent.ai/research/natural-language-processing/

また、2023年7月に「大規模言語モデル入門」、2024年9月に「大規模言語モデル入門Ⅱ〜生成型LLMの実装と評価」を共著で出版しました。第6章「固有表現認識」、第7章「要約生成」、第10章「性能評価」を担当しています。
https://gihyo.jp/book/2023/978-4-297-13633-8
https://gihyo.jp/book/2024/978-4-297-14393-0

本記事では、第6章「固有表現認識」と第10章「性能評価」の実装を組み合わせることで、生成型LLMを用いて固有表現認識を解く実装についてまとめました。第6章ではBERTをベースとした系列ラベリングアプローチの実装を紹介しているのですが、最近注目を集めているSwallowやGPT-4oなどの生成型LLMをベースとした生成型アプローチでファインチューニングせずともある程度高い性能が出るのであれば、訓練データの準備の手間が省けるため、それに越したことはありません。このようなモチベーションから、SwallowとGPT-4oを用いて固有表現認識を実装してみました。どの程度性能が出るのか検証してみましょう。

なお、本記事のコードは以下のGitHubリポジトリにあります。Google Colaboratoryで容易に実行できるので、ぜひ動かしてみてください。
https://github.com/Kosuke-Yamada/named-entity-recognition-llm

また、実装は大規模言語モデル入門のコードをそのまま用いている箇所が多いです。以下の実装がそのまま利用できることを体感してもらえると嬉しいです。実装に関する丁寧な解説は書籍にまとめられているので、そちらを参照してください。
https://github.com/ghmagazine/llm-book

Swallowを用いた固有表現認識の実装

まず、「大規模言語モデル入門」の第6章と同様の形式で、Swallowを用いて固有表現認識モデルを実装します。

準備

実装で必要なパッケージをインストールします。

!pip install bitsandbytes datasets seqeval transformers[ja,torch]

訓練セットの事例を無作為に選択する場面があるため、事前に乱数シードを指定しておきます。

from transformers.trainer_utils import set_seed

# 乱数シードを42に固定する
set_seed(42)

データセット・前処理

データセットの読み込み

第6章「固有表現認識」で用いられているllm-book/ner-wikipedia-datasetを使用します。

from datasets import load_dataset

# データセットを読み込む
dataset = load_dataset("llm-book/ner-wikipedia-dataset", trust_remote_code=True)

データセットの中身を確認するために、検証セットのデータを表示します。一旦、このデータに対して固有表現認識を行うための処理を行なっていきます。

from pprint import pprint

# 検証セットの1つ目のデータを表示する
val_data = dataset["validation"][0]
pprint(val_data)
出力

{'curid': '1662110',
'entities': [{'name': '復活篇', 'span': [1, 4], 'type': '製品名'},
{'name': 'グリーンバニー', 'span': [6, 13], 'type': '法人名'}],
'text': '「復活篇」はグリーンバニーからの発売となっている。'}

データの前処理

LLMへの入力と出力を定め、そのための前処理をします。
生成型LLMを用いた実装では、ここの設計が肝になります。ここでは、入力はテキストそのまま、出力は「N. | 固有表現名 | 固有表現タイプ」としています。Nは登場する順番を示しています。複数出力がある場合は、改行で結合しています。新たに"input"と"output"という項目が増えています。

def convert_data_format(data: dict[str, str]) -> dict[str, str]:
    """データフォーマットを変換する"""
    data["input"] = data["text"]
    data["output"] = "\n".join(
        [
            f"{i+1}. | "
            + " | ".join([f"{v}" for k, v in entity.items() if k != "span"])
            for i, entity in enumerate(data["entities"])
        ]
    )
    return data

# データフォーマットを変換する
val_data = convert_data_format(val_data)
pprint(val_data)
出力

{'curid': '1662110',
'entities': [{'name': '復活篇', 'span': [1, 4], 'type': '製品名'},
{'name': 'グリーンバニー', 'span': [6, 13], 'type': '法人名'}],
'input': '「復活篇」はグリーンバニーからの発売となっている。',
'output': '1. | 復活篇 | 製品名\n2. | グリーンバニー | 法人名',
'text': '「復活篇」はグリーンバニーからの発売となっている。'}

プロンプトテンプレートの作成

プロンプトテンプレートを作成します。
プロンプトテンプレートは、第10章「性能評価」で採用されているものと同じものを使用しています。シャッフルした訓練セットから30件のfew-shot事例を取得してプロンプトテンプレートを作成しています。固有表現認識を行うテキストを代入するため、{input}という置換フィールドを用意しています。

def create_prompt_template(
    instruction: str, few_shots: list[dict[str, str]] | None = None
) -> str:
    """プロンプトテンプレートを作成する"""
    prompt_template = (
        "以下は、タスクを説明する指示と、"
        "文脈のある入力の組み合わせです。"
        "要求を適切に満たす応答を書きなさい。\n\n"
    )
    prompt_template += f"### 指示:\n{instruction}\n\n"
    if few_shots is not None:
        for few_shot in few_shots:
            prompt_template += f"### 入力:\n{few_shot['input']}\n\n"
            prompt_template += f"### 応答:\n{few_shot['output']}\n\n"
    prompt_template += "### 入力:\n{input}\n\n"
    prompt_template += "### 応答:\n"
    return prompt_template

# 指示文を指定してプロンプトテンプレートを作成する
entity_types = sorted(
    set([e["type"] for entity in dataset["train"]["entities"] for e in entity])
)
instruction = (
    "テキストを入力とし、テキストの中から出現順に固有表現を抽出してください。"
    "回答は固有表現名、固有表現タイプを含めてください。"
    "固有表現タイプは{entity_types}から選択してください。".format(
        entity_types="・".join(entity_types)
    )
)

# 訓練セットをシャッフルする
train_dataset = dataset["train"].shuffle()
# 訓練セットの前処理をする
train_dataset = train_dataset.map(convert_data_format)
# 30件のfew-shot事例を取得する
few_shots = list(train_dataset)[:30]
# プロンプトテンプレートを作成する
prompt_template = create_prompt_template(instruction, few_shots)
print(prompt_template)
出力 (長いので注意)

以下は、タスクを説明する指示と、文脈のある入力の組み合わせです。要求を適切に満たす応答を書きなさい。

指示:

テキストを入力とし、テキストの中から出現順に固有表現を抽出してください。回答は固有表現名、固有表現タイプを含めてください。固有表現タイプはその他の組織名・イベント名・人名・地名・政治的組織名・施設名・法人名・製品名から選択してください。

入力:

フランス軍は翌年にシャルルロワを落とし、続いて1746年9月のはじめにクレルモン伯爵率いるフランス軍がナミュールを包囲した。

応答:

  1. | フランス軍 | 政治的組織名
  2. | シャルルロワ | 地名
  3. | クレルモン伯爵 | 人名
  4. | フランス軍 | 政治的組織名
  5. | ナミュール | 地名

入力:

起終点の行政区分、栄町の名の由来は栄通りの沿線であることから来ている。

応答:

  1. | 栄町 | 地名
  2. | 栄通り | 施設名

入力:

イオンエンターテイメントの劇場を下記に示す。

応答:

  1. | イオンエンターテイメント | 法人名

入力:

上海共青森林公園は、中国上海市楊浦区にある森林公園。

応答:

  1. | 上海共青森林公園 | 施設名
  2. | 中国上海市楊浦区 | 地名

入力:

1954年のリーグ初優勝から西鉄ライオンズの黄金時代が始まり、豊田泰光、中西太、大下弘、稲尾和久らを擁したチームは1956~1958年に読売ジャイアンツを下し日本シリーズ3連覇を果たしたが、特に1958年は3連敗の後の雨天中止が引き金となり、奇跡とも言われた4連勝を果たした。

応答:

  1. | 西鉄ライオンズ | その他の組織名
  2. | 豊田泰光 | 人名
  3. | 中西太 | 人名
  4. | 大下弘 | 人名
  5. | 稲尾和久 | 人名
  6. | 読売ジャイアンツ | その他の組織名
  7. | 日本シリーズ | イベント名

入力:

Mandrakesoft、Lycoris、Conectivaの合併で設立された。

応答:

  1. | Mandrakesoft | 法人名
  2. | Lycoris | 法人名
  3. | Conectiva | 法人名

入力:

今宿道路は、福岡県の福岡市西区から糸島市に至る、全長約23kmの高規格幹線道路である。

応答:

  1. | 今宿道路 | 施設名
  2. | 福岡県 | 地名
  3. | 福岡市西区 | 地名
  4. | 糸島市 | 地名

入力:

ニューヨークのアパートに住まう室内装飾家のジャンは、彼女の部屋の上の階に住む作曲家ブラッドと共同電話が混線し、よく言い争っていた。

応答:

  1. | ニューヨーク | 地名
  2. | ジャン | 人名
  3. | ブラッド | 人名

入力:

2010年8月1日、カリフォルニア州サンディエゴのサンディエゴ・スポーツ・アリーナで開催された。

応答:

  1. | カリフォルニア州サンディエゴ | 地名
  2. | サンディエゴ・スポーツ・アリーナ | 施設名

入力:

そこで副会長でヌニェス会長の右腕でもあるジョアン・ガスパールがクラブに留まる気があるかを尋ね、「残留するのなら、会長との仲を取り持つ」と5年の契約延長を提示したが、マラドーナがそれを拒否、クラブは放出の断を下した。

応答:

  1. | ヌニェス | 人名
  2. | ジョアン・ガスパール | 人名
  3. | マラドーナ | 人名

入力:

玉川徹も続投するが、毎日出演することとなった。

応答:

  1. | 玉川徹 | 人名

入力:

また、そこから派生して、物事のシステムや政治の混乱を示す際にも使われる。

応答:

入力:

公園がある山は、東照宮を維持管理する社僧寺利光院があったこともあり権現山とも呼ばれていた。

応答:

  1. | 東照宮 | 施設名
  2. | 社僧寺利光院 | 施設名
  3. | 権現山 | 地名

入力:

1831年、イギリス人マイケル・ファラデーは電気と磁力の関係を示し、磁石を回転させて電流を発生させる発電機を作り上げた。

応答:

  1. | イギリス | 地名
  2. | マイケル・ファラデー | 人名

入力:

しかし、2001年9月11日の「米国多発同時テロ」以降、冷え込んだ世界経済の煽りを受け、一時的に生産を縮小。

応答:

  1. | 米国多発同時テロ | イベント名

入力:

例えばスタンリイ・G・ワインボウムの「火星のオデッセイ」はこの時期の作品である。

応答:

  1. | スタンリイ・G・ワインボウム | 人名
  2. | 火星のオデッセイ | 製品名

入力:

1996年に発表され、Windows用電子メールクライアントとしては古株に属する。

応答:

  1. | Windows | 製品名

入力:

広島県民文化センターふくやまは、広島県福山市東桜町にある多目的コンサートホール。

応答:

  1. | 広島県民文化センターふくやま | 施設名
  2. | 広島県福山市東桜町 | 地名

入力:

一方で、クレジット機能のないカードとして、2012年12月1日にJR西日本との連携による、IC乗車カードのICOCAの機能を加えた「KIPS ICOCAカード」の発行を開始し、さらに2013年1月25日には、現金専用のポイントカード「KIPSポイントカード」の発行を開始した。

応答:

  1. | JR西日本 | 法人名
  2. | ICOCA | 製品名
  3. | KIPS ICOCAカード | 製品名
  4. | KIPSポイントカード | 製品名

入力:

主に自動車用ブレーキを中心に生産し、トヨタ、日産や米GMを中心に各完成車メーカーへの供給を行っている。

応答:

  1. | トヨタ | 法人名
  2. | 日産 | 法人名
  3. | 米 | 地名
  4. | GM | 法人名

入力:

石北本線と並行していたが、乗降場は道路側である湧網線にのみ置かれた。

応答:

  1. | 石北本線 | 施設名
  2. | 湧網線 | 施設名

入力:

2009年、三菱原子燃料に出資、株式の30%を保有している。

応答:

  1. | 三菱原子燃料 | 法人名

入力:

1920年代に、ブエナパークの州道39号線沿いでウォルター・ナッツがボイセンベリーを販売するスタンドを設けたのがナッツベリーファームの起源である。

応答:

  1. | ブエナパーク | 地名
  2. | 州道39号線 | 施設名
  3. | ウォルター・ナッツ | 人名
  4. | ナッツベリーファーム | 施設名

入力:

2007年1月30日に発売されたWindows Vistaにも、「検索フォルダ」というバーチャルフォルダ的な機能が用意されているが、これはあくまでも従来の拡張子や日時単位でのファイル検索の一覧のみを表示する機能であり、バーチャルフォルダがWinFSによって実現するとしていた高度な機能は搭載されていない。

応答:

  1. | Windows Vista | 製品名

入力:

1918年に交渉の後、ネヴィンソンは設置が提案された慰霊館のための大作となる絵画の依頼を戦争記録委員会から受けた。

応答:

  1. | ネヴィンソン | 人名
  2. | 戦争記録委員会 | 政治的組織名

入力:

クラフトバンドエコロジー協会のクラフトバンド実技講座は、2017年に文部科学省認定通信教育の認可を受け、現在4000名の入会者と講師が在籍している。

応答:

  1. | クラフトバンドエコロジー協会 | 法人名
  2. | クラフトバンド実技講座 | 製品名
  3. | 文部科学省 | 政治的組織名

入力:

大阪文化賞とは、直近の一年間に、学術・生活文化・芸術の分野において際立った活躍をした個人・団体、または、文化芸術の活動者の支援や大阪文化の情報発信など文化芸術の振興に著しい功績のあった個人・団体に対してその業績を称える賞である。

応答:

  1. | 大阪文化賞 | 製品名

入力:

復帰後の第9回Vリーグでは4勝止まりの7位となるが、第10回大会では、川浦、諸隈、盛重、外国人選手マックス・ペレイラらの活躍で、10勝11敗で1勝差でファイナルラウンド進出を逃す6位に入った。

応答:

  1. | 第9回Vリーグ | イベント名
  2. | 川浦 | 人名
  3. | 諸隈 | 人名
  4. | 盛重 | 人名
  5. | マックス・ペレイラ | 人名

入力:

その中から阪神タイガースと北海道日本ハムファイターズの主催ゲームを中心に実況中継を行う。

応答:

  1. | 阪神タイガース | その他の組織名
  2. | 北海道日本ハムファイターズ | その他の組織名

入力:

ある日、シーランは地元マフィアに積荷の横流しを行う。

応答:

  1. | シーラン | 人名

入力:

{input}

応答:

プロンプトテンプレートへ入力テキストの挿入

入力テキストをプロンプトテンプレートに挿入し、LLMに入力するプロンプトを作成します。
strformatメソッドを使用しています。

def insert_text_to_prompt_template(
    data: dict[str, str], prompt_template: str
) -> dict[str, str]:
    """入力テキストをプロンプトテンプレートに挿入する"""
    data["prompt"] = prompt_template.format(input=data["input"])
    return data

# 検証セットのデータで入力テキストをプロンプトテンプレートに挿入する
val_data = insert_text_to_prompt_template(val_data, prompt_template)
pprint(val_data)
出力 (長いので注意)

{'curid': '1662110',
'entities': [{'name': '復活篇', 'span': [1, 4], 'type': '製品名'},
{'name': 'グリーンバニー', 'span': [6, 13], 'type': '法人名'}],
'input': '「復活篇」はグリーンバニーからの発売となっている。',
'output': '1. | 復活篇 | 製品名\n2. | グリーンバニー | 法人名',
'prompt': '以下は、タスクを説明する指示と、文脈のある入力の組み合わせです。要求を適切に満たす応答を書きなさい。\n'
'\n'
'### 指示:\n'
'テキストを入力とし、テキストの中から出現順に固有表現を抽出してください。回答は固有表現名、固有表現タイプを含めてください。固有表現タイプはその他の組織名・イベント名・人名・地名・政治的組織名・施設名・法人名・製品名から選択してください。\n'
'\n'
'### 入力:\n'
'平等主義的な「ケーキ分配」のアプローチは、薄熙来の「重慶モデル」の重要な部分である。\n'
'\n'
'### 応答:\n'
'1. | 薄熙来 | 人名\n'
'\n'
'### 入力:\n'
'5月30日、LPSAは「公益社団法人日本女子プロ将棋協会棋士規程」を改定し、LPSAの女流棋士認定基準を、連盟の女流棋士認定基準と事実上同一とした。\n'
'\n'
'### 応答:\n'
'1. | LPSA | 法人名\n'
'2. | 日本女子プロ将棋協会 | 法人名\n'
'3. | LPSA | 法人名\n'
'\n'
'### 入力:\n'
'当時、アフリカ連合の部隊はスーダン解放軍からの攻撃に晒されており、こういった装甲車両を必要としていたのである。\n'
'\n'
'### 応答:\n'
'1. | アフリカ連合 | 政治的組織名\n'
'2. | スーダン解放軍 | 政治的組織名\n'
'\n'
'### 入力:\n'
'商業面積は約33,000m、年間売上高は約500億円にも上り、地方百貨店としてはトップクラスの収益力を誇っている。\n'
'\n'
'### 応答:\n'
'\n'
'\n'
'### 入力:\n'
'中期以降のものは山田章博のコミックならびに水野良の小説「ファリスの聖女」のパロディ。\n'
'\n'
'### 応答:\n'
'1. | 山田章博 | 人名\n'
'2. | 水野良 | 人名\n'
'3. | ファリスの聖女 | 製品名\n'
'\n'
'### 入力:\n'
'アジア、アメリカ、ヨーロッパなどの26か国・地域で発売されており、国際オリンピック委員会、FIFAワールドカップの公式スポーツ飲料である。\n'
'\n'
'### 応答:\n'
'1. | アジア | 地名\n'
'2. | アメリカ | 地名\n'
'3. | ヨーロッパ | 地名\n'
'4. | 国際オリンピック委員会 | 政治的組織名\n'
'5. | FIFAワールドカップ | イベント名\n'
'\n'
'### 入力:\n'
"その後、パトリック・ヤンセンがザ・ホーンテッドの活動で多忙になったことなどからコンスタントに活動できなかったが、センチュリー・メディア・レコードと契約し2006年に4thアルバム「Don't "
'Fear the Reaper」をリリース。\n'
'\n'
'### 応答:\n'
'1. | パトリック・ヤンセン | 人名\n'
'2. | ザ・ホーンテッド | その他の組織名\n'
'3. | センチュリー・メディア・レコード | 法人名\n'
"4. | Don't Fear the Reaper | 製品名\n"
'\n'
'### 入力:\n'
'千葉中央バスは鎌取駅南口発着便、小湊鉄道と九十九里鉄道は鎌取駅北口便を担当する。\n'
'\n'
'### 応答:\n'
'1. | 千葉中央バス | 法人名\n'
'2. | 鎌取駅南口 | 施設名\n'
'3. | 小湊鉄道 | 法人名\n'
'4. | 九十九里鉄道 | 法人名\n'
'5. | 鎌取駅北口 | 施設名\n'
'\n'
'### 入力:\n'
'ただし、SDN48にチーム制はなく、STU48、AKB48 Team '
'SH、SGO48、DEL48ではチームが結成されたことはない。\n'
'\n'
'### 応答:\n'
'1. | SDN48 | その他の組織名\n'
'2. | STU48 | その他の組織名\n'
'3. | AKB48 Team SH | その他の組織名\n'
'4. | SGO48 | その他の組織名\n'
'5. | DEL48 | その他の組織名\n'
'\n'
'### 入力:\n'
'彼が5歳の時に父親が亡くなり、1578年には母親も亡くしている。\n'
'\n'
'### 応答:\n'
'\n'
'\n'
'### 入力:\n'
'航空会社はATR 42-600を導入している日本エアコミューターなどが有力視されているが、取材には明確な答えを出していない。\n'
'\n'
'### 応答:\n'
'1. | ATR 42-600 | 製品名\n'
'2. | 日本エアコミューター | 法人名\n'
'\n'
'### 入力:\n'
'笹山敬輔は、演劇研究者。\n'
'\n'
'### 応答:\n'
'1. | 笹山敬輔 | 人名\n'
'\n'
'### 入力:\n'
'2001年に報道部副部長を最後に宮崎放送を退社し、民主党・社会民主党推薦で参議院議員選挙に出馬するも落選した。\n'
'\n'
'### 応答:\n'
'1. | 報道部 | 政治的組織名\n'
'2. | 宮崎放送 | 法人名\n'
'3. | 民主党 | 政治的組織名\n'
'4. | 社会民主党 | 政治的組織名\n'
'5. | 参議院議員選挙 | イベント名\n'
'\n'
'### 入力:\n'
'溺れるようにできている。\n'
'\n'
'### 応答:\n'
'\n'
'\n'
'### 入力:\n'
'休部に伴い2002年、日立ハイテクスクァレルズに移籍している。\n'
'\n'
'### 応答:\n'
'1. | 日立ハイテクスクァレルズ | その他の組織名\n'
'\n'
'### 入力:\n'
'2018年3月28日に金明恵が監督に就任。\n'
'\n'
'### 応答:\n'
'1. | 金明恵 | 人名\n'
'\n'
'### 入力:\n'
'帝国陸軍における歩兵連隊の軍隊符号はi。\n'
'\n'
'### 応答:\n'
'1. | 帝国陸軍 | 政治的組織名\n'
'2. | 歩兵連隊 | 政治的組織名\n'
'\n'
'### 入力:\n'
'これらの背景には、プロ志向の強い読売クラブに対して日本サッカー協会や実業団チームからの妬みや反発が強く、読売クラブを代表してラモスが被害を受けたという面もある。\n'
'\n'
'### 応答:\n'
'1. | 読売クラブ | その他の組織名\n'
'2. | 日本サッカー協会 | 法人名\n'
'3. | 読売クラブ | その他の組織名\n'
'4. | ラモス | 人名\n'
'\n'
'### 入力:\n'
'2011年11月、アブラハム・プライベートバンクが海外積立投資「いつかはゆかし」を開始。\n'
'\n'
'### 応答:\n'
'1. | アブラハム・プライベートバンク | 法人名\n'
'2. | いつかはゆかし | 製品名\n'
'\n'
'### 入力:\n'
'産経新聞政治部専門委員の野口裕之は、朝鮮戦争における戦歴から、金を韓国にとっての"救国の士"と評している。\n'
'\n'
'### 応答:\n'
'1. | 産経新聞 | 法人名\n'
'2. | 政治部 | その他の組織名\n'
'3. | 野口裕之 | 人名\n'
'4. | 朝鮮戦争 | イベント名\n'
'5. | 金 | 人名\n'
'6. | 韓国 | 地名\n'
'\n'
'### 入力:\n'
'1966年、カナダの投資家であるアービン・グッドによる資金援助を受け、同社を電卓部品メーカーに転換し、さらに70年代後半からはパーソナルコンピュータの大手メーカーとして急成長をさせた。\n'
'\n'
'### 応答:\n'
'1. | カナダ | 地名\n'
'2. | アービン・グッド | 人名\n'
'\n'
'### 入力:\n'
'関西テレビとイーストの共同製作。\n'
'\n'
'### 応答:\n'
'1. | 関西テレビ | 法人名\n'
'2. | イースト | 法人名\n'
'\n'
'### 入力:\n'
'ドイツの電子音楽グループ「クラフトワーク」で1975年から1990年までパーカッション、キーボードなどを担当していた。\n'
'\n'
'### 応答:\n'
'1. | ドイツ | 地名\n'
'2. | クラフトワーク | その他の組織名\n'
'\n'
'### 入力:\n'
'努力をすべての基とし、偏見を排し幅広い知識を身につけ、国際的視野に立って判断のできる人材を養成することを目的とする。\n'
'\n'
'### 応答:\n'
'\n'
'\n'
'### 入力:\n'
'2016年12月26日、カンボジアのシェムリアップ・アンコールFCの経営に携わることを発表した。\n'
'\n'
'### 応答:\n'
'1. | カンボジア | 地名\n'
'2. | シェムリアップ・アンコールFC | その他の組織名\n'
'\n'
'### 入力:\n'
'母体となった会社は、ゲルハルディ・クンストシュトフテクニークGmbHとゲルハルディ・アルテクニーク、およびリダル・ゲルハルディに分割された。\n'
'\n'
'### 応答:\n'
'1. | ゲルハルディ・クンストシュトフテクニークGmbH | 法人名\n'
'2. | ゲルハルディ・アルテクニーク | 法人名\n'
'3. | リダル・ゲルハルディ | 法人名\n'
'\n'
'### 入力:\n'
'OPPOの特徴は、OEMを一切使わず広東省東莞市に自社工場を丸抱えすることによる品質管理・生産管理のおかげで、自撮り用カメラや急速充電など中国の若者の要求に迅速に対応して来たことと、小米やアップルのようにネット店舗やネット宣伝を重視するのではなく、リアル店舗やリアル宣伝を重視した中国の地方都市まで広がる強固な販売網があることで、中国では地方民を中心として人気があり、ほぼ同じ戦略を取るVivoと並んで「OV」と称される。\n'
'\n'
'### 応答:\n'
'1. | OPPO | 法人名\n'
'2. | 広東省東莞市 | 地名\n'
'3. | 中国 | 地名\n'
'4. | 小米 | 法人名\n'
'5. | アップル | 法人名\n'
'6. | 中国 | 地名\n'
'7. | Vivo | 法人名\n'
'\n'
'### 入力:\n'
'3月14日の大手集中回答日では、三菱を除く全てで賃上げは達成する。\n'
'\n'
'### 応答:\n'
'1. | 三菱 | 法人名\n'
'\n'
'### 入力:\n'
'ボストン交響楽団は歴史的に、ジェームズ・レヴァインを除いて外国の著名な指揮者を首席指揮者や客演指揮者に迎えており、ハンス・フォン・ビューローとの共演でチャイコフスキーの「ピアノ協奏曲第1番」の世界初演を行なったことは有名である。\n'
'\n'
'### 応答:\n'
'1. | ボストン交響楽団 | その他の組織名\n'
'2. | ジェームズ・レヴァイン | 人名\n'
'3. | ハンス・フォン・ビューロー | 人名\n'
'4. | チャイコフスキー | 人名\n'
'5. | ピアノ協奏曲第1番 | 製品名\n'
'\n'
'### 入力:\n'
'そして、1803年8月8日にイギリス東インド会社との間に第二次マラーター戦争が勃発した。\n'
'\n'
'### 応答:\n'
'1. | イギリス東インド会社 | 法人名\n'
'2. | 第二次マラーター戦争 | イベント名\n'
'\n'
'### 入力:\n'
'「復活篇」はグリーンバニーからの発売となっている。\n'
'\n'
'### 応答:\n',
'text': '「復活篇」はグリーンバニーからの発売となっている。'}

固有表現の予測・抽出

テキスト生成パイプラインの作成

テキスト生成用のパイプラインを作成します。
第10章「性能評価」と同様に、モデルを量子化して読み込み、パイプラインを作成します。モデルはtokyotech-llm/Swallow-7b-instruct-hfを使用します。

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    pipeline
)

model_name = "tokyotech-llm/Swallow-7b-instruct-hf"
# AutoTokenizerでトークナイザを読み込む
tokenizer = AutoTokenizer.from_pretrained(model_name)
# モデルを量子化して読み込むためのパラメータを指定する
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)
# 生成を行うモデルであるAutoModelForCausalLMを使ってモデルを読み込む
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    quantization_config=quantization_config,
    use_cache=False,
    device_map="auto",
)
# テキスト生成用のパラメータを指定する
generation_config = {
    "max_new_tokens": 512, # 生成する最大トークン数
    "top_p": 1.0, # top-pサンプリング
    "repetition_penalty": 1.0, # 繰り返しペナルティ
}
# pipelineを作成する
text_generation_pipeline = pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
    device_map="auto",
    **generation_config
)

固有表現認識の実行

パイプラインを使用して生成ベースで固有表現認識を行います。
出力を確認してみると、入力テキストに対する出力結果が得られていますが、新たな入力テキストを生成し、その出力を生成するというのを繰り返してしまっています。

# 固有表現認識を行う
output = text_generation_pipeline(val_data["prompt"])
# プロンプト部分を削除して予測部分のみにする
generated_text = output[0]["generated_text"].replace(val_data["prompt"], "")
print(generated_text)
出力
  1. | グリーンバニー | 法人名
  2. | 復活篇 | 製品名

入力:

この作品は、1991年の第64回アカデミー賞の短編アニメ賞にノミネートされた。

応答:

  1. | 第64回アカデミー賞 | イベント名
  2. | 短編アニメ賞 | 製品名

入力:

1991年の第64回アカデミー賞では、「風と共に去りぬ」のリメイク版がノミネートされた。

応答:

  1. | 第64回アカデミー賞 | イベント名
  2. | 風と共に去りぬ | 製品名

入力:

1939年の第12回アカデミー賞では、「オズの魔法使い」が作品賞を受賞した。

応答:

  1. | 第12回アカデミー賞 | イベント名
  2. | オズの魔法使い | 製品名

入力:

1939年の第12回アカデミー賞では、「風と共に去りぬ」が作品賞を受賞した。

応答:

  1. | 第12回アカデミー賞 | イベント名
  2. | 風と共に去りぬ | 製品名

入力:

最初の2年間、3000万ドルの予算で撮影されたこの映画は、2億5000万ドル以上の収益を上げた。

応答:

  1. | 最初の2年間 | 期間
  2. | 3000万ドル | 金額
  3. | 2億5000万ドル | 金額

入力:

1991年には、「ティム・バートンのコープスブライド」がアカデミー賞の長編アニメ賞にノミネートされた。

応答:

  1. | ティム・バートンのコープスブライド | 製品名
  2. | アカデミー賞 | イベント名
  3. | 長編アニメ賞 | 製品

繰り返しの出力の防ぐために、指定したフレーズで生成を停止するStopOnPhraseクラスを定義します。そうすることで、推論時間を短くすることができます。

import torch
from transformers import StoppingCriteria

class StopOnPhrase(StoppingCriteria):
    """指定したフレーズで生成を停止するクラス"""

    def __init__(self, stop_phrase: str, prompt: str, tokenizer: AutoTokenizer) -> None:
        self.stop_phrase = stop_phrase
        self.tokenizer = tokenizer
        self.n_prompt_tokens = len(tokenizer(prompt)["input_ids"])

    def __call__(self, input_ids: torch.Tensor, _) -> bool:
        # トークンをデコードして現在の出力テキストを取得する
        decoded_text = self.tokenizer.decode(
            input_ids[0][self.n_prompt_tokens :], skip_special_tokens=True
        )
        # 特定のフレーズが出現した場合にTrueを返して生成を停止する
        return self.stop_phrase in decoded_text

# 固有表現認識を行う
stopping_criteria = StopOnPhrase("\n\n", val_data["prompt"], tokenizer)
output = text_generation_pipeline(
    val_data["prompt"], stopping_criteria=[stopping_criteria]
)
# プロンプト部分を削除して予測部分のみにする
generated_text = output[0]["generated_text"].replace(val_data["prompt"], "")
print(generated_text)
出力
  1. | 復活篇 | 製品名
  2. | グリーンバニー | 法人名

出力結果の解析

出力を構造化したデータに整形します。
得られた出力は文字列であり、扱いにくいため、これを元の形式と同じ形式のデータに整形します。parse_outputs関数で整形を行います。

def parse_outputs(
    generated_text: str, input_text: str, entity_types: list[str]
) -> list[dict[str, str]]:
    """予測結果を解析する"""
    output_entities = []
    start = 0
    for t in generated_text.split("\n"):  # 出力結果を行ごとに処理する
        # 何も出力していない行が現れたら解析を終了する
        if t == "":
            break
        # 3つ組でなければ、予測結果に入れない
        if len(t.split(" | ")) != 3:
            continue
        _, entity_name, entity_type = t.split(" | ")
        # 固有表現ラベルに含まれないものの場合、予測結果に入れない
        if entity_type not in entity_types:
            continue

        # 固有表現の位置を探索する
        index = input_text.find(entity_name, start)
        if index != -1:
            entity_span = [index, index + len(entity_name)]
            # 次の探索の開始位置を更新する
            start = index + 1
        else:
            entity_span = None
        # 固有表現がテキストの中で見つからない場合、予測結果に入れない
        if entity_span is None:
            continue

        output_entities.append(
            {"name": entity_name, "span": entity_span, "type": entity_type}
        )
    return output_entities

# 出力結果を解析する
output_entities = parse_outputs(generated_text, val_data["input"], entity_types)
pprint(output_entities)
出力

[{'name': '復活篇', 'span': [1, 4], 'type': '製品名'},
{'name': 'グリーンバニー', 'span': [6, 13], 'type': '法人名'}]

検証セットの全データに対して固有表現認識を実行

これまでは、検証セットの1つのデータに対して固有表現認識を行ってきましたが、同じ処理を全データに対して行い、モデルの性能評価を行います。

前処理

検証セットの全データに対して前処理を行います。
datasetsDataset型へのデータの前処理はmapメソッドを用いるのが便利です。前処理を行う関数で必要なデータ以外の引数に関しては、functoolsライブラリのpartial関数を用いることで代入できます。

from functools import partial

# 検証セットの入力テキストをプロンプトへの変更する
val_dataset = dataset["validation"].map(convert_data_format)
val_dataset = val_dataset.map(
    partial(insert_text_to_prompt_template, prompt_template=prompt_template)
)

固有表現認識の実行

これまでの処理を全データに対して行います。

from datasets import Dataset
from tqdm import tqdm

def run_entity_extraction(
    dataset: Dataset,
    text_generation_pipeline: pipeline,
    entity_types: list[str],
):
    """データセットに対して固有表現認識を行う"""
    results = []
    for data in tqdm(dataset):  # 各事例を処理する
        # 固有表現認識を行う
        stopping_criteria = StopOnPhrase("\n\n", data["prompt"], tokenizer)
        output = text_generation_pipeline(
            data["prompt"], stopping_criteria=[stopping_criteria]
        )
        # プロンプト部分を削除して予測部分のみにする
        generated_text = output[0]["generated_text"].replace(data["prompt"], "")
        # 出力を整形する
        data["pred_entities"] = parse_outputs(
            generated_text, data["input"], entity_types
        )
        results.append(data)
    return results

# データセットに対して固有表現認識を実行する
results = run_entity_extraction(
    val_dataset, text_generation_pipeline, entity_types
)

性能評価

モデルの性能評価を行います。
第6章「固有表現認識」で用いられているものと同様の関数を使用します。
同章で提案されたBERT-CRFのf1-scoreのマイクロ平均の値は0.90なのに対して、Swallowのf1-scoreのマイクロ平均の値は0.53となっています。BERT-CRFは4,274件の訓練データを用いているのに対して、Swallowはわずか30件の訓練データしか用いていないことが大きな要因だと考えられます。

from typing import Any
from seqeval.metrics import classification_report

def create_character_labels(
    text: str, entities: list[dict[str, list[int] | str]]
) -> list[str]:
    """文字ベースでラベルのlistを作成"""
    # "O"のラベルで初期化したラベルのlistを作成する
    labels = ["O"] * len(text)
    for entity in entities: # 各固有表現を処理する
        entity_span, entity_type = entity["span"], entity["type"]
        # 固有表現の開始文字の位置に"B-"のラベルを設定する
        labels[entity_span[0]] = f"B-{entity_type}"
        # 固有表現の開始文字以外の位置に"I-"のラベルを設定する
        for i in range(entity_span[0] + 1, entity_span[1]):
            labels[i] = f"I-{entity_type}"
    return labels

def convert_results_to_labels(
    results: list[dict[str, Any]]
) -> tuple[list[list[str]], list[list[str]]]:
    """正解データと予測データのラベルのlistを作成"""
    true_labels, pred_labels = [], []
    for result in results: # 各事例を処理する
        # 文字ベースでラベルのリストを作成してlistに加える
        true_labels.append(
            create_character_labels(result["text"], result["entities"])
        )
        pred_labels.append(
            create_character_labels(result["text"], result["pred_entities"])
        )
    return true_labels, pred_labels

true_labels, pred_labels = convert_results_to_labels(results)
print(classification_report(true_labels, pred_labels))
出力
          precision    recall  f1-score   support
          
 その他の組織名       0.21      0.36      0.26        99
   イベント名       0.43      0.51      0.46        85
      人名       0.81      0.64      0.71       299
      地名       0.69      0.33      0.45       184
  政治的組織名       0.75      0.36      0.48       121
     施設名       0.76      0.61      0.68       103
     法人名       0.79      0.55      0.65       231
     製品名       0.32      0.11      0.16       123
     
micro avg       0.61      0.46      0.53      1245
macro avg       0.59      0.43      0.48      1245
weighted avg       0.66      0.46      0.53      1245

エラー分析

エラー分析をします。
ここでは、5つの誤っている事例を表示します。結果を確認すると、抽出できていないものや固有表現タイプの誤っているものなど様々ありますが、事例2の「体の動きと頭の動き」や事例4の「昼間課程」のような一般的では固有表現でないものを抽出してしまっているのは結構致命的なミスです。やはり少数の事例で固有表現認識というのはなかなか難しいタスクかもしれません。

def find_error_results(
    results: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """エラー事例を発見"""
    error_results = []
    for idx, result in enumerate(results): # 各事例を処理する
        result["idx"] = idx
        # 正解データと予測データが異なるならばlistに加える
        if result["entities"] != result["pred_entities"]:
            error_results.append(result)
    return error_results

def output_text_with_label(result: dict[str, Any], entity_column: str) -> str:
    """固有表現ラベル付きテキストを出力"""
    text_with_label = ""
    entity_count = 0
    for i, char in enumerate(result["text"]): # 各文字を処理する
        # 出力に加えていない固有表現の有無を判定する
        if entity_count < len(result[entity_column]):
            entity = result[entity_column][entity_count]
            # 固有表現の先頭の処理を行う
            if i == entity["span"][0]:
                entity_type = entity["type"]
                text_with_label += f" [({entity_type}) "
            text_with_label += char
            # 固有表現の末尾の処理を行う
            if i == entity["span"][1] - 1:
                text_with_label += "] "
                entity_count += 1
        else:
            text_with_label += char
    return text_with_label

# エラー事例を発見する
error_results = find_error_results(results)
# 3件のエラー事例を出力する
for result in error_results[:5]:
    idx = result["idx"]
    true_text = output_text_with_label(result, "entities")
    pred_text = output_text_with_label(result, "pred_entities")
    print(f"事例{idx}の正解: {true_text}")
    print(f"事例{idx}の予測: {pred_text}")
    print()
出力

事例0の正解: 「 [(製品名) 復活篇] 」は [(法人名) グリーンバニー] からの発売となっている。
事例0の予測: 「復活篇」は [(法人名) グリーンバニー] からの発売となっている。

事例1の正解: これらにより実質的な証拠調べが遅れたと [(法人名) 日刊ゲンダイ] は報じている。
事例1の予測: これらにより実質的な証拠調べが遅れたと [(その他の組織名) 日刊ゲンダイ] は報じている。

事例2の正解: プログラマの [(人名) アンドリュー・スミス] によれば、体の動きと頭の動きを独立させてしまうと、「カンニングできて」しまうパズルがあるという。
事例2の予測: プログラマの [(人名) アンドリュー・スミス] によれば、 [(その他の組織名) 体の動きと頭の動き] を独立させてしまうと、「カンニングできて」しまうパズルがあるという。

事例3の正解: [(人名) ポリュビオス] に従えば [(人名) ピクトル] は、 [(イベント名) 第二次ポエニ戦争] についてその責任を [(人名) ハミルカル・バルカ] 、 [(人名) ハンニバル] ら [(人名) バルカ] 家に帰している。
事例3の予測: ポリュビオスに従えば [(人名) ピクトル] は、 [(イベント名) 第二次ポエニ戦争] についてその責任をハミルカル・バルカ、ハンニバルらバルカ家に帰している。

事例4の正解: 昼間課程・夜間課程共通の教育目標として「真理と正義を尊び、自主的精神に満ちた、心豊かな人間の育成」を掲げている。
事例4の予測: [(その他の組織名) 昼間課程] ・ [(その他の組織名) 夜間課程] 共通の [(その他の組織名) 教育目標] として「真理と正義を尊び、自主的精神に満ちた、心豊かな人間の育成」を掲げている。

OpenAI APIを用いた固有表現認識の実装

OpenAI APIを用いて、これまでと同様に生成型アプローチで固有表現認識を行います。

準備

Swallowの実装に追加で必要ライブラリをインストールします。
openai=="1.56.1"とする必要があるかもしれません。

!pip install openai

OpenAI APIキーを設定

まず、OpenAIのAPIキーを環境変数として設定します。
Open AIのWebサイトからキーを取得します。以下のページで取得できます。

取得したキーを下記のように環境変数として設定します。

%env OPENAI_API_KEY=sk-...

OpenAI APIを用いた固有表現認識

OpenAI APIを用いて固有表現認識を行います。
プロンプトとparse_outputs関数は、Swallowで用いたものと同様のものを使用します。

from openai import OpenAI

client = OpenAI()

def run_entity_extraction_openai(data, client, entity_types):
    """OpenAI APIを用いて固有表現認識を行う"""
    messages = [
        {
            "role": "system",
            "content": "あなたは役に立つアシスタントです。",
        },
        {
            "role": "user",
            "content": data["prompt"],
        },
    ]
    params = {
        "messages": messages,
        "max_tokens": 2048,
        "model": "gpt-4-turbo-2024-04-09"
    }

    # 固有表現認識を行う
    response = client.chat.completions.create(**params)
    generated_text = response.choices[0].message.content
     # 出力を整形する
    output_entities = parse_outputs(generated_text, data["input"], entity_types)
    return output_entities

pred_entities = run_entity_extraction_openai(val_data, client, entity_types)
print(pred_entities)
出力

[{'name': '復活篇', 'span': [1, 4], 'type': '製品名'}, {'name': 'グリーンバニー', 'span': [6, 13], 'type': '法人名'}]

検証セットの全データに対して固有表現認識を実行

検証セット全体に対して固有表現認識を実行し、性能評価を行います。
このとき、複数のスレッドでAPIを同時に実行することで高速に実行できます。性能評価の結果を確認すると、GPT-4oのマイクロ平均の値は0.76であり、Swallowのf1-scoreのマイクロ平均の値の0.53よりは高いものの、BERT-CRFのマイクロ平均の値の0.90には及ばない結果となっています。

from concurrent.futures import ThreadPoolExecutor

# 固有表現認識を複数のスレッドで同時に実行する
with ThreadPoolExecutor() as executor:
    outputs = executor.map(
        partial(run_entity_extraction_openai, client=client, entity_types=entity_types),
        val_dataset,
    )
val_dataset = val_dataset.add_column("pred_entities", outputs)
true_labels, pred_labels = convert_results_to_labels(val_dataset)
print(classification_report(true_labels, pred_labels))
出力
          precision    recall  f1-score   support

 その他の組織名       0.50      0.64      0.56        99
   イベント名       0.73      0.72      0.73        85
      人名       0.93      0.86      0.89       299
      地名       0.76      0.73      0.74       184
  政治的組織名       0.89      0.47      0.62       121
     施設名       0.77      0.76      0.76       103
     法人名       0.85      0.74      0.79       231
     製品名       0.78      0.72      0.75       123
     
micro avg       0.80      0.73      0.76      1245
macro avg       0.78      0.70      0.73      1245
weighted avg       0.81      0.73      0.76      1245

まとめ

本記事では、SwallowとGPT-4oを用いて固有表現認識を解く実装についてまとめました。
結論としては、ファインチューニングしたBERTやBERT-CRFには及ばない結果となっており、ファインチューニングを行わずに固有表現認識を行うのは、手法の改善の余地はあるとはいえど、難しそうな印象です。もちろんSwallowやGPT-4oをファインチューニングすれば性能を超える見込みはあると思うので、検証してみるのも良さそうです。

Discussion