使用 C#、BERT NLP 深度学习和 ONNX Runtime 进行推理

在本教程中,我们将学习如何在 C# 中对流行的 BERT 自然语言处理深度学习模型进行推理。

为了能够在 C# 中对文本进行预处理,我们将利用开源项目 BERTTokenizers,其中包含了大多数 BERT 模型的 tokenizer。支持的模型如下所示。

  • BERT Base
  • BERT Large
  • BERT German
  • BERT 多语言
  • BERT Base Uncased
  • BERT Large Uncased

有许多模型(包括本教程中使用的模型)是基于这些基础模型进行微调的。模型的 tokenizer 仍然与微调所基于的基础模型相同。

目录

先决条件

本教程可以在本地运行,也可以利用 Azure Machine Learning 计算资源运行。

本地运行

使用 Azure Machine Learning 在云端运行

使用 Hugging Face 下载 BERT 模型

Hugging Face 提供了强大的 API,用于下载开源模型,然后我们可以使用 python 和 Pytorch 将其导出为 ONNX 格式。对于尚不属于 ONNX Model Zoo 的开源模型,这是一个很好的选择。

在 Python 中下载和导出模型的步骤

使用 transformers API 下载名为 bert-large-uncased-whole-word-masking-finetuned-squadBertForQuestionAnswering 模型

import torch
from transformers import BertForQuestionAnswering

model_name = "bert-large-uncased-whole-word-masking-finetuned-squad"
model_path = "./" + model_name + ".onnx"
model = BertForQuestionAnswering.from_pretrained(model_name)

# set the model to inference mode
# It is important to call torch_model.eval() or torch_model.train(False) before exporting the model, 
# to turn the model to inference mode. This is required since operators like dropout or batchnorm 
# behave differently in inference and training mode.
model.eval()

下载模型后,我们需要将其导出为 ONNX 格式。Pytorch 的 torch.onnx.export 函数内置了此功能。

  • inputs 变量指示输入形状。您可以像下面一样创建虚拟输入,或者使用测试模型的样本输入。

  • opset_version 设置为与模型兼容的最高版本。在此处详细了解 opset 版本:此处

  • 设置模型的 input_namesoutput_names

  • 为动态长度输入设置 dynamic_axes,因为每次推理问题时,sentencecontext 变量的长度都会不同。

# Generate dummy inputs to the model. Adjust if necessary.
inputs = {
        # list of numerical ids for the tokenized text
        'input_ids':   torch.randint(32, [1, 32], dtype=torch.long), 
        # dummy list of ones
        'attention_mask': torch.ones([1, 32], dtype=torch.long),     
        # dummy list of ones
        'token_type_ids':  torch.ones([1, 32], dtype=torch.long)     
    }

symbolic_names = {0: 'batch_size', 1: 'max_seq_len'}
torch.onnx.export(model,                                         
# model being run
                  (inputs['input_ids'],
                   inputs['attention_mask'], 
                   inputs['token_type_ids']),                    # model input (or a tuple for multiple inputs)
                  model_path,                                    # where to save the model (can be a file or file-like object)
                  opset_version=11,                              # the ONNX version to export the model to
                  do_constant_folding=True,                      # whether to execute constant folding for optimization
                  input_names=['input_ids',
                               'input_mask', 
                               'segment_ids'],                   # the model's input names
                  output_names=['start_logits', "end_logits"],   # the model's output names
                  dynamic_axes={'input_ids': symbolic_names,
                                'input_mask' : symbolic_names,
                                'segment_ids' : symbolic_names,
                                'start_logits' : symbolic_names, 
                                'end_logits': symbolic_names})   # variable length axes/dynamic input

在 Python 中理解模型

当使用预构建模型并投入运行时,花一些时间了解模型的预处理和后处理,以及输入/输出的形状和标签是很有用的。许多模型都提供了 Python 示例代码。我们将使用 C# 对模型进行推理,但首先让我们测试一下并在 Python 中看看它是如何完成的。这将有助于我们下一步的 C# 逻辑开发。

  • 用于测试模型的代码已在本教程中提供。请查看在 Python 中测试和推理此模型的源代码。下面是运行模型时的示例 input 句子和示例 output

  • 示例 input

input = "{\"question\": \"What is Dolly Parton's middle name?\", \"context\": \"Dolly Rebecca Parton is an American singer-songwriter\"}"

print(run(input))
  • 以上问题的输出应该如下所示。您可以使用 input_ids 来验证 C# 中的分词。
