大規模言語モデルを用いたテキスト分類の基礎と応用

前章では、埋め込み表現を核とする類似度マッチングの基本と、その技術が実現するタスクやアプリケーションについて解説しました。埋め込み表現は、テキストを意味的に効率的に表現することに焦点を当て、通常はコサイン類似度で関連性を評価します。テキストだけでなく、あらゆるオブジェクトが埋め込み表現可能であり、この技術が深層学習アルゴリズムの多岐にわたる分野で活用されています。

本章では、自然言語処理(NLP)における二つの主要なタスク、すなわち「文分類」と「トークン分類」(日本語においては「句分類」と「単語分類」とも称されます)に焦点を当てます。まず、文分類の基礎、関連する一般的なタスク、および文とトークンをどのように分類するかについて解説します。次に、ChatGPTをはじめとする大規模言語モデル(LLM)のAPI利用方法を学びます。これらのAPIを使いこなすことで、文分類はLLMが実行できる多くのタスクの一部に過ぎないことを理解できるでしょう。最後に、これらの分類タスクの具体的な応用例を紹介します。これらの応用は従来の手法でも解決可能ですが、LLMを活用することで、よりシンプルかつ効果的に実現できます。前章と同様に、最終的な目標と、その達成のためのアプローチおよびプロセスに重点を置きます。

テキスト分類の基本概念

自然言語理解(NLU)と自然言語生成(NLG)は、NLPの二大主流タスクとして知られています。NLUは一般的に、与えられた文の意味を解釈するタスクを指し、感情分析、意図認識、固有表現抽出、照応解決などが含まれ、特にインテリジェントな対話システムで広く利用されています。具体的には、ユーザーが発した発話(履歴情報も考慮に入れる場合もあります)に対して、対話エージェントは多角的な分析を行います。以下にその一部を示します。

  • 感情分析: 基本的にはポジティブ、ニュートラル、ネガティブの3種類が一般的ですが、より多くのカテゴリや、きめ細かい感情分析を設定することも可能です。特にきめ細かい感情分析は、特定のエンティティや属性に対する感情の特定に焦点を当てます。例えば、ECサイトの商品レビューでは、ユーザーが商品の価格、配送、サービスなど、一つまたは複数の側面に対して意見を表明することがあります。このような場合、レビュー全体の感情傾向よりも、各属性に対する感情傾向を把握することが重要となります。
  • 意図認識: 大半は分類モデルであり、多クラス分類が一般的ですが、階層型ラベル分類や多ラベル分類の場合もあります。多クラス分類では、入力テキストに対して単一のラベルが出力されますが、ラベルの総数は複数存在します(例: 対話テキストのラベルが「住所の問い合わせ」「時間の問い合わせ」「価格の問い合わせ」「雑談」など)。階層型ラベル分類では、入力テキストに対して階層的なラベルが出力され、ルートノードから最も詳細なカテゴリへのパスを示します(例: 「住所の問い合わせ/自宅住所の問い合わせ」「住所の問い合わせ/会社住所の問い合わせ」など)。多ラベル分類では、入力テキストに対して不定数のラベルが出力され、各テキストが複数の同位のラベルを持つ可能性があります(例: 「苦情」「提案」(苦情と提案の両方を含む場合))。
  • 固有表現抽出と関係抽出: 固有表現抽出とは、与えられたテキストから固有表現を識別・抽出するタスクです。固有表現は通常、特定の意味を持つ実詞を指し、人名、地名、作品名、ブランド名などが含まれ、多くの場合、業務に直接関連するか、特に注目すべき単語です。関係抽出とは、固有表現間の関係を判断するタスクです。固有表現間にはしばしば特定の関係が存在します。例えば、「中国四大名著の筆頭『紅楼夢』は清代の作家 曹雪芹によって書かれた。」という文から、「曹雪芹」は人名、「紅楼夢」は作品名であり、両者の関係は「執筆」と捉えられ、通常は(曹雪芹、執筆、紅楼夢)のような三つ組で表現されます。

これらの分析を通じて、対話エージェントはユーザーの入力内容を明確に理解し、適切な応答を生成するための準備が整います。なお、上記の一連のプロセスは対話システムに限らず、ユーザーからのクエリ(問い合わせ)に対してシステムが応答を生成する必要があるあらゆるシナリオで適用され、一般に「クエリ解析」と呼ばれます。

上記の分析をアルゴリズムの観点から見ると、以下の二つに分類できます。

  • 文レベル分類: 感情分析、意図認識、関係抽出など。これは、文(および追加情報)に対して一つまたは複数のラベルを付与するものです。
  • トークンレベル分類: 固有表現抽出、読解問題(テキストと質問が与えられ、テキストの中から質問の答えを見つけるタスク)など。これは、文中の各トークンに対して、対応する固有表現や答えのインデックス位置を与えるものです。

トークンレベル分類は直感的に理解しにくいかもしれませんので、例を挙げます。先ほどの「中国四大名著の筆頭『紅楼夢』は清代の作家 曹雪芹によって書かれた。」という文を考えましょう。この文がアノテーションされる際は以下のようになります。

中 O
国 O
四 O
大 O
名 O
著 O
之 O
首 O
《 O
紅 B-WORK
楼 I-WORK
夢 I-WORK
》 O
は O
清 O
代 O
の O
作 O
家 O
曹 B-PERSON
雪 I-PERSON
芹 I-PERSON
に O
よ O
っ O
て O
書 O
か O
れ O
た O
。 O

この例では、各トークン(ここでは各漢字)が対応するラベルを持ちます(もちろん、複数持つことも可能です)。ラベル中の B は「開始 (Begin)」、I は「内部 (Internal)」、O は「その他 (Other)」(つまり、固有表現ではない)を示します。「紅楼夢」は作品名なので WORK、「曹雪芹」は人名なので PERSON とラベル付けされています。もちろん、実際の要件に応じて「中国」や「清代」などの固有表現をラベル付けするかどうかは決定されます。モデルの役割は、この対応関係を学習し、新しいテキストが与えられた際に、各トークンに対するラベルを予測することです。

これら本質的にすべて分類タスクであり、分類対象の位置や基準が異なるだけであることが分かります。もちろん、実際のアプリケーションでは様々なバリエーションや設計が存在しますが、全体的な考え方は似ています。私たちは詳細をすべて把握する必要はなく、入力、出力、および基本的なロジックを理解していれば十分です。

文単位の分類方法

次に、これらの分類が具体的にどのように行われるかを簡単に説明します。まずは文レベルの分類から始めましょう。前章で紹介した埋め込み表現は、深層学習を用いたNLP全体の基盤であり、このセクションの内容でも埋め込み表現を利用します。具体的なプロセスは以下の通りです。

  • 与えられた文やテキストを埋め込みベクトルに変換します。
  • この埋め込みベクトルをニューラルネットワークに入力し、異なるラベルの確率分布を計算します。
  • 前ステップで得られたラベルの確率分布を真のラベルと比較し、誤差を逆伝播させてニューラルネットワークのパラメータを修正します(すなわち、学習を行います)。
  • 学習済みのニューラルネットワーク、つまりモデルを獲得します。

例として、簡単のため、埋め込みベクトルの次元を32次元と仮定します(前章のOpenAIが返す次元はより大きい値です)。

import numpy as np
np.random.seed(42) # 再現性のためにシードを設定

input_embedding = np.random.normal(0, 1, (1, 32))
# input_embedding の形状: (1, 32)
# これは、1つの文が32次元のベクトルで表現されていることを意味します。

ここで、平均0、標準偏差1の1×32次元のガウス分布に従う乱数を埋め込みベクトルとして生成します。もし3クラス分類を行う場合、最も単純なモデルのパラメータである重み行列 classifier_weights は32×3のサイズになります。モデルの予測プロセスは以下のようになります。

classifier_weights = np.random.random((32, 3))
raw_scores = input_embedding @ classifier_weights
print(f"生のスコア (raw_scores): {raw_scores}")
print(f"スコアの形状: {raw_scores.shape}")
# 出力例: 生のスコア (raw_scores): [[12.19129596  6.25141042  5.96041699]]
#         スコアの形状: (1, 3)

ここで得られる raw_scores は一般的に「ロジット」と呼ばれます。確率分布を得るためには、これを正規化する必要があります。つまり、ロジットを0から1の間の確率値に変換し、各行の合計が1(100%)になるようにします。以下にその方法を示します。

def softmax_normalize(scores):
    """ロジットを確率分布に変換するSoftmax関数"""
    exponentiated_scores = np.exp(scores - np.max(scores, axis=-1, keepdims=True)) # オーバーフロー防止
    return exponentiated_scores / np.sum(exponentiated_scores, axis=-1, keepdims=True)

predicted_probs = softmax_normalize(raw_scores)
print(f"予測確率 (predicted_probs): {predicted_probs}")
print(f"確率の合計: {np.sum(predicted_probs)}")
# 出力例: 予測確率 (predicted_probs): [[0.99723235 0.00136279 0.00140486]]
#         確率の合計: 1.0

得られた predicted_probs から、予測されるラベルは0番目の位置のラベルであることがわかります。なぜなら、その位置の確率が最も高い(約99.72%)からです。もし真のラベルが1番目の位置のラベルであった場合、真の値は1(100%)ですが、現在の予測確率は約0.13%しかありません。この誤差が逆伝播され、重み行列 classifier_weights のパラメータが調整されます。次回の計算では、0番目の位置の確率は小さくなり、1番目の位置の確率は大きくなるように学習が進みます。このように、アノテーションされたデータサンプルを用いて繰り返し誤差を修正するプロセスがモデル学習であり、モデル classifier_weights がラベルを可能な限り正確に予測できるようにすることを目指します。

実際には、classifier_weights はより複雑な構造を持つことが多く、最終的な出力が1×3のサイズになる限り、任意の配列を含めることができます。例えば、もう少し複雑なネットワークを記述してみましょう。

hidden_weights_1 = np.random.random((32, 100))
hidden_weights_2 = np.random.random((100, 32))
output_weights = np.random.random((32, 3))

# 複数の層を通過させて予測確率を計算
# 通常は非線形活性化関数を挟みますが、ここでは簡略化
intermediate_output_1 = input_embedding @ hidden_weights_1
intermediate_output_2 = intermediate_output_1 @ hidden_weights_2
final_raw_scores = intermediate_output_2 @ output_weights

predicted_probs_complex = softmax_normalize(final_raw_scores)
print(f"複雑なモデルでの予測確率: {predicted_probs_complex}")
# 出力例: 複雑なモデルでの予測確率: [[0.33413988 0.3323063  0.33355382]]

ご覧の通り、ここでは3つの重み行列を持つモデルパラメータが存在し、形式は複雑になりますが、結果はやはり1×3のサイズの配列です。その後のプロセスは前述と同様になります。

少し複雑なのは多ラベル分類と階層型ラベル分類です。これらは複数のラベルを出力するため、処理が少し面倒になりますが、処理方法は類似しています。多ラベル分類を例に説明します。10個のラベルがあり、与えられた入力テキストがそのうちの任意の複数のラベルを持つ可能性があると仮定します。これは、10個のラベルそれぞれの確率分布を表現する必要があることを意味します。各ラベルに対して二値分類を行うことができます。つまり、出力サイズは10×2となり、各行が「そのラベルであるか否か」の確率分布を示します。以下に例を示します。

def softmax_row_wise(scores_matrix):
    """行列の各行に対してSoftmax正規化を適用する関数"""
    exp_scores = np.exp(scores_matrix - np.max(scores_matrix, axis=-1, keepdims=True))
    return exp_scores / np.sum(exp_scores, axis=-1, keepdims=True)

np.random.seed(42) # 再現性のためにシードを設定

input_embedding = np.random.normal(0, 1, (1, 32))
# 10個のラベルに対し、それぞれ2値分類器(各ラベルである/ない)
# 重み行列の形状: (10, 32, 2) -> 各ラベルについて (32, 2)
multi_label_weights = np.random.random((10, 32, 2))

# 埋め込みベクトルと重み行列の乗算
# 各ラベルに対するスコアを計算
raw_multi_label_scores = np.array([input_embedding @ multi_label_weights[i] for i in range(10)])
raw_multi_label_scores = raw_multi_label_scores.squeeze(axis=1) # 形状を (10, 2) に調整

predicted_multi_label_probs = softmax_row_wise(raw_multi_label_scores)
print(f"多ラベル分類の予測確率:\n{predicted_multi_label_probs}")
print(f"形状: {predicted_multi_label_probs.shape}")

# 出力例:
# 多ラベル分類の予測確率:
# [[0.66293305 0.33706695]
#  [0.76852603 0.23147397]
#  [0.59404023 0.40595977]
#  [0.04682992 0.95317008]
#  [0.84782999 0.15217001]
#  [0.01194495 0.98805505]
#  [0.96779413 0.03220587]
#  [0.04782398 0.95217602]
#  [0.41894957 0.58105043]
#  [0.43668264 0.56331736]]
# 形状: (10, 2)

ここでの出力は各行が2つの値を持っており、それぞれ「そのラベルではない」と「そのラベルである」の確率を表します。例えば、1行目では、そのラベルではない確率が約66.29%、そのラベルである確率が約33.71%と予測されています。正規化の際には、特定の軸(ここでは各行)に沿って合計を求めるように指定する必要がある点に注意してください。そうしないと、すべての確率値の合計が1になってしまい、正しくありません(各行の確率の合計が1になるべきです)。

