.NET での AI を使ったアプリの評価ライブラリ爆誕…!!
注意事項
このライブラリはプレビュー版の情報になります。2024/11/28 時点での情報です。
正式リリースや今後のプレビュー版の変更により、内容が変わる可能性があります。
はじめに
Evaluate the quality of your AI applications with ease という記事が先日投稿されました。
個人的に待ち望んでいた、AI を使ったアプリの評価を行うためのライブラリの .NET 版です!!
今まで、AI からの回答を評価するためには、Python のそのようなライブラリを使うとか、PromptFlow の評価を使うか、先日の Microsoft Ignite 2024 で登場した Azure AI Foundry の評価機能 や Azure AI Foundry SDK の評価機能 を使うといった感じでした。.NETで決め手となるライブラリがなかったので、自分で手組みするしかないのかなぁ、めんどくさいなぁと思っていたところに今回の記事が投稿されました。
使い方
ということで、早速使ってみたいと思います。このライブラリは既存の .NET の単体テスト フレームワークで使いやすいように設計されているので、既存のテスト プロジェクトに追加して使うことができます。
今回は新規作成した MSTest のプロジェクトをベースに試してみたいと思います。
追加が必要な NuGet パッケージ
最低でも以下の3つのパッケージを追加して使う必要があります。
- 
Microsoft.Extensions.AI.Evaluation- AIアプリケーションの評価を行うための抽象化された機能を提供
 
 - 
Microsoft.Extensions.AI.Evaluation.Quality- 以下の AI からの応答の評価基準を提供
- 関連性【Relevance】: 応答がどれだけ質問に適しているか。
 - 真実性【Truth】: 提供された情報の正確さ。
 - 完全性【Completeness】: 応答が質問のすべての必要な側面をカバーしているか。
 - 流暢さ【Fluency】: 応答の読みやすさと文法の正確さ。
 - 一貫性【Coherence】: 応答の論理的な流れと整合性。
 - 類似性【Equivalence】: 応答が意図された意味や文脈にどれだけ一致しているか。
 - 根拠性【Groundedness】: 応答が検証可能な情報に基づいているかどうか。
 
 
 - 以下の AI からの応答の評価基準を提供
 - 
Microsoft.Extensions.AI.Evaluation.Reporting- LLMの応答をキャッシュし、評価結果を保存し、そのデータからレポートを生成するためのサポートを提供
 
 
MSTest プロジェクトに上記の 3 つのパッケージを追加します。
さらに、追加で今回使用する gpt-4o モデルのトークン数を数えるための以下の Microsoft.ML.Tokenizers 関連のパッケージも追加します。
Microsoft.ML.Tokenizers.Data.O200kBase
そして評価にするために、今回は gpt-4o を使うために以下のパッケージも追加します。
Microsoft.Extensions.AIMicrosoft.Extensions.AI.OpenAIAzure.AI.OpenAIAzure.Identity
評価を行うクラスの作成
まずは、どのような評価をしたいかを定義するクラスを作成します。
今回は、関連性、真実性、完全性と流暢さと同等性を評価してみたいと思います。
まずは対応する 評価器 (Evaluator) を作成します。
IEvaluator[] evaluators = [
        // 関連性、真実性、完全性を評価するための評価器
        new RelevanceTruthAndCompletenessEvaluator(),
        // 流暢さを評価するための評価器
        new FluencyEvaluator(),
        // 類似性を評価するための評価器
        new EquivalenceEvaluator(),
    ];
また、評価に使用するための gpt-4o 用の IChatClient と Tokenizer を作成して評価用のチャットの構成をします。
評価に使用するためのトークン数の上限を設定しています。
var chatClient = new AzureOpenAIClient(
    // AOAI のエンドポイント
    new("https://<<AOAI のリソース名>>.openai.azure.com/"),
    // AOAI に接続するための資格情報 (キーで接続する場合は AzureKeyCredential を使う)
    new AzureCliCredential())
    // gpt-4o という名前でデプロイされた gpt-4o モデルを使うチャットクライアントを作成
    .AsChatClient("gpt-4o");
// gpt-4o 用の Tokenizer を作成
var tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o");
// チャット設定を作成 (入力トークン数の上限を 6000 に設定)
var chatConfig = new ChatConfiguration(chatClient, tokenizer.ToTokenCounter(inputTokenLimit: 6000));
評価器とチャット設定を使って、実際に評価を行う ScenarioRun を作成します。
// ディスクにキャッシュを保存する ReportingConfiguration を作成
var reportingConfiguration = DiskBasedReportingConfiguration.Create(
    // キャッシュを保存するルートパス
    storageRootPath: ".\\reports",
    // 評価器の設定
    evaluators,
    // チャット設定
    chatConfig);
