その他

【自然言語処理】JaQuADデータセットを使って質問応答モデルを作ってみた

その他
この記事は約10分で読めます。

前回のおさらい

こんにちは。

最近はAIエージェントの出力結果をより適切に制御するため、システムプロンプトの設計に工夫を加える取り組みを行っています。

前回の投稿では、自作したAIエージェントを社内に浸透させるための一歩として、社内の役職や業務内容をシステムプロンプトに組み込む試みを紹介しました。

まだご覧になっていない方は、ぜひ以下のリンクから前回の記事もご一読ください!

今回の目的

これまでに公開してきた記事では、主に画像処理や機械学習、AIエージェント開発に焦点を当てて紹介してきました。

一方で、現在は自然言語処理に特に力を入れており、マルチモーダルモデルではなくとも、純粋に自然言語のみを対象としたモデルを自分の環境で構築したいと考えてきました。

そこで今回は、自然言語を対象とした機械学習モデルを構築し、Google Colab 上で実際に動作させることを目的とします。

JaQuADデータセット

機械学習においては、適切なデータセットの選択がモデル性能を大きく左右することは広く知られています。

今回の実装では、日本語を対象とした機械学習モデルを構築するため、JaQuAD(Japanese Question Answering Dataset) を利用します。

従来、自然言語処理の大規模データセットは英語を中心に整備されてきたため、学習済みモデルは日本語に関して十分な理解や応答能力を示せないことが課題でした。

こうした課題に対して、英語圏のQAデータセットに匹敵する性能を日本語でも実現することを目的に、JaQuAD が公開されているようです。

実際に配布データを確認すると、例えば「手塚治虫」に関する文章と、それに基づいた質問および回答が、1つの学習サンプルとして JSON 形式でまとめられていることがわかります。

今回は、このデータセットの形式に対応するため、質問応答タスクを対象としたモデルの構築を目指します。