上記が文レベル分類(sequence classification)のロジックですが、実際のシナリオはここで挙げた例よりもはるかに複雑であることを再度強調します。しかし、基本的な考え方は変わりません。大規模言語モデルの時代では、もはや自分でモデルを構築する必要はなく、本章の後半で大規模言語モデルAPIを使用して様々なタスクを実行する方法を説明します。

文分類からトークン分類へ

次に、トークンレベル分類を見ていきましょう。これまでの基礎があれば、比較的容易に理解できるはずです。最大の特徴は、埋め込み表現が各トークンに対して生成される点にあります。つまり、与えられたテキストの長さが10で、次元が引き続き32と仮定すると、埋め込み表現のサイズは (1, 10, 32) となります。これは、先ほどの文分類で使用した (1, 32) に10の次元が増えた形です。言い換えれば、このテキストの各トークンがそれぞれ32次元のベクトルとして表現されることになります。

ここでは、ラベルが合計5つあると仮定します。上記の例に対応させて、B-PERSONI-PERSONB-WORKI-WORK、およびOとします。基本的なプロセスは以下の通りです。

token_embeddings = np.random.normal(0, 1, (1, 10, 32)) # 1文、10トークン、各32次元
# トークンごとに5クラス分類を行うための重み行列
token_classifier_weights = np.random.random((32, 5))

# 各トークンに対して分類スコアを計算
raw_token_scores = token_embeddings @ token_classifier_weights
# raw_token_scores の形状: (1, 10, 5)

# 各トークンのスコアを確率分布に変換
predicted_token_probs = softmax_normalize(raw_token_scores)
print(f"トークン分類の予測確率の形状: {predicted_token_probs.shape}")
print(f"トークン分類の予測確率 (一部):\n{predicted_token_probs[:, :, :2]}") # 例として最初の2クラス分を表示

# 出力例:
# トークン分類の予測確率の形状: (1, 10, 5)
# トークン分類の予測確率 (一部):
# [[[0.23850186 0.04651826]
#   [0.06401011 0.3422055 ]
#   [0.18309536 0.62132479]
#   [0.01570559 0.0271437 ]
#   [0.1308541  0.06810165]
#   [0.08011671 0.04648297]
#   [0.05143706 0.09635837]
#   [0.03721064 0.14529403]
#   [0.24154874 0.28648044]
#   [0.10965428 0.00432547]]]

注意深く見ると、各行が1つのトークンが特定のラベルである確率分布を示しています(各行の合計は1になります)。例えば、最初のトークンについて。

print(np.sum(predicted_token_probs[0, 0, :])) # 最初のトークンの全ラベル確率の合計
# 出力: 1.00000001

具体的な意味としては、最初のトークンが0番目の位置のラベル(上記で指定した順序であれば B-PERSON)である確率は約23.85%であり、他のラベルも同様です。ここで予測された結果に基づいて、例えば最初のトークンのラベルが O であるとします。すると、真のラベルとこの予測されたラベルとの間に誤差が生じる可能性があり、この誤差を通じてパラメータを更新することで、次回の予測時に正しいラベル(つまり、正しい位置の確率が最大になるように)を予測できるようになります。このロジックは、前述の文分類と類似しており、本質的には各トークンに対して多クラス分類を行っているにすぎません。

NLUの一般的な問題の基本原理についてはここまでとします。さらに詳細に興味がある読者は、NLPアルゴリズムに関する書籍を読み、実際に手を動かせる小規模なプロジェクトから始めて、段階的に知識体系を構築していくことをお勧めします。

ChatGPT APIの利用

基本的なGPT補完機能の活用

このセクションでは、OpenAIの「Completion」APIについて説明し、大規模言語モデルのIn-Context学習能力を利用したゼロショット推論またはフューショット推論を紹介します。ここでいくつかの重要な概念を第一章で紹介しましたが、簡潔に振り返ります。

  • In-Context学習: 簡単に言えば、文脈を理解する能力です。モデルは入力されたテキストに基づいて、自動的に対応する結果を生成します。この能力は、モデルが膨大な量のテキストを学習した後に獲得したものであり、一種の内在的な理解能力と見なせます。
  • ゼロショット学習: モデルに直接テキストを与え、求められるラベルや出力を生成させる手法です。
  • フューショット学習: モデルにいくつかの類似する例(入力+出力)を与え、それに続いて未出力の新しい入力を与えることで、モデルに出力を生成させる手法です。

これらの手法を活用することで、同じAPIを使い、異なる入力を構築することで多様なタスクをこなすことができます。つまり、大規模言語モデルのIn-Context学習能力を借りて、入力時にモデルにタスクを伝えるだけでよくなります。具体的な使い方を見ていきましょう。

import openai
import os

# 環境変数からAPIキーを取得
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY

def generate_completion(prompt_text: str) -> str:
    """OpenAI Completion APIを呼び出すヘルパー関数"""
    response = openai.Completion.create(
      model="text-davinci-003",
      prompt=prompt_text,
      temperature=0, # 出力のランダム性を制御 (0で決定論的)
      max_tokens=64, # 生成する最大トークン数
      top_p=1.0,     # サンプリング方法 (1.0で全トークンを考慮)
      frequency_penalty=0.0, # トークンの繰り返しを抑制
      presence_penalty=0.0   # 新しいトピックの導入を促進
    )
    # 最初の選択肢のテキスト部分を返す
    answer_text = response.choices[0].text
    return answer_text.strip() # 前後の空白や改行を削除

Completion APIは、単に段落や文章を書き続けるだけでなく、本章で説明する文分類や固有表現抽出など、様々なタスクを実行できます。前章の Embedding APIと比較して、そのパラメータははるかに複雑です。特に重要なパラメータについて説明します。

  • model: 使用するモデルを指定します。text-davinci-003はその一例です。公式ドキュメントを参照し、価格と効果を考慮して適切なモデルを選択できます。
  • prompt: プロンプト(指示テキスト)です。デフォルトは <|endoftext|> で、これはモデルの訓練中に見られるドキュメント区切り文字です。したがって、プロンプトが指定されない場合、モデルは新しいドキュメントの開始から始まるかのように動作します。簡単に言えば、モデルへの指示文です。
  • max_tokens: 生成される最大トークン数で、デフォルトは16です。ここでのトークン数は必ずしも文字数と一致しない点に注意してください。プロンプトと生成されたテキストの全トークン長は、モデルのコンテキスト長を超えることはできません。モデルによってサポートされる最大長は異なるため、関連ドキュメントを参照してください。
  • temperature: 温度パラメータで、デフォルトは1です。0から2の間の値を指定します。値が高いほど(例: 0.8)出力はよりランダムになり、値が低いほど(例: 0.2)より集中的で決定的な出力になります。通常、このパラメータか後述の top_p のいずれかを調整することをお勧めしますが、両方を同時に変更することは推奨されません。
  • top_p: 次のトークンが累積確率が top_p のトークンの中からサンプリングされます。デフォルトは1で、すべてのトークンがサンプリング範囲に含まれることを意味します。0.8は、確率の高い上位80%のトークンのみが次のサンプリング対象となることを意味します。
  • stop: 生成を停止させるトークンまたはシーケンスで、デフォルトは null です(最大4つ)。このトークンまたはシーケンスに遭遇すると、生成が停止します。生成結果には stop 文字列は含まれません。
  • presence_penalty: 存在ペナルティで、デフォルトは0です。-2.0から2.0の間の数値を指定します。正の値を設定すると、新しいトークンがそれまでのテキストに出現しているかどうかに基づいてペナルティが課され、モデルが新しいトピックについて議論する可能性が高まります。値が高すぎると、サンプル品質が低下する可能性があります。
  • frequency_penalty: 頻度ペナルティで、デフォルトは0です。-2.0から2.0の間の数値を指定します。正の値を設定すると、新しいトークンがそれまでのテキストにおける既存の出現頻度に基づいてペナルティが課され、モデルが同じ内容を繰り返し生成する可能性が低減します。値が高すぎると、サンプル品質が低下する可能性があります。

ほとんどの場合、上記のパラメータ、あるいは最初の2つのパラメータだけを考慮すれば十分であり、残りはデフォルト設定でも問題ありません。しかし、これらのパラメータに精通しておくことで、APIをより効果的に活用できるようになります。また、ここでOpenAIのAPIを使用していますが、他のプロバイダが提供する同様のAPIのパラメータも大きくは違いません。これらのパラメータに慣れておくことで、将来的に切り替える際もスムーズに対応できるでしょう。

それでは、文分類のいくつかの例を見ていきましょう。ゼロショットとフューショットのそれぞれについて示します。ゼロショットの例は以下の通りです。

# ゼロショット学習の例 (OpenAI公式サンプルを参考に)
company_classification_prompt = """以下の企業とその属するカテゴリのリストです:

アップル, フェイスブック, フェデックス

アップル
カテゴリ:"""

classification_response = generate_completion(company_classification_prompt)
print(classification_response)

# 期待される出力例:
# テクノロジー
#
# フェイスブック
# カテゴリ:
# ソーシャルメディア
#
# フェデックス
# カテゴリ:
# 物流・配送

ご覧の通り、会社名とそれに対応する形式を提示するだけで、モデルは各会社が属するカテゴリを返すことができます。次に、フューショットの例です。

# フューショット学習の例 (感情分析)
sentiment_prompt = """今日は本当に楽しかった。-->ポジティブ
気分があまり良くない。-->ネガティブ
私たちは幸せな若者だ。-->"""

sentiment_response = generate_completion(sentiment_prompt)
print(sentiment_response)

# 期待される出力例:
# ポジティブ

この例では、まず2つのサンプルを提供し、次に新しい文を与えてモデルにそのカテゴリを出力させています。モデルが「ポジティブ」を正しく出力していることがわかります。

次に、トークン分類(固有表現抽出)の例を見ていきましょう。まずはゼロショットの例です。

# ゼロショット学習の例 (固有表現抽出 - OpenAI公式サンプルを参考に)
entity_extraction_prompt = """以下のテキストから、指定された形式でエンティティを抽出してください:
企業名: <カンマ区切りの企業名リスト>
人物名と役職: <カンマ区切りの人物名リスト (役職や役割を括弧書きで追記)>

テキスト:
1981年3月、Assistant Attorney General William Baxterの指揮のもと、United States v. AT&Tの裁判が始まりました。AT&T会長のCharles L. Brownは、会社が解体されるだろうと考えていました。彼はAT&Tが敗訴することを悟り、1981年12月に司法省との交渉を再開しました。1ヶ月も経たないうちに合意に達し、Brownは分割に同意しました。これが最良かつ唯一現実的な選択肢でした。AT&Tの決定により、同社は研究部門と製造部門を保持することができました。この判決は、Modification of Final Judgmentと題され、1956年1月14日のConsent Decreeの調整でした。Judge Harold H. Greeneには、修正された判決に対する権限が与えられました...

1982年、米国政府はAT&Tが独占企業としての存在を終えることを発表しました。1984年1月1日、AT&Tは米国内の地域電話サービスを担う7つの小規模な地域会社、Bell South、Bell Atlantic、NYNEX、American Information Technologies、Southwestern Bell、US West、Pacific Telesisに分割されました。AT&Tは長距離サービスを制御し続けましたが、競争から保護されなくなりました。
"""

entity_response = generate_completion(entity_extraction_prompt)
print(entity_response)

# 期待される出力例:
# 企業名: AT&T, Bell South, Bell Atlantic, NYNEX, American Information Technologies, Southwestern Bell, US West, Pacific Telesis
# 人物名と役職: William Baxter (Assistant Attorney General), Charles L. Brown (AT&T chairman), Harold H. Greene (Judge)

OpenAIの公式サンプルであるこの例では、モデルに与えられたテキストからエンティティを抽出し、指定された形式で出力するよう求めています。「企業名」エンティティについては、カンマ区切りの企業名リストを、また「人物名と役職」エンティティについては、カンマ区切りの人物名リスト(括弧内に役職または役割を追記)を出力します。モデルがタスクを適切に実行していることがわかります。次に、フューショットの例です。ここではエンティティを少し特殊なものにし、一般的な人名、会社名、住所などではなく、和音に関する音楽理論の知識を利用します。

# フューショット学習の例 (専門的な固有表現抽出)
chord_entity_prompt = """
以下の形式に従って、指定されたテキストから和音のエンティティを抽出してください:
和音: <カンマ区切り>

テキスト:
増三和音は大三度+大三度の増五度音、減三和音は小三度+小三度の減五度音である。
和音:増三和音,減三和音

テキスト:
三和音は3つの音が三度音程関係で並べられた音群である。長三和音は大三度+小三度の完全五度音、短三和音は小三度+大三度の完全五度音である。
"""

chord_entity_response = generate_completion(chord_entity_prompt)
print(chord_entity_response)

# 期待される出力例:
# 和音:長三和音,短三和音

結果は非常に良好に見えます。読者の皆さんは、この例を与えなかった場合にどのような出力になるか、また他の例を与えた場合にどのような効果があるか試してみることをお勧めします。なお、OpenAIモデルの継続的なアップグレードに伴い、この Completion APIは将来的に非推奨となる可能性があります。