Output:
{'input_ids': [101, 2054, 2003, 19958, 2112, 2239, 1005, 1055, 2690, 2171, 1029, 102, 19958, 9423, 2112, 2239, 2003, 2019, 2137, 3220, 1011, 6009, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
{'answer': 'Rebecca'}

使用 C# 进行推理

现在我们已经在 Python 中测试了模型,是时候在 C# 中实现它了。我们需要做的第一件事是创建我们的项目。在本示例中,我们将使用控制台应用程序,但您可以在任何 C# 应用程序中使用此代码。

安装 Nuget 包

  • 安装 Nuget 包 BERTTokenizersMicrosoft.ML.OnnxRuntimeMicrosoft.ML.OnnxRuntime.ManagedMicrosoft.ML
    dotnet add package Microsoft.ML.OnnxRuntime --version 1.16.0
    dotnet add package Microsoft.ML.OnnxRuntime.Managed --version 1.16.0
    dotnet add package Microsoft.ML
    dotnet add package BERTTokenizers --version 1.1.0
    

创建应用程序

  • 导入包
using BERTTokenizers;
using Microsoft.ML.Data;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using System;
  • 添加 namespaceclassMain 函数。

namespace MyApp // Note: actual namespace depends on the project name.
{
    internal class BertTokenizeProgram
    {
        static void Main(string[] args)
        {

        }
    }
}

创建用于编码的 BertInput 类

  • 添加 BertInput 结构体
    public struct BertInput
    {
        public long[] InputIds { get; set; }
        public long[] AttentionMask { get; set; }
        public long[] TypeIds { get; set; }
    }

使用 BertUncasedLargeTokenizer 对句子进行分词

  • 创建一个句子(问题和上下文),并使用 BertUncasedLargeTokenizer 对其进行分词。基础模型是 bert-large-uncased,因此我们使用库中的 BertUncasedLargeTokenizer。务必检查您的 BERT 模型是基于哪个基础模型构建的,以确认您使用了正确的 tokenizer。
  var sentence = "{\"question\": \"Where is Bob Dylan From?\", \"context\": \"Bob Dylan is from Duluth, Minnesota and is an American singer-songwriter\"}";
  Console.WriteLine(sentence);

  // Create Tokenizer and tokenize the sentence.
  var tokenizer = new BertUncasedLargeTokenizer();

  // Get the sentence tokens.
  var tokens = tokenizer.Tokenize(sentence);
  // Console.WriteLine(String.Join(", ", tokens));

  // Encode the sentence and pass in the count of the tokens in the sentence.
  var encoded = tokenizer.Encode(tokens.Count(), sentence);

  // Break out encoding to InputIds, AttentionMask and TypeIds from list of (input_id, attention_mask, type_id).
  var bertInput = new BertInput()
  {
      InputIds = encoded.Select(t => t.InputIds).ToArray(),
      AttentionMask = encoded.Select(t => t.AttentionMask).ToArray(),
      TypeIds = encoded.Select(t => t.TokenTypeIds).ToArray(),
  };
 

创建推理所需的 name -> OrtValue 对形式的 inputs

  • 获取模型,在输入缓冲区之上创建 3 个 OrtValue,并将它们封装到一个 Dictionary 中,然后馈送到 Run() 中。请注意,几乎所有 Onnxruntime 类都封装了原生数据结构,因此必须进行释放以防止内存泄漏。
  // Get path to model to create inference session.
  var modelPath = @"C:\code\bert-nlp-csharp\BertNlpTest\BertNlpTest\bert-large-uncased-finetuned-qa.onnx";

  using var runOptions = new RunOptions();
  using var session = new InferenceSession(modelPath);

  // Create input tensors over the input data.
  using var inputIdsOrtValue = OrtValue.CreateTensorValueFromMemory(bertInput.InputIds,
        new long[] { 1, bertInput.InputIds.Length });

  using var attMaskOrtValue = OrtValue.CreateTensorValueFromMemory(bertInput.AttentionMask,
        new long[] { 1, bertInput.AttentionMask.Length });

  using var typeIdsOrtValue = OrtValue.CreateTensorValueFromMemory(bertInput.TypeIds,
        new long[] { 1, bertInput.TypeIds.Length });

  // Create input data for session. Request all outputs in this case.
  var inputs = new Dictionary<string, OrtValue>
  {
      { "input_ids", inputIdsOrtValue },
      { "input_mask", attMaskOrtValue },
      { "segment_ids", typeIdsOrtValue }
  };

运行推理

  • 创建 InferenceSession,运行推理并打印结果。
  // Run session and send the input data in to get inference output. 
  using var output = session.Run(runOptions, inputs, session.OutputNames);

后处理 output 并打印结果

  • 在在这里,我们获取起始位置 (startLogit) 和结束位置 (endLogits) 的索引。然后我们取出输入句子的原始 tokens,并获取预测的 token ID 对应的词汇表值。
            // Get the Index of the Max value from the output lists.
            // We intentionally do not copy to an array or to a list to employ algorithms.
            // Hopefully, more algos will be available in the future for spans.
            // so we can directly read from native memory and do not duplicate data that
            // can be large for some models
            // Local function
            int GetMaxValueIndex(ReadOnlySpan<float> span)
            {
                float maxVal = span[0];
                int maxIndex = 0;
                for (int i = 1; i < span.Length; ++i)
                {
                    var v = span[i];
                    if (v > maxVal)
                    {
                        maxVal = v;
                        maxIndex = i;
                    }
                }
                return maxIndex;
            }

            var startLogits = output[0].GetTensorDataAsSpan<float>();
            int startIndex = GetMaxValueIndex(startLogits);

            var endLogits = output[output.Count - 1].GetTensorDataAsSpan<float>();
            int endIndex = GetMaxValueIndex(endLogits);

            var predictedTokens = tokens
                          .Skip(startIndex)
                          .Take(endIndex + 1 - startIndex)
                          .Select(o => tokenizer.IdToToken((int)o.VocabularyIndex))
                          .ToList();

            // Print the result.
            Console.WriteLine(String.Join(" ", predictedTokens));

使用 Azure Web App 部署

在本示例中,我们创建了一个简单的控制台应用程序,但此代码可以很容易地实现到 C# Web 应用等程序中。请查阅有关如何快速入门:部署 ASP.NET Web 应用的文档。

下一步

有许多不同的 BERT 模型已针对不同任务进行了微调,您也可以针对您的特定任务微调不同的基础模型。此代码适用于大多数 BERT 模型,只需根据您的特定模型更新输入、输出以及预处理/后处理即可。