// ReportingConfiguration を使って ScenarioRun を作成 (ScenarioRun を使って実際の評価を行う)
await using var scenario = await reportingConfiguration.CreateScenarioRunAsync("Test1");
評価を行う
ScenarioRun を使って、実際に評価を行います。
// インプットに対して、結果がちゃんとしているかを評価
EvaluationResult evaluationResult = await scenario.EvaluateAsync(
    // チャットのインプット
    [
        new ChatMessage(ChatRole.System, "あなたは猫型アシスタントです。猫らしく振舞うために語尾を「にゃん」にしてください。"),
        new ChatMessage(ChatRole.User, "今日の品川の天気は?"),
    ],
    // 結果
    new ChatMessage(ChatRole.Assistant, "今日の広島のお昼ご飯はお好み焼きだよ!"),
    // Evaluator によっては追加のコンテキストが必要な場合がある
    // EquivalenceEvaluator は追加のコンテキストが必要で、比較対象の文を提供する必要がある
    additionalContext: [
            new EquivalenceEvaluator.Context(groundTruth: "今日の品川の天気は晴れです。"),
        ]);
今回は結果のところにも固定文字列を設定していますが、実際には AI からの応答を使います。
結果の確認
では結果を確認しましょう。
EvaluationResult は IDictionary<string, EvaluationMetric> 型の Metrics プロパティを持っています。
EvaluationMetric は以下のように定義されています。逆コンパイル結果を、少し手直ししたコードなので実際のコードとは違う可能性があります。
[JsonDerivedType(typeof(NumericMetric), "numeric")]
[JsonDerivedType(typeof(BooleanMetric), "boolean")]
[JsonDerivedType(typeof(StringMetric), "string")]
[JsonDerivedType(typeof(EvaluationMetric), "none")]
public class EvaluationMetric
{
    public string Name { get; set; }
    public EvaluationMetricInterpretation? Interpretation { get; set; }
    public IList<EvaluationDiagnostic> Diagnostics { get; set; }
    public EvaluationMetric(string name)
    {
        Name = name;
        Diagnostics = new List<EvaluationDiagnostic>();
    }
    public void AddDiagnostic(EvaluationDiagnostic diagnostic)
    {
        Diagnostics.Add(diagnostic);
    }
}
名前と EvaluationMetricInterpretation と Diagnostics を持っています。
Diagnostics は多分エラーが起きたとかだと思うので EvaluationMetricInterpretation の方の定義を見ていきます。
public sealed class EvaluationMetricInterpretation
{
    //
    // 概要:
    //     An Microsoft.Extensions.AI.Evaluation.EvaluationRating that identifies how good
    //     or bad the result represented in the associated Microsoft.Extensions.AI.Evaluation.EvaluationMetric
    //     is considered.
    public EvaluationRating Rating { get; set; }
    //
    // 概要:
    //     true if the result represented in the associated Microsoft.Extensions.AI.Evaluation.EvaluationMetric
    //     is considered a failure, false otherwise.
    public bool Failed { get; set; }
    //
    // 概要:
    //     An optional string that can be used to provide some commentary around the values
    //     specified for Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.Rating
    //     and / or Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.Failed.
    public string? Reason { get; set; }
    public EvaluationMetricInterpretation(EvaluationRating rating = EvaluationRating.Unknown, bool failed = false, string? reason = null)
    {
        Rating = rating;
        Failed = failed;
        Reason = reason;
    }
}
EvaluationRating は以下のように定義されています。
public enum EvaluationRating
{
    Unknown,
    Inconclusive,
    Exceptional,
    Good,
    Average,
    Poor,
    Unacceptable
}
EvaluationRating は Unknown(未知), Inconclusive(不明瞭), Exceptional(例外的), Good(良好), Average(平均), Poor(悪い), Unacceptable(受け入れられない) の 7 つの値を持っています。これを見て大丈夫かどうか判定できそうです。例外的は、悪い意味なのかなと思ったら例外的に良いというような意味で使われるっぽいので Exceptional, Good, Average が成功で、Inconclusive, Poor, Unacceptable が失敗という感じになりそうです。
EvaluationRating を見なくても単純に Failed が true だと失敗しているということになるはずです。
なんとなくここら辺をみることで結果がわかる感じですね。実際に実行した結果の evaluationResult を JSON にして出力してみたところ以下のような結果になりました。
    {
  "Metrics": {
    "Equivalence": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Equivalence",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Equivalence is less than 4."
      },
      "Diagnostics": []
    },
    "Fluency": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Fluency",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Fluency is less than 4."
      },
      "Diagnostics": []
    },
    "Relevance": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Relevance",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Relevance is less than 4."
      },
      "Diagnostics": []
    },
    "Truth": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Truth",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Truth is less than 4."
      },
      "Diagnostics": []
    },
    "Completeness": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Completeness",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Completeness is less than 4."
      },
      "Diagnostics": []
    }
  }
}
すべての評価項目で Failed が true になっていて Rating が Unacceptable になっているので、今回の AI の応答は悪いということになります。では評価メソッドの引数に渡した AI の応答を変えてみて、結果が変わるか試してみましょう。以下のように結果の部分で、ちゃんと天気について言及するように変えてみました。
// インプットに対して、結果がちゃんとしているかを評価
EvaluationResult evaluationResult = await scenario.EvaluateAsync(
    // チャットのインプット
    [
        new ChatMessage(ChatRole.System, "あなたは猫型アシスタントです。猫らしく振舞うために語尾を「にゃん」にしてください。"),
        new ChatMessage(ChatRole.User, "今日の品川の天気は?"),
    ],
    // 結果
    new ChatMessage(ChatRole.Assistant, "今日の品川の天気は晴れですにゃん。お出かけ日和ですにゃん。"),
    // Evaluator によっては追加のコンテキストが必要な場合がある
    // EquivalenceEvaluator は追加のコンテキストが必要で、比較対象の文を提供する必要がある
    additionalContext: [
            new EquivalenceEvaluator.Context(groundTruth: "今日の品川の天気は晴れです。"),
        ]);