より高度なChatGPT命令

このセクションでは、ChatGPTのAPI、具体的には ChatCompletions インターフェースについて説明します。これは対話(チャット)を目的としていますが、実質的にあらゆるNLPタスクを実行できます。パラメータは Completion APIと似ていますが、主要なパラメータに焦点を当てて説明します。

  • model: 使用するモデルを指定します。gpt-3.5-turboはChatGPTのモデルです。実際の状況に応じて、公式ドキュメントを参照して適切なモデルを選択できます。
  • messages: 会話メッセージのリストです。複数回のターンをサポートし、各メッセージは rolecontent の2つのフィールドを含む辞書形式で表されます。role は役割(例: "user", "assistant", "system")、content はメッセージ内容です。例: [{"role": "user", "content": "こんにちは!"}]
  • temperature: Completion APIと同じ意味です。
  • top_p: Completion APIと同じ意味です。
  • stop: Completion APIと同じ意味です。
  • max_tokens: デフォルトは上限なしですが、Completion APIと同じく、モデルがサポートする最大コンテキスト長に制限されます。
  • presence_penalty: Completion APIと同じ意味です。
  • frequency_penalty: Completion APIと同じ意味です。

詳細は公式ドキュメントを参照してください。複数ターンの会話をサポートしており、過去の会話履歴を messages リストに追加するだけで非常に簡単に実現できる点が特筆すべきです。

それでは、ChatGPT方式で前のセクションで行ったタスクを試してみましょう。同様に、まず汎用的なメソッドを作成します。

import openai
import os

OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY

def interact_chat_model(user_message: str) -> str:
    """OpenAI ChatCompletion APIを呼び出すヘルパー関数"""
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[{"role": "user", "content": user_message}]
    )
    # 最初の選択肢のassistantメッセージのテキスト部分を返す
    assistant_reply = response.choices[0].message.content
    return assistant_reply.strip() # 前後の空白や改行を削除

上記の例を順に試していきます。まず、最初の企業分類の例を見てみましょう。

company_category_prompt_chat = """以下の企業とその属するカテゴリのリストです:

アップル, フェイスブック, フェデックス

アップル
カテゴリ:"""

chat_response_1 = interact_chat_model(company_category_prompt_chat)
print(chat_response_1)

# 期待される出力例:
# テクノロジー/エレクトロニクス
#
# フェイスブック
# カテゴリ:
# テクノロジー/ソーシャルメディア
#
# フェデックス
# カテゴリ:
# 物流/配送

入力は前のセクションと同じにしていますが、最終的な出力結果も同様であることがわかります。ただし、ChatGPTではプロンプトをより柔軟で自然な形にすることができます。例えば、以下のように記述できます。

natural_company_category_prompt = """以下の企業カテゴリを出力してください:
アップル, フェイスブック, フェデックス

出力形式は以下の通りにしてください:
<企業名>
カテゴリ:
<カテゴリ名>
"""

chat_response_2 = interact_chat_model(natural_company_category_prompt)
print(chat_response_2)

# 期待される出力例:
# アップル
# カテゴリ:
# テクノロジー
#
# フェイスブック
# カテゴリ:
# テクノロジー/ソーシャルメディア
#
# フェデックス
# カテゴリ:
# 配送/物流

うまくタスクを完了しています。ChatCompletion APIは、以前の Completion APIよりも「賢く」、より自然な対話が可能です。まるで私たちが与えた指示を理解し、タスクを完了したかのように見えます。単なる続きの文章生成ではありません。しかし、Completion APIも一定の指示はサポートしており、ChatCompletion APIはその初期バージョンから継承された技術であることを補足しておきます。

プロンプトは非常に柔軟であるため、異なる記述方法によって異なる結果が得られる可能性があります。これがすぐに「プロンプトエンジニアリング」という新しい技術分野を生み出しました。ここでは、プロンプト記述に関するいくつかの一般的な推奨事項を紹介します。

  • 明確さ: 複雑さや曖昧さを避け、専門用語がある場合は明確に定義してください。
  • 具体性: 記述する言語は可能な限り具体的であるべきで、抽象的または曖昧な表現は避けてください。
  • 焦点: 問題は広範またはオープンエンドにならないように焦点を絞ってください。
  • 簡潔さ: 不必要な記述は避けてください。
  • 関連性: 主にテーマとの関連性を指し、対話全体を通じて関連性を保つべきです。