{
	"version": "JaQuAD-version-0.1.0",
	"data": [
		{
			"title": "手塚治虫",
			"paragraphs": [
				{
					"context": "手塚治虫(てづかおさむ、本名:手塚治(読み同じ)、1928年(昭和3年)11月3日-1989年(平成元年)2月9日)は、日本の漫画家、アニメーター、アニメ監督である。\n戦後日本においてストーリー漫画の第一人者として、漫画表現の開拓者的な存在として活躍した。\n\n兵庫県宝塚市出身(出生は大阪府豊能郡豊中町、現在の豊中市)同市名誉市民である。\n大阪帝国大学附属医学専門部を卒業。\n医師免許取得のち医学博士(奈良県立医科大学・1961年)。",
					"qas": [
						{
							"question": "戦後日本のストーリー漫画の第一人者で、医学博士の一面もある漫画家は誰?",
							"id": "tr-000-00-000",
							"answers": [
								{
									"text": "手塚治虫",
									"answer_start": 0,
									"answer_type": "Person"
								}
							],
GitHub - SkelterLabsInc/JaQuAD: JaQuAD: Japanese Question Answering Dataset for Machine Reading Comprehension (2022, Skelter Labs)
JaQuAD: Japanese Question Answering Dataset for Machine Reading Comprehension (2022, Skelter Labs) - SkelterLabsInc/JaQuAD

実装

今回利用するデータセットは、質問に対して適切な回答スパンを抽出することを目的とした抽出型質問応答データセットです。

そのため、学習モデルは「段落・質問・回答スパン」というデータ構成を最大限に活用できるように設計しました。

学習モデルには近年話題となっている Transformer をベースに構築しました。

モデルの構築

自然言語は、そのままでは機械にとって扱いにくい形式で表現されています。

そこで、まず 質問文とコンテキスト文をトークン化し、それぞれを数値ベクトルに変換します。これらのベクトルを組み合わせて行列を構築し、モデルに入力することで、言語表現に潜む統計的なパターンを学習できるようにしています。

さらに自然言語処理では、単語をトークン単位に分割した後、元の文章に復元するための処理が必要となる場合があります。
この復元処理を適切に行うことで、モデル出力を人間が理解可能な自然文に変換できます。

class QATransformerModel(nn.Module):
    def __init__(self, vocab_size, d_model, nhead, dim_feedforward, nlayers, dropout, max_context_len, max_question_len):
        super(QATransformerModel, self).__init__()
        self.d_model = d_model
        self.max_context_len = max_context_len
        self.max_question_len = max_question_len
        
        # 埋め込み層
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.pos_encoder = PositionalEncoding(d_model, dropout, batch_first=True)
        
        # Transformerエンコーダー(batch_first=True)
        encoder_layer = TransformerEncoderLayer(d_model, nhead, dim_feedforward, dropout, batch_first=True)
        self.transformer_encoder = TransformerEncoder(encoder_layer, nlayers)
        
        # 質問応答用のヘッド
        self.qa_head = nn.Linear(d_model, 2)  # start, end位置予測
        
        self.init_weights()

    def init_weights(self):
        initrange = 0.1
        self.embedding.weight.data.uniform_(-initrange, initrange)
        self.qa_head.bias.data.zero_()
        self.qa_head.weight.data.uniform_(-initrange, initrange)

    def forward(self, context, question):
        # コンテキストと質問を結合
        # [CLS] question [SEP] context [SEP] の形式
        batch_size = context.size(0)
        
        # 埋め込み
        question_emb = self.embedding(question) * math.sqrt(self.d_model)
        context_emb = self.embedding(context) * math.sqrt(self.d_model)
        
        # 位置エンコーディング
        question_emb = self.pos_encoder(question_emb)
        context_emb = self.pos_encoder(context_emb)
        
        # シーケンス結合(batch_first): question + context
        combined_seq = torch.cat([question_emb, context_emb], dim=1)
        
        # Transformerエンコーダー(batch_first)
        encoded = self.transformer_encoder(combined_seq)
        
        # コンテキスト部分のみを抽出(質問部分を除く)
        context_encoded = encoded[:, question.size(1):, :]
        
        # 質問応答ヘッド
        qa_logits = self.qa_head(context_encoded)  # [batch, context_len, 2]
        
        # start, end位置のロジットを分離
        start_logits = qa_logits[:, :, 0]  # [batch, context_len]
        end_logits = qa_logits[:, :, 1]    # [batch, context_len]
        
        return start_logits, end_logits

Google Colabを使ってみよう

みなさんGoogle Colabをご存知でしょうか?

Googleが提供するクラウドベースのpython開発環境であり、Googleアカウントがあればブラウザ上で使用することができます。

さらに素晴らしい点として、ローカル環境の構築が要らず、制限はありますがGPUが無料で使えるので、高騰し続けるGPUを自前で用意しなくても良いです!

ではセットアップをしていきましょう。

  1. Google アカウントの作成
  2. “https://colab.research.google.com/?hl=ja”にアクセス
  3. ノートブックを新規作成
  4. ノートブック名を変更し保存
  5. 目次からファイルを選択して、Google Driveにマウント
  6. メニューバーのランタイムからランタイプの変更をクリックし、T4 GPUに設定
  7. Drive上の動かしたいディレクトリにcd コマンドで移動
  8. !pyhon ファイル名でプログラムを実行(Colabではpython実行時にルールがある)
4. ノートブック名変更
5. Google Driveにマウント
6. ランタイプの変更でT4 GPUに設定
7. ディレクトリに移動
Google Colab

動かしてみよう

ここでは、セットアップ済みの Google Colab 上で学習から推論までを動作確認します。

なお本セクションは 動作検証を主目的 とし、精度評価は扱いません。

学習時にはloss の挙動が不安定 に見られましたが、最終的に学習は完了し推論を実行できる状態であることを確認しました。

続いて、生成された重みファイルを用いてテストを行います。テストでは、学習サンプルと同様に コンテキスト文質問文 を与えます。

テスト方法としては、学習サンプルに準えて、コンテキスト文と質問文が必要になりますので、下記のような文章でテストしてみることにしました。

!python predict.py _log_test/_m_test_020.pth "手塚治虫は日本を代表する漫画家です。彼の代表作には火の鳥や鉄腕アトムがあります。" "手塚治虫とは?"

その結果、下記のような出力結果となりました。

cuda 
Q: 手塚治虫とは? 
A: 日本を代 

回答が途中で切れていることから、トークン化後の復元(デトークナイズ)またはスパン復元処理 に問題がある可能性が高いと考えられます。

特に日本語では、トークン単位のオフセットと文字単位の文字位置のずれが生じやすく、予測した開始から終了トークンを 元テキストの文字範囲に正しく写像 できないと、このような部分的出力が発生するようです。

おわりに

いかがでしたでしょうか。

今回はJaQuADデータセットを用いて、質問応答モデルを構築してみました。

今回のテストの例では出力結果に不備がありましたが、モデルは動いていることが確認できました。

今後は自然言語処理そのものの理解を深めつつ、基礎的なモデルを構築していきたいと思います。

参考URL

Google Colabの使い方(2025年8月4日更新) | Interface – CQ出版
私が学ぶLLM:①トークナイザについて - Qiita
今日は、自然言語処理において非常に重要な役割を果たすトークナイザについて説明したいと思います。 1.トークナイザとは 自然言語処理を行う際、まず最初に行わなければならない作業が、文章をコンピュータが処理可能な形式に変換することです。つまり、文章を単語や形態素などの基本的な...
質問応答モデルの性能の評価方法