実行した結果以下のようになりました。
    {
  "Metrics": {
    "Equivalence": {
      "$type": "numeric",
      "Value": 4,
      "Name": "Equivalence",
      "Interpretation": {
        "Rating": "Good",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Fluency": {
      "$type": "numeric",
      "Value": 3,
      "Name": "Fluency",
      "Interpretation": {
        "Rating": "Average",
        "Failed": true,
        "Reason": "Fluency is less than 4."
      },
      "Diagnostics": []
    },
    "Relevance": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Relevance",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Truth": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Truth",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Truth is less than 4."
      },
      "Diagnostics": []
    },
    "Completeness": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Completeness",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    }
  }
}
先ほどより改善して Fluency と Truth が Failed が true になっているので、まだ改善の余地があるようです。Relevance と Completeness は Failed が false になっているので、問題ないようです。
インプットと結果をもう少し変えてみました。今回は全部成功を狙っています。猫型アシスタント要素を取り払って一般的な応答にしてみました。
// インプットに対して、結果がちゃんとしているかを評価
EvaluationResult evaluationResult = await scenario.EvaluateAsync(
    // チャットのインプット
    [
        new ChatMessage(ChatRole.System, "あなたは AI アシスタントです。ユーザーからの質問に答えてください。"),
        new ChatMessage(ChatRole.User, "今日の品川の天気は?"),
    ],
    // 結果
    new ChatMessage(ChatRole.Assistant, "今日の品川の天気は晴れです。"),
    // Evaluator によっては追加のコンテキストが必要な場合がある
    // EquivalenceEvaluator は追加のコンテキストが必要で、比較対象の文を提供する必要がある
    additionalContext: [
            new EquivalenceEvaluator.Context(groundTruth: "今日の品川の天気は晴れです。"),
        ]);