初心者は特に、以下の見落としがちな点に注意してください。

  • 具体的な出力目標の欠如: 特殊なシナリオ(例えば、目的のない雑談など)を除き、出力目標を明確にしない。
  • 一度の会話で複数のテーマを混合: 複数のテーマを混ぜて質問すると、モデルが混乱する可能性があります。
  • 言語モデルに数学問題を解かせる: 言語モデルは数学的な問題処理にはあまり得意ではありません。
  • 求めるもののサンプルを提供しない: モデルが意図をよりよく理解するためには、いくつかの例を示す必要がある場合があります。例えば、前のセクションで作成した和音の固有表現抽出の例や、あまり一般的でない例には、いくつかサンプルを提供すべきです。
  • 逆提示(ネガティブプロンプト): 「~をしてはいけない」といった否定的な指示です。モデルはこのようなタスクにはあまり得意ではありません。
  • 一度に一つのことだけをさせる: 新しいユーザーは、タスクを細かく分解しすぎて、一度にモデルに小さなステップだけを尋ねる極端な傾向に陥りがちです。この場合、ステップをまとめて一度に明確に伝えることをお勧めします。
  • 引き続き、感情分類の例を試してみましょう。

    sentiment_prompt_chat = """以下の文の感情傾向を判断してください。感情傾向は「ポジティブ」、「中立」、「ネガティブ」の3種類です。
    文:私たちは幸せな若者だ。
    """
    
    chat_response_3 = interact_chat_model(sentiment_prompt_chat)
    print(chat_response_3)
    
    # 期待される出力例:
    # 感情傾向:ポジティブ
    

    問題なく、期待通りの結果です。このような一般的なタスクの場合、通常はモデルが非常にうまく処理できます。

    次に、固有表現抽出の例を行います。

    entity_extraction_prompt_chat = """
    以下のテキストからエンティティを抽出してください。エンティティには企業名 (Company) と人物名・役職 (People&Title) が含まれます。人物名については、その後に役職または役割を括弧書きで追記してください。
    
    テキスト:
    1981年3月、Assistant Attorney General William Baxterの指揮のもと、United States v. AT&Tの裁判が始まりました。AT&T会長のCharles L. Brownは、会社が解体されるだろうと考えていました。彼はAT&Tが敗訴することを悟り、1981年12月に司法省との交渉を再開しました。1ヶ月も経たないうちに合意に達し、Brownは分割に同意しました。これが最良かつ唯一現実的な選択肢でした。AT&Tの決定により、同社は研究部門と製造部門を保持することができました。この判決は、Modification of Final Judgmentと題され、1956年1月14日のConsent Decreeの調整でした。Judge Harold H. Greeneには、修正された判決に対する権限が与えられました...
    
    1982年、米国政府はAT&Tが独占企業としての存在を終えることを発表しました。1984年1月1日、AT&Tは米国内の地域電話サービスを担う7つの小規模な地域会社、Bell South、Bell Atlantic、NYNEX、American Information Technologies、Southwestern Bell、US West、Pacific Telesisに分割されました。AT&Tは長距離サービスを制御し続けましたが、競争から保護されなくなりました。
    """
    
    chat_response_4 = interact_chat_model(entity_extraction_prompt_chat)
    print(chat_response_4)
    
    # 期待される出力例:
    # エンティティ抽出結果:
    # - Company: AT&T, Bell South, Bell Atlantic, NYNEX, American Information Technologies, Southwestern Bell, US West, Pacific Telesis
    # - People&Title: William Baxter (Assistant Attorney General), Charles L. Brown (AT&T chairman), Judge Harold H. Greene.
    

    これも良好な結果であり、日本語と英語の混合入力を使用している点に注目してください。最後に、前のセクションで扱ったもう一つの固有表現抽出の例を試します。

    chord_entity_prompt_chat = """
    以下の形式に従って、指定されたテキストから和音のエンティティを抽出してください。エンティティには「和音」という二文字を含める必要があります。
    
    希望する出力形式:
    和音:<カンマ区切り>
    
    テキスト:
    三和音は3つの音が三度音程関係で並べられた音群である。長三和音は大三度+小三度の完全五度音、短三和音は小三度+大三度の完全五度音である。
    """
    
    chat_response_5 = interact_chat_model(chord_entity_prompt_chat)
    print(chat_response_5)
    
    # 期待される出力例:
    # 和音:長三和音, 短三和音
    

    ここでも日本語と英語の混合入力を使用しましたが、結果は完全に問題ありません。読者の皆さんも様々なプロンプトを試してみることをお勧めします。一般的に、プロンプトには標準的な答えはなく、実践経験が重要です。

    関連タスクと応用

    ドキュメントQA: 文書に基づく質疑応答

    ドキュメントQAは、前章で紹介したQAに似ていますが、少し複雑になります。まずQAの手法を用いて関連文書を検索し、次にその文書から質問の答えをモデルに探させます。一般的な流れは、関連文書を検索し、その上で読解タスクを実行することです。読解タスクは固有表現抽出タスクと類似していますが、具体的なラベルを予測するのではなく、元の文書中の答えの開始位置と終了位置を予測します。

    例として、「北京オリンピックは何年に開催されましたか?」という質問を考えましょう。この質問に対して、北京オリンピックの開催に関するニュースを含む文書が検索されるかもしれません。例えば、以下の文書です。この場合、「2008年」という答えが文書内のどこにあるかというインデックス位置が、アノテーションデータとしてマークされます。

    第29回夏季オリンピック競技大会(Beijing 2008; Games of the XXIX Olympiad)、別称2008年北京オリンピックは、2008年8月8日午後8時ちょうどに中国の首都北京で開幕しました。8月24日に閉幕。
    

    もちろん、1つの文書に複数の質問がある場合があります。例えば、上記の文書に対して、「北京オリンピックはいつ開幕しましたか?」「北京オリンピックはいつ閉幕しましたか?」「北京オリンピックは何回目の大会でしたか?」といった質問も可能です。

    従来のNLP手法では、このタスクを実行するためのアプローチは多数あり、それなりに複雑でしたが、全体としては意味マッチングとトークン分類タスクに帰着します。しかし、大規模言語モデルが登場したことで、問題は非常にシンプルになりました。依然として2つのステップで構成されます。

    • 関連文書の検索: 前章のQAと類似していますが、ここでは質問ではなく文書を検索します。具体的には、質問と多数の文書の類似度を計算し、最も類似度の高い文書を選択します。
    • 与えられた文書に基づく質問応答: 検索された文書と質問をプロンプトとして大規模言語モデルAPI(例えば、前節で紹介した CompletionChatCompletion)に渡し、直接モデルに答えを生成させます。

    最初のステップについては既に詳しく説明しましたが、2番目のステップでは、2種類のAPIそれぞれで例を挙げます。まず、 Completion APIの例を見てみましょう。

    import openai
    import os
    
    OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
    openai.api_key = OPENAI_API_KEY
    
    
    def get_completion_response(query_prompt: str) -> str:
        """Completion APIを使用して質問応答を行う関数"""
        response = openai.Completion.create(
            prompt=query_prompt,
            temperature=0,
            max_tokens=300,
            top_p=1,
            frequency_penalty=0,
            presence_penalty=0,
            model="text-davinci-003"
        )
        generated_answer = response["choices"][0]["text"].strip(" \n")
        return generated_answer
    
    

    最初のステップが完了し、1つの文書が取得されたと仮定します。この文書は通常、比較的長いため、プロンプトも長くなります。

    # 公式サンプルより
    qa_prompt_with_context = """提供されたテキストを使用して、質問に可能な限り正確に答えてください。もし答えが以下のテキストに含まれていない場合は、「わかりません」と答えてください。
    
    コンテキスト:
    2020年夏季オリンピックの男子走り高跳び競技は、2021年7月30日から8月1日までオリンピックスタジアムで行われました。24カ国から33人の選手が参加しました。合計可能な選手数は、32人が標準記録またはランキングで予選を通過したことに加え、どれだけの国がユニバーサリティ枠を利用して選手を参加させるかによって決まります(2021年にはユニバーサリティ枠は使用されませんでした)。イタリアのジャンマルコ・タンベリ選手とカタールのムタズ・エッサ・バルシム選手は、2.37mをクリアした後に両者同点でイベントの共同優勝者となりました。タンベリとバルシムの両選手は、オリンピック史上稀な、異なる国の選手が同じメダルを共有することに合意しました。特にバルシム選手は、「2つの金メダルはもらえますか?」と、ジャンプオフを提案されたことに対して競技役員に尋ねるのが聞かれました。ベラルーシのマクシム・ネダセカウ選手が銅メダルを獲得しました。このメダルは、男子走り高跳びにおいてイタリアとベラルーシにとって史上初のメダルであり、イタリアとカタールにとっては男子走り高跳びで初の金メダルであり、カタールにとっては男子走り高跳びで3大会連続のメダル(すべてバルシム選手による)でした。バルシム選手は、走り高跳びで3つのメダルを獲得した史上2人目の選手となり、スウェーデンのパトリック・ショーベリ選手(1984年から1992年)に並びました。
    
    質問:2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?
    A:"""
    
    qa_answer_completion = get_completion_response(qa_prompt_with_context)
    print(qa_answer_completion)
    
    # 期待される出力例:
    # ジャンマルコ・タンベリとムタズ・エッサ・バルシムが共同でイベントの優勝者となりました。
    

    上記の Context は検索された文書です。APIが適切に答えを出力していることがわかります。また、プロンプトを構築する際にいくつかの制約を設けている点に注意してください。主に2点あります。1つ目は、与えられたテキストに基づいて可能な限り正確に質問に答えるよう要求していること。2つ目は、答えが与えられたテキストに含まれていない場合は「わかりません」と回答するよう指示していることです。これらはすべて、出力結果の正確性を最大限に保証し、モデルが不正確な情報を生成する可能性を減らすためのものです。

    次に、 ChatCompletion APIを見てみましょう。ここでは日本語の例を選びます。

    japanese_qa_prompt = """以下のコンテキストに基づいて質問に答えてください。答えのみを出力し、いかなる文脈も付加しないでください。
    
    コンテキスト:
    ノルマン人(ノルマン人:Nourmands;フランス語:Normands;ラテン語:Normanni)は、10世紀から11世紀にかけてフランスのノルマンディー地方にその名を冠した人々です。彼らは北欧人(デンマーク人、ノルウェー人、そしてヴァイキング)の子孫である海賊と海賊であり、首相ロロ(Rollo)の指導の下、西フランク王シャルル3世に忠誠を誓いました。数世代にわたる同化と、フランク人やローマ・ガリア人の地元住民との融合を経て、彼らの子孫は西カロライナを拠点とするカロリング文化に徐々に統合されていきました。ノルマン人の独特の文化と民族的アイデンティティは10世紀前半に初めて現れ、その後数世紀にわたって発展を続けました。
    
    質問:
    ノルマンディーはどの国にありますか?
    """
    
    def get_chat_response(message_content: str) -> str:
        """ChatCompletion APIを使用して質問応答を行う関数"""
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[{"role": "user", "content": message_content}]
        )
        answer_text = response.choices[0].message.content
        return answer_text.strip()
    
    japanese_qa_answer = get_chat_response(japanese_qa_prompt)
    print(japanese_qa_answer)
    
    # 期待される出力例:
    # フランス。
    

    問題なく機能しているようです。それでは、 Completion APIを例にとり、2つのステップを連結してみましょう。

    OpenAIが提供するデータセットとして、2020年東京オリンピックに関するWikipediaデータを使用します。データセットはOpenAIの openai-cookbook GitHubリポジトリの examples/fine-tuned_qa/ から入手できます。ダウンロード後、CSVファイルとして、前章と同様にデータを読み込み、確認します。

    import pandas as pd
    df_olympics = pd.read_csv("./dataset/olympics_sections_text.csv")
    print(f"データセットの形状: {df_olympics.shape}") # 出力例: データセットの形状: (3964, 4)
    print(df_olympics.head())
    

    データは表3-1のようになります。1列目はページタイトル、2列目はページ内のセクションタイトル、3列目はセクションの内容、最後の列はトークン数です。

    title heading content tokens
    0 2020 Summer Olympics Summary The 2020 Summer Olympics (Japanese: 2020年夏季オリン... 726
    1 2020 Summer Olympics Host city selection The International Olympic Committee (IOC) vote... 126
    2 2020 Summer Olympics Impact of the COVID-19 pandemic In January 2020, concerns were raised about th... 374
    3 2020 Summer Olympics Qualifying event cancellation and postponement Concerns about the pandemic began to affect qu... 298
    4 2020 Summer Olympics Effect on doping tests Mandatory doping tests were being severely res... 163

    表3-1 2020東京オリンピックデータセットの例

    ここでは content 列を文書として扱います。基本的な流れは以下の通りです。

    • ステップ1: 各文書の埋め込みベクトルを計算します。
    • ステップ2: 埋め込みベクトル、および内容やその他の必要な情報(例: heading)を保存します。
    • ステップ3: 保存場所から最も関連性の高い文書を検索します。
    • ステップ4: 最も関連性の高い文書に基づいて、与えられた質問に答えます。
    • ステップ1では引き続きOpenAIのEmbedding APIを利用します。get_embedding 関数または直接 Embedding.create インターフェースを使用することで、バッチリクエストをサポートできます。

      from openai.embeddings_utils import get_embedding, cosine_similarity
      
      def get_embeddings_for_texts(input_texts: list):
          """テキストリストの埋め込みベクトルを一括で取得する関数"""
          embedding_model_name = "text-embedding-ada-002"
          response = openai.Embedding.create(
              input=input_texts,
              engine=embedding_model_name
          )
          return [item.embedding for item in response.data]
      
      

      データを準備した後、埋め込みベクトルを一括で取得します。

      all_contents = [row.content for row in df_olympics.itertuples()]
      print(f"全文書数: {len(all_contents)}") # 出力例: 全文書数: 3964
      
      import pnlp # ユーティリティライブラリ
      
      document_embeddings = []
      batch_size = 200 # 一度に処理するバッチサイズ
      
      for batch_idx, text_batch in enumerate(pnlp.generate_batches_by_size(all_contents, batch_size)):
          batch_embeddings = get_embeddings_for_texts(text_batch)
          document_embeddings.extend(batch_embeddings)
          print(f"バッチ {batch_idx+1} 完了")
      
      print(f"取得した埋め込みベクトルの総数: {len(document_embeddings)}")
      print(f"各埋め込みベクトルの次元: {len(document_embeddings[0])}")
      # 出力例: 取得した埋め込みベクトルの総数: 3964
      #         各埋め込みベクトルの次元: 1536
      

      上記 generate_batches_by_size メソッドは、イテラブルオブジェクト(ここではリスト)をバッチサイズ200の複数のバッチに分割します。一度のAPI呼び出しで200個の文書の埋め込み表現を取得できます。

      次に、ステップ2として、インデックスを作成し、ベクトルデータベースに登録します。その前に、クライアントを作成します。

      from qdrant_client import QdrantClient
      
      # Qdrantクライアントの初期化 (Dockerで動作している場合)
      qdrant_client = QdrantClient(host="localhost", port=6333)
      

      Qdrantはメモリ内データベースやファイルベースのライブラリもサポートしており、埋め込みベクトルを直接メモリやディスクに保存することも可能です。

      # client = QdrantClient(":memory:") # メモリ内で動作させる場合
      # または
      # client = QdrantClient(path="path/to/db") # ファイルベースで永続化する場合
      

      インデックスの作成はRedisと似ていますが、Qdrantでは「コレクション (collection)」として扱われます。

      from qdrant_client.models import Distance, VectorParams
      
      # コレクションの再作成 (既存であれば削除し新規作成)
      qdrant_client.recreate_collection(
          collection_name="olympics_qa_docs",
          vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
      )
      
      

      成功すると True が返されます。コレクションを削除するには、以下のコマンドを使用します。

      # qdrant_client.delete_collection("olympics_qa_docs")
      

      以下はベクトルデータベースにデータをアップロードするコードです。

      # 各文書に関連付けたいペイロード(メタデータ)を作成
      document_payloads = [
          {"content": row.content, "heading": row.heading, "title": row.title, "tokens": row.tokens}
          for row in df_olympics.itertuples()
      ]
      
      # コレクションに埋め込みベクトルとペイロードをアップロード
      qdrant_client.upload_collection(
          collection_name="olympics_qa_docs",
          vectors=document_embeddings,
          payload=document_payloads
      )
      
      

      次に、ステップ3として、関連文書を検索します。これはRedisよりもはるかに簡単で、複雑なクエリ文を構築する必要がありません。

      question_query = "2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?"
      
      # 質問の埋め込みベクトルを取得
      query_embedding = get_embedding(question_query, engine="text-embedding-ada-002")
      
      # Qdrantで関連文書を検索
      search_results = qdrant_client.search(
          collection_name="olympics_qa_docs",
          query_vector=query_embedding,
          limit=5 # 上位5件の関連文書を取得
      )
      
      

      最も関連性の高い5つの文書が取得されます。最初の結果の例は以下の通りです。

      ScoredPoint(id=236, version=3, score=0.90316474, payload={'content': '<CONTENT>', 'heading': 'Summary', 'title': "Athletics at the 2020 Summer Olympics – Men's high jump", 'tokens': 275}, vector=None)
      

      スペースの都合上、content は省略されています。payload は以前保存した情報であり、必要なあらゆる情報を格納できます。score は類似度スコアであり、与えられた query とベクトルデータベースに保存された文書との類似度を表します。

      次に、このプロセスとプロンプトの構築を統合します。

      # 公式サンプルを参考に
      
      # コンテキストの最大長
      MAX_CONTEXT_SECTION_LENGTH = 500
      # 複数の文書を検索する際、文書間の区切り文字
      DOCUMENT_SEPARATOR = "\n* "
      SEPARATOR_LENGTH = len(DOCUMENT_SEPARATOR)
      
      def construct_qa_prompt(question_text: str) -> str:
          """質問と検索された文書からQAプロンプトを構築する関数"""
          query_embedding = get_embedding(question_text, engine="text-embedding-ada-002")
          search_results = qdrant_client.search(
              collection_name="olympics_qa_docs",
              query_vector=query_embedding,
              limit=5 # 上位5件の関連文書を取得
          )
      
          selected_context_segments = []
          current_context_length = 0
          source_document_info = [] # どの文書が使用されたかを示す情報
      
          for hit in search_results:
              doc_payload = hit.payload
              # 各文書のトークン数と区切り文字の長さを加算
              segment_length = doc_payload["tokens"] + SEPARATOR_LENGTH
              if current_context_length + segment_length > MAX_CONTEXT_SECTION_LENGTH:
                  break # 最大長を超える場合は追加しない
      
              selected_context_segments.append(DOCUMENT_SEPARATOR + doc_payload["content"].replace("\n", " "))
              source_document_info.append(doc_payload["title"] + " - " + doc_payload["heading"])
              current_context_length += segment_length
      
          # シンプルなログ出力
          print(f"選択された文書セクション数: {len(selected_context_segments)}")
          print("使用された文書情報:")
          print("\n".join(source_document_info))
      
          # プロンプトのヘッダー部分
          prompt_header = """提供されたコンテキストを使用して、質問に可能な限り正確に答えてください。もし答えが以下のテキストに含まれていない場合は、「わかりません」と答えてください。\n\nコンテキスト:\n"""
      
          # 構築されたプロンプトを返す
          return prompt_header + "".join(selected_context_segments) + "\n\n Q: " + question_text + "\n A:"
      
      

      例を使って試してみましょう。

      qa_test_query = "2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?"
      final_qa_prompt = construct_qa_prompt(qa_test_query)
      
      print("===\n", final_qa_prompt)
      # 出力例 (一部省略):
      # 選択された文書セクション数: 2
      # 使用された文書情報:
      # Athletics at the 2020 Summer Olympics – Men's high jump - Summary
      # Athletics at the 2020 Summer Olympics – Men's long jump - Summary
      # ===
      # 提供されたコンテキストを使用して、質問に可能な限り正確に答えてください。もし答えが以下のテキストに含まれていない場合は、「わかりません」と答えてください。
      #
      # コンテキスト:
      #
      # * <CONTENT 1>
      # * <CONTENT 2>
      #
      #  Q: 2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?
      #  A:
      

      上記の結果の通りです(スペースの都合上、一部内容は省略しています)。見つかった5つの関連文書のうち、長さ制限(500トークン)のため、ここでは最初の2つのみが使用されました。

      プロンプトを構築したら、最後のステップとして、与えられた文書に基づいて質問に答えます。

      # get_completion_response 関数は以前定義済み
      
      completion_qa_answer = get_completion_response(final_qa_prompt)
      print(f"Q: {qa_test_query}\nA: {completion_qa_answer}")
      
      # 期待される出力例:
      # Q: 2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?
      # A: ジャンマルコ・タンベリとムタズ・エッサ・バルシムが、両者2.37mをクリアして同点となり、共同でイベントの優勝者となりました。両選手は金メダルを共有することに合意しました。
      

      次に ChatCompletion APIを試してみましょう。

      # get_chat_response 関数は以前定義済み
      
      chat_qa_answer = get_chat_response(final_qa_prompt)
      print(f"Q: {qa_test_query}\nA: {chat_qa_answer}")
      
      # 期待される出力例:
      # Q: 2020年夏季オリンピック男子走り高跳びで優勝したのは誰ですか?
      # A: 2020年夏季オリンピック男子走り高跳びイベントでは、ジャンマルコ・タンベリとムタズ・エッサ・バルシムが金メダルを共有しました。
      

      ご覧の通り、両方のAPIが質問に正確に回答しています。さらにいくつかの例を見てみましょう。

      another_query = "2020年夏季オリンピックで最も多くのメダルを獲得した国は、何個の金メダルを獲得しましたか?"
      another_qa_prompt = construct_qa_prompt(another_query)
      
      completion_answer = get_completion_response(another_qa_prompt)
      print(f"\nQ: {another_query}\nA: {completion_answer}")
      # 出力例:
      # 選択された文書セクション数: 2
      # 使用された文書情報:
      # 2020 Summer Olympics medal table - Summary
      # List of 2020 Summer Olympics medal winners - Summary
      #
      # Q: 2020年夏季オリンピックで最も多くのメダルを獲得した国は、何個の金メダルを獲得しましたか?
      # A: アメリカ合衆国が全体で113個のメダルを最も多く獲得し、金メダルは39個でした。
      
      chat_answer = get_chat_response(another_qa_prompt)
      print(f"\nQ: {another_query}\nA: {chat_answer}")
      # 出力例:
      # Q: 2020年夏季オリンピックで最も多くのメダルを獲得した国は、何個の金メダルを獲得しましたか?
      # A: 2020年夏季オリンピックで最も多くのメダルを獲得した国はアメリカ合衆国で、113個のメダルのうち39個が金メダルでした。
      

      上記の質問は「2020年夏季オリンピックで最も多くのメダルを獲得した国は、何個の金メダルを獲得しましたか?」です。2つのAPIそれぞれで回答が提供され、結果はほぼ同じですが、後者の方がより具体的です。

      irrelevant_query = "世界で最も高い山は何ですか?"
      irrelevant_qa_prompt = construct_qa_prompt(irrelevant_query)
      
      completion_answer_irrelevant = get_completion_response(irrelevant_qa_prompt)
      print(f"\nQ: {irrelevant_query}\nA: {completion_answer_irrelevant}")
      # 出力例:
      # 選択された文書セクション数: 3
      # 使用された文書情報:
      # Sport climbing at the 2020 Summer Olympics – Men's combined - Route-setting
      # Ski mountaineering at the 2020 Winter Youth Olympics – Boys' individual - Summary
      # Ski mountaineering at the 2020 Winter Youth Olympics – Girls' individual - Summary
      #
      # Q: 世界で最も高い山は何ですか?
      # A: わかりません。
      
      chat_answer_irrelevant = get_chat_response(irrelevant_qa_prompt)
      print(f"\nQ: {irrelevant_query}\nA: {chat_answer_irrelevant}")
      # 出力例:
      # Q: 世界で最も高い山は何ですか?
      # A: わかりません。
      

      上記の質問は「世界で最も高い山は何ですか?」です。この質問でも3つの文書が検索されますが、それらには答えが含まれていません。両方のAPIは、事前に設定した要求通りに適切に回答しています。

      ドキュメントQAは、大規模言語モデルの強力な理解能力を最大限に活用できる、非常に適したアプリケーションです。また、各質問に関連する文書が根拠となるため、モデルが不正確な情報を生成する可能性を最小限に抑えられます。筆者の実験では、このような利用方法であれば、ゼロショット学習やファインチューニングを行わない場合でも良好な結果が得られました。同様のシナリオを抱える読者は、このアプローチを試してみることを強くお勧めします。

      モデルのファインチューニング:個別ニーズへの対応

      これまで、様々な分類タスクや固有表現抽出の利用方法について説明してきました。本節では、独自のデータに対するモデルのファインチューニング方法を、主題分類タスクを例にとって紹介します。主題分類とは、簡単に言えば、与えられたテキストがどの主題に属するかを判断するタスクです。

      ここでは、中国のニュース分類データセット「今日頭条中文新聞分類データセット」を使用します。このデータセットには、テクノロジー、金融、エンターテイメント、世界、自動車、スポーツ、文化、軍事、旅行、ゲーム、教育、農業、不動産、社会、株式の15種類のカテゴリが含まれています。

      import pnlp # ユーティリティライブラリ
      
      news_data = pnlp.read_file_to_list_dict("./dataset/tnews.json")
      print(f"データセットの行数: {len(news_data)}") # 出力例: データセットの行数: 10000
      

      まずデータセットを読み込みます。あるサンプルデータの例は以下の通りです。

      print(news_data[59])
      # 出力例:
      # {'label': '101', 'label_desc': 'news_culture', 'sentence': '上聯:銀笛吹開雲天月,下聯怎麼對?', 'keywords': ''}
      

      ここで、label はラベルID、label_desc はラベルの説明、sentence は文のテキスト、keywords はキーワードです(上記のように空の場合もあります)。まず、ラベルの分布を見てみましょう。

      from collections import Counter
      
      label_description_counts = Counter([item["label_desc"] for item in news_data])
      print(label_description_counts.most_common())
      # 出力例:
      # [('news_tech', 1089), ('news_finance', 956), ('news_entertainment', 910), ('news_world', 905), ('news_car', 791), ('news_sports', 767), ('news_culture', 736), ('news_military', 716), ('news_travel', 693), ('news_game', 659), ('news_edu', 646), ('news_agriculture', 494), ('news_house', 378), ('news_story', 215), ('news_stock', 45)]
      

      統計結果を見ると、「stock」カテゴリのデータ量が少ないことがわかります。実際、実際のシナリオでは、ほとんどの場合、各ラベルのデータは不均一です。もしデータ量が少ないタイプが注目すべきカテゴリである場合は、データをさらに増やすべきです。そうでなければ、特別な処理は不要です。

      上記のAPIを使用してタスクを完了させてみましょう。まず、プロンプトを構築します。

      def create_classification_prompt(text_input: str) -> str:
          """テキスト分類のためのプロンプトを生成する関数"""
          prompt_template = f"""与えられたテキストを以下のカテゴリに分類してください:テクノロジー、金融、エンターテイメント、世界、自動車、スポーツ、文化、軍事、旅行、ゲーム、教育、農業、不動産、社会、株式。
      
      与えられたテキスト:
      {text_input}
      カテゴリ:
      """
          return prompt_template
      
      sample_prompt = create_classification_prompt(news_data[0]["sentence"])
      print(sample_prompt)
      # 出力例:
      # 与えられたテキストを以下のカテゴリに分類してください:テクノロジー、金融、エンターテイメント、世界、自動車、スポーツ、文化、軍事、旅行、ゲーム、教育、農業、不動産、社会、株式。
      #
      # 与えられたテキスト:
      # 上聯:銀笛吹開雲天月,下聯怎麼對?
      # カテゴリ:
      

      このプロンプトは、sentence を与えられたテキストとして扱い、モデルに対応するカテゴリを出力するよう要求しています。これらのカテゴリはモデルに提供する必要があります。そして、APIを呼び出してタスクを完了させます。

      import openai
      import os
      
      OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
      openai.api_key = OPENAI_API_KEY
      
      def get_completion_classification(prompt_text: str) -> str:
          """Completion APIで分類を行うヘルパー関数"""
          response = openai.Completion.create(
              prompt=prompt_text,
              temperature=0,
              max_tokens=10,
              top_p=1,
              frequency_penalty=0,
              presence_penalty=0,
              model="text-davinci-003"
          )
          result = response["choices"][0]["text"].strip(" \n")
          return result
      
      def get_chat_classification(message_content: str) -> str:
          """ChatCompletion APIで分類を行うヘルパー関数"""
          response = openai.ChatCompletion.create(
              model="gpt-3.5-turbo",
              messages=[{"role": "user", "content": message_content}],
              temperature=0,
              max_tokens=10,
              top_p=1,
              frequency_penalty=0,
              presence_penalty=0,
          )
          result = response.choices[0].message.content
          return result.strip()
      
      completion_classification_result = get_completion_classification(sample_prompt)
      print(f"Completion APIの結果: {completion_classification_result}")
      # 出力例: Completion APIの結果: 文化
      
      chat_classification_result = get_chat_classification(sample_prompt)
      print(f"ChatCompletion APIの結果: {chat_classification_result}")
      # 出力例: ChatCompletion APIの結果: 文化
      

      ご覧の通り、両方のAPIが与えられたタスクを適切に完了しています。次に、認識があまり理想的ではない例を見てみましょう。データは以下の通りです。

      print(news_data[2])
      # 出力例:
      # {'label': '104', 'label_desc': 'news_finance', 'sentence': '出栏一头猪亏损300元,究竟谁能笑到最后!', 'keywords': '商品猪,养猪,猪价,仔猪,饲料'}
      
      unclear_prompt = create_classification_prompt(news_data[2]["sentence"])
      
      completion_unclear_result = get_completion_classification(unclear_prompt)
      print(f"Completion APIの結果: {completion_unclear_result}") # 出力例: Completion APIの結果: 社会
      
      chat_unclear_result = get_chat_classification(unclear_prompt)
      print(f"ChatCompletion APIの結果: {chat_unclear_result}") # 出力例: ChatCompletion APIの結果: 農業
      

      少し意見が分かれています。この文を人間が分析すると、「農業」カテゴリの方がより適切に見えるかもしれません。しかし、残念ながらデータが与えるラベルは「金融」です。このような状況は実際のシナリオでもよく見られます。一般的には、以下の手段で解決できます。

      • フューショット学習: 毎回、訓練データセットからいくつかのサンプル(文とラベルを含む)をランダムに抽出し、プロンプトの一部として与えます。
      • ファインチューニング: 独自のデータセットを指定された形式で準備し、ファインチューニングAPIに送信して、サーバーが自動的にファインチューニングを完了させます。ファインチューニングが完了すると、新しいモデルIDが生成されます。

      フューショット学習で最も重要なのは、どのように「サンプル」を見つけるか、つまり、どの例をモデルの参考サンプルとして提供するかです。カテゴリラベルが非常に多い場合(実際の業務シナリオでは、数百、数千のラベルが一般的です)、各ラベルに1つの例を与えたとしても、コンテキスト長は受け入れがたいものになります。この場合、フューショット学習は少し不便になります。もちろん、どうしても使用したいのであれば不可能ではありません。最も一般的な戦略は、まずいくつかの類似する文を検索し、その類似する文の内容とラベルをフューショットの例として与え、APIに与えられた文のカテゴリを予測させることです。しかし、この方法はQA方式とほとんど同じになります。

      このような場合、より良い方法は、独自のデータセットでモデルをファインチューニングすることです。簡単に言えば、モデルに独自のデータを「学習」させ、それによって類似のデータに対して正しいラベルを識別する能力を習得させることです。

      それでは、具体的にどのように行うかを見ていきましょう。一般的に、主要な3つのステップがあります。

      • ステップ1: データの準備。 APIが要求する形式でデータを準備します。ここでのデータは、少なくとも1つのテキストと1つのカテゴリを含む独自のデータセットです。
      • ステップ2: ファインチューニング。 処理済みのデータをファインチューニングAPIに渡し、サーバーが自動的にファインチューニングを完了させます。ファインチューニングが完了すると、新しいモデルIDが取得されます。このモデルIDはあなた自身のものであり、他人に公開しないように注意してください。
      • ステップ3: 新しいモデルでの推論。 これは非常に簡単で、元のAPIの model パラメータの内容を、先ほど取得したモデルIDに置き換えるだけです。

      本書では、API経由でのファインチューニングのみを扱います。それでは、この主題多分類モデルのファインチューニングを行いましょう。結果を迅速に検証するため、データセットの最後の500件のデータのみを訓練セットとして使用します。

      import pandas as pd
      
      training_news_entries = news_data[-500:]
      training_data_df = pd.DataFrame(training_news_entries)
      print(f"訓練データの形状: {training_data_df.shape}") # 出力例: 訓練データの形状: (500, 4)
      print(training_data_df.head(3)) # 最初の3行のみ表示
      

      データサンプルは表3-2のようになります。各列の意味は既に説明済みなので、ここでは繰り返しません。キーワードはラベルに似ていますが、必ずしも原文中に現れるとは限らない点に注意してください。

      label label\_desc sentence keywords
      0 103 news\_sports なぜスケッチャーズとアディダスの履き心地は似ているのに、価格は約2倍も違うのか? ダッスラー,アディダス,FOAM,BOOST,スケッチャーズ
      1 100 news\_story 娘が日に日に痩せ細り、両親は化け物がいることに気づいた。毎日娘を食い荒らす 大将軍,化け物
      2 104 news\_finance 異例の踏み上げで反発確定、3200ポイント以上を目指す 株式,異例の踏み上げ,金融,創業板,急速拡大
      3 100 news\_story 夫がパーティーで私に彼の上司に酒を勧めさせ、今夫は泣き、私は笑った news\_story
      4 106 news\_edu 女の子が中学校に入ってから成績が下がった。どうすれば成績を上げられるか? news\_edu

      表3-2 主題分類ファインチューニングデータセットの例

      各カテゴリの出現頻度を統計します。

      print(training_data_df.label_desc.value_counts())
      # 出力例:
      # news_finance          48
      # news_tech             47
      # news_game             46
      # news_entertainment    46
      # news_travel           44
      # news_sports           42
      # news_military         40
      # news_world            38
      # news_car              36
      # news_culture          35
      # news_edu              27
      # news_agriculture      20
      # news_house            19
      # news_story            12
      # Name: label_desc, dtype: int64
      

      実際の実行では、株式(stock)データの量が少なすぎるため、このカテゴリは除外しましたが、全体のプロセスには影響しません。

      まず、ステップ1として、データを準備します。データは promptcompletion の2つの列を持つ必要があります。もちろん、サービスプロバイダーによってAPIの要件は異なる場合がありますが、ここではOpenAIのAPIを例に説明します。

      fine_tuning_data_df = training_data_df[["sentence", "label_desc"]]
      fine_tuning_data_df.columns = ["prompt", "completion"] # 列名を変更
      print(fine_tuning_data_df.head())
      

      構築された訓練データサンプルは表3-3のようになります。

      prompt completion
      0 なぜスケッチャーズとアディダスの履き心地は似ているのに、価格は約2倍も違うのか? news\_sports
      1 娘が日に日に痩せ細り、両親は化け物がいることに気づいた。毎日娘を食い荒らす news\_story
      2 異例の踏み上げで反発確定、3200ポイント以上を目指す news\_finance
      3 夫がパーティーで私に彼の上司に酒を勧めさせ、今夫は泣き、私は笑った news\_story
      4 女の子が中学校に入ってから成績が下がった。どうすれば成績を上げられるか? news\_edu

      表3-3 主題分類ファインチューニング訓練データ例

      データをローカルに保存し、OpenAIが提供するコマンドラインツールを使用して、要求される形式に変換します。

      fine_tuning_data_df.to_json("dataset/tnews-finetuning.jsonl", orient="records", lines=True)
      
      # OpenAIツールでデータを準備
      # -f: 入力ファイル, -q: 確認プロンプトを表示しない
      !openai tools fine_tunes.prepare_data -f dataset/tnews-finetuning.jsonl -q
      

      変換されたデータサンプルは以下のようになります。

      !head dataset/tnews-finetuning_prepared_train.jsonl
      
      # 出力例:
      # {"prompt":"cf生存特訓:ロケット弾で狂暴な復讐、兄弟のために死ぬまで戦う ->","completion":" game"}
      # {"prompt":"ハルビン 東北抗日連合博物館 ->","completion":" culture"}
      # {"prompt":"中国株式市場で、なぜ仕手株はこんなに横行するのか?一文で真相を教える ->","completion":" finance"}
      # {"prompt":"天府錦繍が再び訪れる ->","completion":" agriculture"}
      # {"prompt":"生活、ゲーム、映画の中で、少し変更するだけで非常に威厳のある名前になる言葉は? ->","completion":" game"}
      # {"prompt":"法廷で実父が子供の親権を争い、少年の一言で実父は言葉を失った ->","completion":" entertainment"}
      # {"prompt":"良い深センビッグデータ研修機関をどうやって選ぶか? ->","completion":" edu"}
      # {"prompt":"芸能界にいる追っかけスターは誰? ->","completion":" entertainment"}
      # {"prompt":"東坞原生野生茶 ->","completion":" culture"}
      # {"prompt":"ACL: 広州恒大の不振は予見されていた、全北現代の敗北は運命づけられていた ->","completion":" sports"}
      

      変換後、最も顕著なのは、各 prompt の末尾に -> マーカーが追加されている点です。その他にも、いくつかの調整が行われています。

      • 小文字化: すべてのテキストを小文字にする(主に英語に適用され、中国語にはこの概念はありません)。
      • ラベルの news_ 接頭辞削除: completion フィールドの値を見ると、接頭辞が削除されており、処理後の結果は意味のある単語になっています。これがより合理的です。
      • completion フィールド値の前にスペース追加: 接頭辞を削除するだけでなく、余分なスペースも追加されています。これも英語特有の処理です(英語では単語をスペースで区切ります)。
      • 訓練セットと検証セットへの分割: 訓練セットはモデルのファインチューニングに使用され、検証セットはモデルのパフォーマンス評価とハイパーパラメータ調整に使用されます。

      これらの調整は、対応するログ出力として表示されるため、変換時の出力ログを注意深く読むことをお勧めします。また、これらは一般的な推奨される前処理手法でもあります。

      データが準備できたら、ステップ2のファインチューニングです。APIを使用したファインチューニングは非常に簡単で、通常は1行のコマンドで完了するか、あるいはウェブページ上でマウスをクリックするだけでできます。

      import openai
      import os
      
      OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
      openai.api_key = OPENAI_API_KEY
      
      # ファインチューニングジョブの作成
      # -t: 訓練データ, -v: 検証データ
      # --compute_classification_metrics: 分類メトリクスを計算
      # --classification_n_classes: 分類クラス数 (ここでは14クラス)
      # -m: ベースモデル (ここではdavinci)
      # --no_check_if_files_exist: ファイルの存在チェックをスキップ (既にアップロード済みの場合など)
      !openai api fine_tunes.create \
          -t "./dataset/tnews-finetuning_prepared_train.jsonl" \
          -v "./dataset/tnews-finetuning_prepared_valid.jsonl" \
          --compute_classification_metrics --classification_n_classes 14 \
          -m davinci \
          --no_check_if_files_exist
      

      ここで、-t-v はそれぞれ訓練セットと検証セットを指定します。次の行はメトリクスを計算するために使用されます。-m はファインチューニングするモデルを指定し、ファインチューニング可能なモデルと料金は公式ドキュメントで確認できます。最後の行はファイルが存在するかどうかを確認するもので、以前ファイルをアップロードしている場合は再利用できます。

      なお、現在ファインチューニングできるのは Completion APIのみであり、ChatCompletion はファインチューニングをサポートしていません。コマンド実行後、ジョブIDが取得されます。次に、別のAPIとジョブIDを使用して、タスクのリアルタイムステータスを取得できます。

      # ファインチューニングジョブのステータスを取得
      # -i: ジョブID
      !openai api fine_tunes.get -i ft-QOkrWkHU0aleR6f5IQw1UpVL # ジョブIDは実際のものを置き換えてください
      

      または、以下のAPIを使用してデータストリームを再開できます。

      # ファインチューニングジョブのログをフォロー
      !openai api fine_tunes.follow -i ft-QOkrWkHU0aleR6f5IQw1UpVL # ジョブIDは実際のものを置き換えてください
      

      followget は異なる用途で使われます。openai api --help でサポートされる他のコマンドも確認できます。

      しばらく待ってから get APIで進行状況を確認することをお勧めします。follow APIを継続的に呼び出してデータストリームを取得する必要はありません。キューイングが完了してトレーニング段階に入ると、すぐに進行します。進行状況を確認する際は、主に status の状態を確認します。ファインチューニングが完了すると、新しいモデルIDが取得されます。これが今回調整したモデルです。また、以下のコマンドで今回のファインチューニングの各メトリクスも確認できます。

      # ファインチューニング結果をCSVファイルに保存
      # -i: ファインチューニングジョブID
      !openai api fine_tunes.results -i ft-QOkrWkHU0aleR6f5IQw1UpVL > metric.csv
      
      # 結果を読み込み、精度がNullでない最後の行を表示
      fine_tuning_metrics = pd.read_csv('metric.csv')
      print(fine_tuning_metrics[fine_tuning_metrics['classification/accuracy'].notnull()].tail(1))
      

      ここでは、主にトレーニング後の損失や精度などが出力されます。精度をグラフにプロットすると、図3-1のようになります。

      import matplotlib.pyplot as plt
      
      # 精度データのみを抽出
      accuracy_steps = fine_tuning_metrics[fine_tuning_metrics['classification/accuracy'].notnull()]
      step_values = accuracy_steps.index
      accuracy_values = accuracy_steps['classification/accuracy']
      
      # グラフの描画
      fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10 ,6))
      ax.plot(step_values, accuracy_values, "k-", lw=1, alpha=1.0)
      ax.set_xlabel("ステップ数")
      ax.set_ylabel("精度");
      plt.title("ファインチューニング精度の推移")
      plt.grid(True)
      plt.show()
      
      ファインチューニング精度
      図3-1 ファインチューニング精度

      この精度は非常に一般的なもので、最高値は約1200ステップで約64%の精度に達しています。これは、提供したコーパスが少なすぎることが原因と考えられます。実際には、データ量が多く、データの品質が良いほど、対応する効果も高くなります。

      ステップ3として、新しいモデルで推論を行います。引き続き、先ほどの例を使ってデモンストレーションします。

      print(news_data[2])
      # 出力例:
      # {'label': '104', 'label_desc': 'news_finance', 'sentence': '出栏一头猪亏损300元,究竟谁能笑到最后!', 'keywords': '商品猪,养猪,猪价,仔猪,饲料'}
      
      test_prompt_for_finetune = create_classification_prompt(news_data[2]["sentence"])
      print(test_prompt_for_finetune)
      # 出力例:
      # 対给定文本进行分类,类别包括:科技、金融、娱乐、世界、汽车、运动、文化、军事、旅游、游戏、教育、農業、房产、社会、股票。
      #
      # 给定文本:
      # 出栏一头猪亏损300元,究竟谁能笑到最后!
      # 类别:
      

      APIを呼び出すコードを少し調整し、model パラメータを追加します。

      def get_completion_with_model(prompt_text: str, model_id: str, max_tokens_to_generate: int) -> str:
          """指定されたモデルIDでCompletion APIを呼び出す関数"""
          response = openai.Completion.create(
              prompt=prompt_text,
              temperature=0,
              max_tokens=max_tokens_to_generate,
              top_p=1,
              frequency_penalty=0,
              presence_penalty=0,
              model=model_id # モデルIDを動的に指定
          )
          result = response["choices"][0]["text"].strip(" \n")
          return result
      
      

      ファインチューニング前のモデルを呼び出すのは以前と同じですが、ファインチューニング後のモデルを呼び出す際は、プロンプトを修正する必要がある点に注意してください。

      # ファインチューニング前のモデル (text-davinci-003) を使用
      base_model_prediction = get_completion_with_model(test_prompt_for_finetune, "text-davinci-003", 5)
      print(f"ベースモデルの予測: {base_model_prediction}") # 出力例: ベースモデルの予測: 社会
      
      # ファインチューニング後のモデルを使用
      # ファインチューニングモデルは「prompt -> completion」形式を学習するため、プロンプトもその形式に合わせる
      finetuned_prompt_input = news_data[2]["sentence"] + " ->"
      finetuned_model_id = "davinci:ft-personal-2023-04-04-14-51-29" # ご自身のモデルIDに置き換えてください
      
      finetuned_prediction = get_completion_with_model(finetuned_prompt_input, finetuned_model_id, 1)
      print(f"ファインチューニングモデルの予測: {finetuned_prediction}") # 出力例: ファインチューニングモデルの予測: agriculture
      

      ファインチューニング後のモデルは英語の単語を返しましたが、これは正常です。なぜなら、ファインチューニングデータの completion が英語だったからです。ここではファインチューニングの効果を実演するために、このままにしていますが、読者の皆さんが実際に使用する際は、必ず統一するようにしてください。しかし、この結果もアノテーションされた「finance」ではありません。この文自体が「agriculture」というカテゴリの訓練テキストにより近いことが原因と考えられます。このような比較的特殊なサンプルについては、モデルに一定量の類似する訓練サンプルを提供することが不可欠です。

      ここまで、主題分類のファインチューニングについて説明しました。固有表現抽出のファインチューニングも同様で、推奨される入力形式は以下の通りです。

       {
          "prompt":"<任意のテキスト、例えばニュース記事>\n\n###\n\n",
          "completion":" <改行で区切られたエンティティのリスト> END"
      }
      

      例えば、以下のようになります。

       {
          "prompt":"Portugal will be removed from the UK's green travel list from Tuesday, amid rising coronavirus cases and concern over a \"Nepal mutation of the so-called Indian variant\". It will join the amber list, meaning holidaymakers should not visit and returnees must isolate for 10 days...\n\n###\n\n",
          "completion":" Portugal\nUK\nNepal mutation\nIndian variant END"
      }
      

      読者の皆さんも容易に理解できるはずです。専門分野のエンティティに対してファインチューニングを行い、ファインチューニング前後の効果を比較してみることをお勧めします。

      スマート対話:大規模言語モデルによる自律制御ロボット

      スマート対話は、インテリジェントカスタマーサービス、対話ロボット、チャットボットなどとも呼ばれ、ユーザーとチャット形式でインタラクションする技術全般を指します。従来の対話ロボットは、一般的に3つの主要モジュールで構成されていました。

      • 自然言語理解モジュール (NLU): ユーザー入力を理解する役割を担います。本章の冒頭で言及した通り、主に意図分類と固有表現認識の2つの技術が中心です。実際には、固有表現関係抽出や感情認識などのコンポーネントも含まれる場合があります。
      • 対話管理モジュール (DM): NLUの結果を受けて、ロボットの返答内容を決定する役割を担います。これは対話の流れを制御する部分です。
      • 応答生成モジュール (NLG): 最終的にユーザーに返答する出力テキストを生成する役割を担います。

      対話ロボットは一般的に3つの種類に分類され、それぞれの技術的アプローチが異なります。

      • タスク指向型ロボット: 特定のタスク(航空券予約、レストラン予約など)を完了することを主な目的とします。このタイプのロボットで最も重要なのは、タスク完了に必要な各種情報(専門用語で「スロット」と呼びます)を収集することです。対話プロセス全体は、スロットを埋めるプロセスと見なすことができ、ユーザーとの継続的な対話を通じて必要なスロット情報を取得します。例えば、レストラン予約タスクでは、食事人数、食事時間、連絡先電話番号などが基本情報であり、ロボットはこれらの情報を取得する方法を見つける必要があります。ここではNLUが重要となり、DMは通常、モデル制御またはフローチャート制御の2つの方法を使用します。前者はモデルが自動的にフローを学習して遷移し、後者は意図の種類に基づいてフローを制御します。
      • 質問応答型ロボット: 主にユーザーの質問に答えることを目的とし、前章で紹介したQAに似ています。私たちが日常的に目にするカスタマーサービスロボットの多くはこのタイプです。これらは問題マッチングがより重要であり、DMは比較的弱い傾向があります。
      • 雑談型ロボット: 雑談ロボットは一般的に実用的な目的を持ちません。ただし、感情的なサポートを提供するロボットも存在しますが、本書の議論の範囲外とします。

      上記が大まかな分類ですが、実際のシナリオにおける対話ロボットは、多くの場合、複数の機能の組み合わせです。より適切な分類は、能動的発話と受動的応答の観点から行うことができます。

      • 能動的に対話を開始するロボット: 通常、アウトバウンドコール形式で運用され、マーケティング、督促、通知などが一般的なシナリオです。このタイプの対話ロボットは雑談をせず、電話料金の関係上、特定のタスクや目的を持ってプロセスを進めます。プロセスが完了すると通話を終了します。ユーザーとのインタラクションはQA形式で完結することが多く、ロボットが主導権を握っているため、プロセスは通常固定的に制御され、QAの数や応答回数も制限されます。
      • 受動的に対話を受け入れるロボット: 通常、ウェブページやクライアントアプリケーションの形式で存在し、ほとんどの場合、ユーザーがアクセスしてくる状況です。多くの企業のウェブサイトやアプリケーションのトップページにある「スマートカスタマーサービス」がこれに該当します。これらはQAを主体とし、雑談を補完します。より複雑なのは、前述のタスク指向型ロボットで、スロット情報を継続的に収集する必要があります。

      大規模言語モデルの時代において、スマート対話ロボットにはどのような新しい変化があるでしょうか?次に、この側面について検討します。

      まず、ChatGPTのような大規模言語モデルは、対話ロボットの境界を大きく広げたことは間違いありません。大規模言語モデルの強力なIn-Context学習能力は、使用をより簡単にするだけでなく(履歴対話を役割ごとに渡すだけでよいため)、効果も向上させます。雑談だけでなく、質問応答型ロボットやタスク指向型ロボットも得意とし、より人間らしいインタラクションが可能です。

      具体的に何ができるか、そしてどのように行うかについていくつか例を挙げましょう。

      • 質問応答製品として: 知識Q&A、感情相談、心理カウンセリングなど、あらゆる「困ったときのChatGPT」と呼べるでしょう。例えば、プログラミングの概念、意中の異性を振り向かせる方法、不安を避ける方法などを尋ねることができます。そのほとんどの回答は驚くほど優れています。
      • スマートカスタマーサービスとして: 企業の知識ベースと連携することで、カスタマーサービス業務を遂行できます。これまでのQA型カスタマーサービスと比較して、よりパーソナライズされた回答が可能で、効果も向上します。
      • スマートマーケティングロボットとして: スマートカスタマーサービスが受動的でユーザーの疑問を解決する方向性であるのに対し、マーケティングロボットはより能動的です。保存されているユーザー情報に基づいて関連製品を積極的にユーザーに推奨し、設定された目標に基づいてユーザーに対話を開始します。同時に顧客関係の維持も担当できます。
      • ゲーム中の非プレイヤーキャラクター (NPC)、チャット相手ロボットなどのレジャーエンターテイメント製品として。
      • 教育・研修のメンターとして: 1対1の指導が可能で、特に言語学習やプログラミング学習に適しています。

      これらはすべて実現可能であり、市場には関連するアプリケーションが多数存在します。なぜ大規模言語モデルがこれらを可能にするのでしょうか?結局のところ、それは大規模なパラメータによって学習された知識と理解力に帰着します。特に強力な理解力は決定的な要因であるはずです(知識だけならGoogle検索エンジンと同じです)。

      もちろん、何でもかんでもChatGPTに頼る必要はありません。「手にハンマーがあるからといって、どこでも釘を探す」ような思考は避けるべきです。ある哲学者が言ったように、新しい技術の出現は、短期的には常に過大評価され、長期的には常に過小評価されます。ChatGPTが牽引する大規模言語モデルは画期的なものですが、何でもChatGPTで解決できるわけではありません。例えば、一部の分類タスクや固有表現抽出タスクでは、以前の手法でも非常に優れた効果を達成できています。このような場合、あえて置き換える必要はありません。多くの実用的なタスクは、技術の発展によって大きく変わるわけではないことを私たちは知っています。例えば、分類タスクが新しい技術の登場で分類タスクでなくなるわけではありません。技術の更新は私たちの効率を向上させます。つまり、同じタスクをより簡単かつ効率的に実行できるようになり、より困難なタスクに取り組めるようになりますが、タスク自体が変わるわけではありません。したがって、これらの本質を明確にし、手段と目的の違いを理解することが重要です。

      しかし、新しいサービスを開発する場合や、この分野の専門知識がない場合は、大規模言語モデルAPIを使用する方が良い戦略である可能性があります。ただし、実際に運用を開始する前に、サービスの利用不可時の対応、おおよその同時接続数、応答時間要件、ユーザー規模などの詳細を明確に考慮する必要があります。技術ソリューションの選択は、会社や個人のニーズと密接に関連しており、絶対的に良いソリューションはなく、その時に最適なソリューションがあるだけです。

      同時に、可能な限り多くのステップを考慮すべきですが、多すぎてもいけません(過剰な最適化は罪です)。例えば、日次アクティブユーザーが数百未満の場合に、いきなり分散設計ソリューションを検討するのは適切ではありません。ただし、これはコードやアーキテクチャ設計で拡張性を考慮することを妨げるものではありません。例えばデータベースでは、SQLiteを使用するかもしれませんが、コードではそれに直接結合させず、他のデータベースや分散データベースもサポートできるORMツールを使用します。これにより、記述はわずかに手間がかかるかもしれませんが、コードはより明確になり、変更される可能性のあるものと疎結合になります。そうすれば、将来的に規模が大きくなっても、データベースを自由に交換でき、コードをほとんど変更する必要がありません。

      最後に、ChatGPTのいくつかの限界も理解しておくべきです(これについては後続の章で詳しく説明します)。エンジニアリングの観点からは、少なくとも以下の点には常に注意を払うべきです。応答時間と安定性、並行処理と水平スケーラビリティ、保守性とイテレーション、コスト。これらすべてが私たちの期待を満たす場合にのみ、そのソリューションを選択すべきです。

      それでは、ChatGPTを使用して簡単なタスク指向型対話ロボットを実装してみましょう。設計段階では、いくつかの要素を考慮する必要があります。

      • 使用目的: まず、使用目的を明確にする必要があります。上述の通り、用途によって考慮すべき要素は異なります。シンプルに(しかし非常に実用的に)、「レストラン予約ロボット」を例にとります。機能は簡単な挨拶から始まり、ユーザーの連絡先、予約人数、食事時間の3つの情報を取得します。
      • 使用方法: 使用方法は比較的シンプルで、主にChatGPTの多ターン対話能力を利用します。ここでのポイントはコンテキストの制御です。しかし、タスクが単純なため、履歴記録を検索して対話を進めるのではなく、各ターンで既に取得した情報をモデルに伝え、同時に他の情報の取得を継続させ、すべての情報が取得されるまで続けます。また、出力トークンの数を制限することも可能です(出力テキストの長さを制御)。
      • メッセージのクエリと保存: ユーザーのメッセージ(およびロボットの返信)は、実際のところ保存される必要があり、各ターンの返信時に履歴メッセージを検索するために使用されます。さらに、将来的には他の用途にも使用される可能性があります(例: 対話記録を使用してユーザープロファイルを作成したり、訓練データとして利用したり)。保存は直接データベースに、またはElasticSearchのような内部検索エンジンに転送できます。
      • メッセージ解析: メッセージの解析はリアルタイム(必ずしもChatGPTを使用する必要はありません)またはオフラインで行うことができます。このケースでは、リアルタイムオンライン解析が必要です。このプロセスは、ChatGPTが返信を生成する際に同時に実行させることができます。
      • リアルタイム介入: リアルタイム介入は考慮すべき点であり、そのようなモジュールを設計する必要があります。一方では、制限を設けても、不適切な質問によって予期せぬ返答が生成される可能性があります。他方では、悪意のあるユーザーがロボットを攻撃する可能性も排除できません。したがって、介入メカニズムを設計することが最善です。ここでは、簡単な戦略を設計します。ユーザーが機密性の高い質問をしているかどうかを検出し、そのような質問が発見された場合は、ChatGPTを呼び出して対話応答を生成する代わりに、事前に設定されたテキストを直接返します。
      • 更新戦略: 更新戦略は主に企業知識ベースの更新に関するものです。ここではIn-Context学習能力を使用しているため、ChatGPTを調整する必要はなく、Embedding APIを調整する必要があるかもしれません。このケースでは現在扱っていません。

      上記をまとめると、まずユーザー入力を機密性チェックし、問題がなければ対話を開始する必要があります。同時にユーザーメッセージを保存し、各対話ターンでユーザーの履歴メッセージをAPIに渡します。

      まず、機密性チェックを見てみましょう。この種のAPIは国内の多くのベンダーが提供しており、OpenAIも関連するAPIを提供しています。このAPI自体は対話とは関係ありませんが、ここではOpenAIのAPIを例に説明します。

      import openai
      import os
      import requests
      
      OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
      openai.api_key = OPENAI_API_KEY
      
      def check_content_safety(input_text: str) -> bool:
          """OpenAI Moderation APIを使用して入力テキストの安全性をチェックする関数"""
          moderation_api_url = "https://api.openai.com/v1/moderations"
          headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
          payload = {"input": input_text}
      
          try:
              response = requests.post(moderation_api_url, json=payload, headers=headers)
              response.raise_for_status() # HTTPエラーがあれば例外を発生
              data = response.json()
              # 'flagged' が True の場合、コンテンツが不適切と判断されたことを意味する
              return data["results"][0]["flagged"]
          except requests.exceptions.RequestException as e:
              print(f"安全性チェックAPI呼び出し中にエラーが発生しました: {e}")
              return False # エラー時は安全でないとは判断しない
      
      # テスト
      print(f"「good」の安全性チェック結果: {check_content_safety('good')}") # 出力例: 「good」の安全性チェック結果: False
      

      次に、APIの入力の構築方法を検討します。ここには2つのタスクがあります。1つ目は、履歴対話記録をコンテキストとしてクエリすることです。簡単のため、前のターンのみを考慮するか、すべての記録を渡すこともできます。対話ターンが少ないため、後者のアプローチを採用します。2つ目は、入力のトークン数を計算し、モデルが受け入れられる最大トークン長と希望する最大出力長に基づいて、コンテキストの最大長を逆算し、履歴対話を処理することです(例: 途中で切り詰める)。この戦略を決定したら、データ構造を設計します。以下の通りです。

      from dataclasses import dataclass, asdict
      from typing import List, Dict, Optional
      from datetime import datetime
      import uuid
      import json
      import re
      # SQLAlchemyのインポートは、実際にDBと連携する場合に必要
      # from sqlalchemy import create_engine, Column, String, Integer, DateTime
      # from sqlalchemy.ext.declarative import declarative_base
      # from sqlalchemy.orm import sessionmaker
      
      # DB接続設定 (ダミー、実際のDB接続は適宜設定)
      # DATABASE_URL = "sqlite:///./chat_history.db"
      # engine = create_engine(DATABASE_URL)
      # Base = declarative_base()
      
      # class ChatSessionTable(Base):
      #     __tablename__ = "chat_sessions"
      #     id = Column(Integer, primary_key=True, index=True)
      #     user_id = Column(String, index=True)
      #     session_id = Column(String, unique=True, index=True)
      #     phone_number = Column(String, nullable=True)
      #     num_people = Column(Integer, nullable=True)
      #     reservation_time = Column(String, nullable=True)
      #     session_start_time = Column(DateTime, default=datetime.now)
      
      # class ChatRecordTable(Base):
      #     __tablename__ = "chat_records"
      #     id = Column(Integer, primary_key=True, index=True)
      #     user_id = Column(String, index=True)
      #     session_id = Column(String, index=True)
      #     user_message = Column(String)
      #     bot_response = Column(String)
      #     message_time = Column(DateTime, default=datetime.now)
      
      # Base.metadata.create_all(bind=engine)
      # SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
      
      @dataclass
      class UserProfile:
          user_identifier: str
          user_name: str
      
      @dataclass
      class ConversationState:
          user_identifier: str
          conversation_id: str
          phone_number: Optional[str] = None
          number_of_people: Optional[int] = None
          meal_reservation_time: Optional[str] = None
          start_timestamp: datetime = datetime.now()
      
      @dataclass
      class ConversationLog:
          user_identifier: str
          conversation_id: str
          user_utterance: str
          bot_utterance: str
          log_timestamp: datetime = datetime.now()
      
      

      上記では、ユーザーの他に2つのシンプルなデータ構造を設計しました。1つは会話情報(ConversationState)、もう1つは会話記録(ConversationLog)です。前者は会話の基本情報を記録し、後者は会話の履歴を記録します。conversation_id は各会話を区別するために使用され、ユーザーが製品ページの「会話を開始」などのボタンをクリックすると生成されます。次回の会話時には新しいIDが生成されます。

      次に、主要な対話ロジックを処理します。この部分は主にChatGPTの能力を利用し、明確な要件を設定し、各対話ターンをモデルに与えます。そして応答を生成します。

      def get_chat_response_for_conversation(messages_list: List[Dict[str, str]]) -> str:
          """メッセージリストを使ってChatCompletion APIから応答を取得する関数"""
          response = openai.ChatCompletion.create(
              model="gpt-3.5-turbo",
              temperature=0.2, # 少しランダム性を持たせる
              max_tokens=100,  # 生成する最大トークン数
              top_p=1,
              frequency_penalty=0,
              presence_penalty=0,
              messages=messages_list
          )
          assistant_reply = response.choices[0].message.content
          return assistant_reply.strip()
      
      

      そして、プロセス全体を連結します。

      class RestaurantBookingChatbot:
      
          def __init__(self):
              # システムプロンプト:ボットの役割と目的、出力形式を定義
              self.system_prompt_template = """あなたはレストラン予約アシスタント(assistant)です。ユーザーから以下の3つの情報を取得することが目的です:電話番号、予約人数、予約時間。ユーザーのメッセージに自由に返信できますが、常にこの目的を忘れないでください。各ターンでは、ユーザーへの返信と、取得した情報をJSON形式で出力してください。JSONには以下の3つのキーを含めます:`phone_number`(電話番号)、`number_of_people`(予約人数)、`meal_reservation_time`(予約時間)。
      
      返信形式:
      ユーザーへの返信:{ユーザーへのメッセージ}
      取得情報:{"phone_number": null, "number_of_people": null, "meal_reservation_time": null}
      """
              self.max_conversation_turns = 10 # 最大対話ターン数
              self.required_slots = ["meal_reservation_time", "number_of_people", "phone_number"] # 取得必須スロット
              self.response_parser = re.compile(r"\n+") # 返信解析用の正規表現
      
          def are_all_slots_filled(self, slot_values: dict) -> bool:
              """すべての必須スロットが埋まったか確認する"""
              for slot in self.required_slots:
                  if slot_values.get(slot) is None:
                      return False
              return True
      
          def display_bot_message(self, message: str):
              """ボットのメッセージを表示する"""
              print(f"アシスタント: {message}")
      
          def conduct_chat(self, user_profile: UserProfile):
              conversation_id = uuid.uuid4().hex
              session_start = datetime.now()
      
              # ChatCompletion APIに渡すメッセージリスト
              chat_messages = [
                  {"role": "system", "content": self.system_prompt_template},
              ]
              current_turn = 0
              conversation_history_logs = [] # 会話履歴ログ
      
              current_slot_values = {"phone_number": None, "number_of_people": None, "meal_reservation_time": None}
      
              while True:
                  if current_turn >= self.max_conversation_turns:
                      self.display_bot_message("ご利用ありがとうございました。またのご来店をお待ちしております。")
                      break
      
                  try:
                      raw_bot_response = get_chat_response_for_conversation(chat_messages)
                  except Exception as e:
                      self.display_bot_message(f"システムエラーが発生しました。後ほど担当者よりご連絡させていただきます。恐れ入りますが、しばらくお待ちください。エラー詳細: {e}")
                      break
      
                  # ボットの応答と取得情報を解析
                  parsed_response_parts = self.response_parser.split(raw_bot_response)
                  bot_dialogue_message = parsed_response_parts[0].replace("ユーザーへの返信:", "").strip()
                  self.display_bot_message(bot_dialogue_message)
      
                  if len(parsed_response_parts) > 1:
                      slot_json_string = parsed_response_parts[1].replace("取得情報:", "").strip()
                      try:
                          extracted_slots = json.loads(slot_json_string)
                          # 取得したスロットを現在のスロット値にマージ
                          current_slot_values.update({k: v for k, v in extracted_slots.items() if v is not None})
                          print(f"\t現在の取得情報: {current_slot_values}")
                      except json.JSONDecodeError:
                          print(f"\tJSON解析エラー: {slot_json_string}")
      
                  current_turn += 1
      
                  if self.are_all_slots_filled(current_slot_values):
                      self.display_bot_message("すべての情報が揃いました。ご予約ありがとうございます。")
                      break
      
                  user_input = input("ユーザー: ")
      
                  # ユーザー入力の安全性をチェック
                  if check_content_safety(user_input):
                      self.display_bot_message("不適切な内容が検出されました。会話を終了します。")
                      break
      
                  # 会話メッセージリストと履歴ログを更新
                  chat_messages.append({"role": "assistant", "content": raw_bot_response})
                  chat_messages.append({"role": "user", "content": user_input})
                  conversation_history_logs.append(
                      ConversationLog(user_profile.user_identifier, conversation_id, user_input, raw_bot_response, datetime.now())
                  )
      
              # 会話セッション状態を保存
              final_conversation_state = ConversationState(
                  user_profile.user_identifier,
                  conversation_id,
                  phone_number=current_slot_values["phone_number"],
                  number_of_people=current_slot_values["number_of_people"],
                  meal_reservation_time=current_slot_values["meal_reservation_time"],
                  start_timestamp=session_start
              )
              self.store_conversation_data(conversation_history_logs, final_conversation_state)
      
          def store_conversation_data(self, logs: List[ConversationLog], state: ConversationState):
              # ここではダミーの実装。実際にはデータベースに保存する
              print("\n--- 会話データ保存 ---")
              print("セッション状態:", asdict(state))
              print("ログ数:", len(logs))
              for log in logs:
                  print(f"  [ログ] ユーザー: {log.user_utterance} | アシスタント: {log.bot_utterance}")
              print("--------------------")
      
              # 実際のDB保存処理の例 (SQLAlchemyを使用する場合)
              # with SessionLocal() as session:
              #     session.add(ChatSessionTable(**asdict(state)))
              #     session.add_all([ChatRecordTable(**asdict(log)) for log in logs])
              #     session.commit()
      

      起動前に、関連するデータベーステーブルを作成する必要がありますが、ここでは省略します。それでは、簡単に試してみましょう。

      import pnlp # ユーティリティライブラリ
      
      user_nickname = "山田"
      current_user = UserProfile(pnlp.generate_uuid(user_nickname), user_nickname)
      restaurant_chatbot = RestaurantBookingChatbot()
      restaurant_chatbot.conduct_chat(current_user)
      
      

      インタラクションの過程は以下の通りです。

      アシスタント: ご予約ありがとうございます。まず、お客様の電話番号をお伺いしてもよろしいでしょうか?
      	現在の取得情報: {'phone_number': None, 'number_of_people': None, 'meal_reservation_time': None}
      ユーザー: 私の電話番号は090-1234-5678です
      
      アシスタント: 090-1234-5678ですね。承知いたしました。次に、ご予約の人数は何名様でしょうか?
      	現在の取得情報: {'phone_number': '090-1234-5678', 'number_of_people': None, 'meal_reservation_time': None}
      ユーザー: 5人です
      
      アシスタント: 5名様ですね。承知いたしました。最後に、ご予約のご希望時間をお伺いしてもよろしいでしょうか?
      	現在の取得情報: {'phone_number': '090-1234-5678', 'number_of_people': 5, 'meal_reservation_time': None}
      ユーザー: ちょっと待って確認しますね
      
      アシスタント: はい、承知いたしました。お時間のある時で結構ですので、ご連絡お待ちしております。
      ユーザー: 明日の午後7時でお願いします、ありがとう
      
      アシスタント: 明日の午後7時ですね。承知いたしました。これでご予約に必要な情報がすべて揃いました。他に何かご不明な点がございましたら、お気軽にお尋ねください。
      	現在の取得情報: {'phone_number': '090-1234-5678', 'number_of_people': 5, 'meal_reservation_time': '明日の午後7時'}
      アシスタント: すべての情報が揃いました。ご予約ありがとうございます。
      --- 会話データ保存 ---
      セッション状態: {'user_identifier': 'd4b0f209581c4e979a02d6501a351111', 'conversation_id': '8e71887e221345d2b781b0a1a0f96899', 'phone_number': '090-1234-5678', 'number_of_people': 5, 'meal_reservation_time': '明日の午後7時', 'start_timestamp': datetime.datetime(2023, 10, 27, 10, 30, 1, 626569)}
      ログ数: 4
        [ログ] ユーザー: 私の電話番号は090-1234-5678です | アシスタント: ユーザーへの返信:ご予約ありがとうございます。まず、お客様の電話番号をお伺いしてもよろしいでしょうか?
      取得情報:{"phone_number": null, "number_of_people": null, "meal_reservation_time": null}
        [ログ] ユーザー: 5人です | アシスタント: ユーザーへの返信:090-1234-5678ですね。承知いたしました。次に、ご予約の人数は何名様でしょうか?
      取得情報:{"phone_number": "090-1234-5678", "number_of_people": null, "meal_reservation_time": null}
        [ログ] ユーザー: ちょっと待って確認しますね | アシスタント: ユーザーへの返信:5名様ですね。承知いたしました。最後に、ご予約のご希望時間をお伺いしてもよろしいでしょうか?
      取得情報:{"phone_number": "090-1234-5678", "number_of_people": 5, "meal_reservation_time": null}
        [ログ] ユーザー: 明日の午後7時でお願いします、ありがとう | アシスタント: ユーザーへの返信:はい、承知いたしました。お時間のある時で結構ですので、ご連絡お待ちしております。
      取得情報:{"phone_number": "090-1234-5678", "number_of_people": 5, "meal_reservation_time": null}
      --------------------
      

      上記では、非常に簡素化されたタスクロボットを実装しました。従来のロボットのNLU、DM、NLGの3つのモジュールは存在しませんが、すでに機能しています。唯一の欠点は、APIの応答が少し遅いことかもしれませんが、これは別の問題です。

      読者の皆さんがより良いアプリケーションを構築できるように、いくつかの点を特に強調する必要があります。

      第一に、 非常に多くの対話ターン(例えば、研修や面接のようなシナリオ)をサポートする必要がある場合、各対話ターンをリアルタイムでインデックス化し、各ターンで関連する履歴対話の中からNターンをコンテキストとして検索する(文書QAの場合と同様に)必要があります。その後、ChatGPTにこれらのコンテキストに基づいてユーザーに応答させます。これにより、理論的には無限のターンをサポートできます。検索プロセスは一種の「記憶の呼び出し」であり、ここには最適化の余地や想像力の広がりが大きく存在します。

      第二に、 ChatGPTに messages パラメータを渡す際、長さ制限があるため、コンテキスト中に非常に長い応答を含むターンがある場合、数ターン(場合によっては1ターンで長さの大部分を消費してしまう)しか渡せないことがあります。ChatGPT自身の説明によると、履歴が非常に長い場合、回答を生成するためにそのごく一部しか利用できないことがあります。このような状況に対処するため、通常、最も関連性の高い履歴を選択するためのいくつかの技術が使用されます。例えば、キーワード抽出技術を使用して履歴の中で最も関連性の高い情報を特定し、それを現在の入力と組み合わせて使用する場合があります。また、要約技術を使用して履歴を圧縮・要約し、回答を生成する際に最も重要な情報のみを使用することもあります。さらに、注意メカニズムなどの記憶メカニズムを使用して、履歴の中で最も関連性の高い情報を選択することもできます。また、ChatGPTによると、回答を生成する際には、出力長を制限するための技術(例えば、出力を切り詰める、より簡潔な回答を生成するための戦略を使用するなど)も使用されます。もちろん、ユーザーは特定の入力制限やルールを使用して回答を短縮することもできます。要するに、出力長と回答品質の間のバランスを可能な限り取るべきです。

      第三に、 セキュリティを十分に考慮し、実際の状況に基づいて合理的なアーキテクチャを設計する必要があります。

      最後に、上記はChatGPTの機能のごく一部を利用したに過ぎません。読者の皆さんは、ご自身の業務と組み合わせたり、自由な発想で、より多くの有用で面白い製品やアプリケーションを開発できるでしょう。

      本章のまとめ

      文分類とトークン分類は、NLP分野で最も基本的な常用タスクです。本章ではまず、これら2種類のタスクの簡単な紹介を行い、次にChatGPTを使用してこれらのタスクを完了する方法を説明し、最後にいくつかの関連する応用例を紹介しました。文書質疑応答は、与えられたコンテキストに基づいて質問に答えるもので、従来の手法と比較して、大規模言語モデルをベースにすることで非常に容易に、しかも良好な効果を得ることができます。モデルのファインチューニングは、垂直ドメインデータに適応させることを主な目的とします。どんなに優れた大規模言語モデルでも、得意でない領域や学習が不十分な領域があり、ファインチューニングはモデルに「さらなる学習」をさせるものです。大規模言語モデルの台頭により、様々な新型アプリケーションが次々と登場し、既存のアプリケーションも大規模言語モデルの力を借りて効率を向上させることができます。読者の皆様に、ご自身のアイデアを具体的に実現していただくため、タスクロボットを例に、大規模言語モデルを活用して数十行のコードでシンプルなアプリケーションを完成させる方法を実演しました。これは本書の目的の一つでもあります。

タグ: 大規模言語モデル 自然言語処理 テキスト分類 エンティティ抽出 感情分析

5月13日 18:09 投稿