結果は以下のようになりました。
    {
  "Metrics": {
    "Equivalence": {
      "$type": "numeric",
      "Value": 4,
      "Name": "Equivalence",
      "Interpretation": {
        "Rating": "Good",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Fluency": {
      "$type": "numeric",
      "Value": 3,
      "Name": "Fluency",
      "Interpretation": {
        "Rating": "Average",
        "Failed": true,
        "Reason": "Fluency is less than 4."
      },
      "Diagnostics": []
    },
    "Relevance": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Relevance",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Truth": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Truth",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Truth is less than 4."
      },
      "Diagnostics": []
    },
    "Completeness": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Completeness",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    }
  }
}
Fluency と Truth が Failed が true になっています…。これは大丈夫なんだろうか…。
詳細を確認するために RelevanceTruthAndCompletenessEvaluator の options を追加してダメだったときの理由を追加するようにしてみました。new RelevanceTruthAndCompletenessEvaluator(new(includeReasoning: true)), のように includeReasoning を true にしています。
この変更を加えて、実行を行うと以下のような結果になりました。
   {
  "Metrics": {
    "Equivalence": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Equivalence",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Fluency": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Fluency",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Relevance": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Relevance",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "100% of the response is 100% related to the request"
        }
      ]
    },
    "Truth": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Truth",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Truth is less than 4."
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "The response provides a specific weather condition for Shinagawa without any verification or source, making it unreliable. Weather conditions can vary and require up-to-date information from a reliable source."
        }
      ]
    },
    "Completeness": {
      "$type": "numeric",
      "Value": 1,
      "Name": "Completeness",
      "Interpretation": {
        "Rating": "Unacceptable",
        "Failed": true,
        "Reason": "Completeness is less than 4."
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "The response lacks any verification or source for the weather information, which is necessary to address the user's request accurately."
        }
      ]
    }
  }
}
なるほど、ちゃんとチャット履歴に天気についての情報がないということで Truth と Completeness が Failed になっているようです。では、チャット履歴を以下のように変更してみましょう。今度こそ成功するといいなぁ。
// インプットに対して、結果がちゃんとしているかを評価
EvaluationResult evaluationResult = await scenario.EvaluateAsync(
    // チャットのインプット
    [
        new ChatMessage(ChatRole.System, """
            あなたは AI アシスタントです。ユーザーからの質問に答えてください。
            ## コンテキスト
            - 品川の天気: 晴れ
            """),
        new ChatMessage(ChatRole.User, "今日の品川の天気は?"),
    ],
    // 結果
    new ChatMessage(ChatRole.Assistant, "今日の品川の天気は晴れです。"),
    // Evaluator によっては追加のコンテキストが必要な場合がある
    // EquivalenceEvaluator は追加のコンテキストが必要で、比較対象の文を提供する必要がある
    additionalContext: [
            new EquivalenceEvaluator.Context(groundTruth: "今日の品川の天気は晴れです。"),
        ]);
実行すると以下のようになりました。すべて OK ですね!
{
  "Metrics": {
    "Equivalence": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Equivalence",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Fluency": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Fluency",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": []
    },
    "Relevance": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Relevance",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "100% of the response is 100% related to the request"
        }
      ]
    },
    "Truth": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Truth",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "100% of the response is 100% true"
        }
      ]
    },
    "Completeness": {
      "$type": "numeric",
      "Value": 5,
      "Name": "Completeness",
      "Interpretation": {
        "Rating": "Exceptional",
        "Failed": false,
        "Reason": null
      },
      "Diagnostics": [
        {
          "Severity": "Informational",
          "Message": "The response includes all points that are necessary to address the request"
        }
      ]
    }
  }
}
ということで以下のようなアサートを追加したら成功!ということになります。
// Failed が全て false だと成功!
Assert.IsTrue(evaluationResult.Metrics.Values.All(x => x.Interpretation?.Failed == false));
レポートを見てみよう
次にレポートを見てみます。
Microsoft.Extensions.AI.Evaluation.Console というツールを使って以下のようなコマンドでレポートを出力できます。
dotnet tool install Microsoft.Extensions.AI.Evaluation.Console --prerelease --create-manifest-if-needed
dotnet aieval report --path <path\to\my\cache\storage> --output report.html
<path\to\my\cache\storage> は DiskBasedReportingConfiguration で指定したパスになります。今回は .\\reports に保存しているので、プロジェクトの配下の bin\Debug\net9.0\reports になります。実際に、そのフォルダーにいって ls コマンドをうってみると何かしらファイルができているみたいです。
PS D:\Labs\TestProject1\TestProject1\bin\Debug\net9.0\reports> ls
    ディレクトリ: D:\Labs\TestProject1\TestProject1\bin\Debug\net9.0\reports
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----        2024/11/29      0:11                cache
d-----        2024/11/29      0:11                results
ここで以下のコマンドをうって report.html を出力してみます。
dotnet aieval report --path . --output report.html
すると以下のようなレポートが出力されました。

ばっちりですね。
まとめ
ということで Microsoft.Extensions.AI.Evaluation を使って AI の応答を評価する方法を試してみました。
ドキュメントを見つけられていないのでブログを見ながら試行錯誤をしながら使ってみましたが、なんとか使えることがわかりました。
Microsoft.Extensions.AI の上に構築されているので、様々なモデルを使って使うことが出来るので今後の .NET での AI を使ったアプリケーションの評価のデファクトスタンダードになってほしいなぁと個人的には思っています。普段使っているユニットテストに組み込んで使うことができるので、開発者にとっても使いやすいライブラリになっていると思います。
Discussion