目录
1. 先决条件
2. 理解Transformers架构
3. BERT直觉
4. ONNX模型
5. 使用ML.NET实现
5.1 数据模型
5.2 训练
该训练类是相当简单,它只有一个方法 BuildAndTrain它使用的路径,预先训练的模式。
5.3 预测器
5.4 助手和扩展
5.4 分词器
5.5 BERT
预测方法进行几个步骤。让我们更详细地探索它。
5.5 Program
结论
到目前为止,在我们的ML.NET之旅中,我们专注于计算机视觉问题,如图像分类和对象检测。在本文中,我们稍微改变一个方向,探索NLP(自然语言处理)以及我们可以通过机器学习解决的一系列问题。
自然语言处理 (NLP)是人工智能的一个子领域,主要目标是帮助程序理解和处理自然语言数据。这个过程的输出是一个可以“理解”语言的计算机程序。
你害怕AI抢走你的工作吗?确保你是建造它的人。
在新兴的人工智能行业中保持相关性!
早在2018年,谷歌发表了一篇关于深度神经网络的论文,该网络被称为来自Transformers的双向编码器表示(Bidirectional Encoder representation from transformer)或BERT。由于其简单性,它成为最流行的NLP算法之一。 有了这个算法,任何人都可以在短短几个小时内训练出自己最先进的问答系统(或各种其他模型)。在本文中,我们将做到这一点,使用BERT创建一个问答系统。
BERT是一种基于Transformers架构的神经网络。这就是为什么在本文中,我们将首先探索该架构,然后继续对BERT进行更深入的了解:
此处提供的实现是C#完成的,我们使用最新的.NET 5。所以请确保你已经安装了这个SDK。如果您使用的是Visual Studio,则它随16.8.3版一起提供。另外,请确保您已安装以下软件包:
$ dotnet add package Microsoft.ML $ dotnet add package Microsoft.ML.OnnxRuntime $ dotnet add package Microsoft.ML.OnnxTransformer
您可以从包管理器控制台执行相同的操作:
Install-Package Microsoft.ML Install-Package Microsoft.ML.OnnxRuntime Install-Package Microsoft.ML.OnnxTransformer
您可以使用Visual Studio的Manage NuGetPackage 选项做类似的事情:
如果您需要使用ML.NET了解机器学习的基础知识,请查看这篇文章。
语言是顺序数据。基本上,您可以将其视为一个词流,其中每个词的含义取决于它之前的词和它之后的词。这就是为什么计算机很难理解语言的原因,因为为了理解一个词,你需要一个上下文。
此外,有时作为输出,您还需要提供一系列数据(单词)。证明这一点的一个很好的例子是将英语翻译成塞尔维亚语。作为算法的输入,我们使用一个单词序列,对于输出,我们还需要提供一个序列。
在这个例子中,算法需要理解英语并理解如何将英语单词映射到塞尔维亚语单词(本质上这意味着必须对塞尔维亚语有一定的了解)。多年来,有许多用于此目的的深度学习架构,例如循环神经网络和LSTM。然而,正是Transformer架构的使用改变了一切。
RNN和LSTM网络不能完全满足需求,因为它们难以训练并且容易出现梯度消失(和爆炸)。Transformers 旨在解决这些问题,并带来更好的性能和对语言的更好理解。它们 于2017年在传奇论文“ Attention is all you need ”中被介绍。
简而言之,他们使用Encoder-Decoder结构和self-attention层来更好地理解语言。如果我们回到翻译示例,编码器负责理解英语,而解码器负责理解塞尔维亚语并将英语映射到塞尔维亚语。
在训练期间,进程编码器提供了来自英语的词嵌入。计算机不理解单词,它们理解数字和矩阵(数字集)。这就是为什么我们将单词转换为某个向量空间的原因,这意味着我们为语言中的每个单词分配某些向量(将它们映射到某个潜在向量空间)。这些是词嵌入。有许多可用的词嵌入,如 Word2Vec。
但是,单词在句子中的位置对于上下文也很重要。这就是完成位置编码的原因。这就是编码器获取有关单词及其上下文的信息的方式。Encoder的Self-attention层正在确定单词之间的关系,并为我们提供有关句子中每个单词的相关性的信息。这就是Encoder理解英语的方式。然后数据进入深度神经网络,然后进入解码器的Mapping-Attention层。
但是,在此之前,解码器获取有关塞尔维亚语的相同信息。它以同样的方式使用词嵌入、位置编码和自我注意来学习如何理解塞尔维亚语。解码器的映射注意层然后拥有关于英语和塞尔维亚语的信息,它只是学习如何将单词从一种语言转换为另一种语言。要了解有关Transformers的更多信息,请查看这篇文章。
BERT使用这种Transformers架构来理解语言。更准确地说,它利用了编码器。该架构实现了两大里程碑。首先,它实现了双向性。这意味着每个句子都以两种方式学习,并且上下文更好地学习,无论是先前的上下文还是未来的上下文。BERT是第一个 深度双向、 无监督的 语言表示,仅使用纯文本语料库(维基百科)进行预训练。它也是NLP的首批预训练模型之一。我们了解了计算机视觉的迁移学习。然而,在BERT之前,这个概念并没有在NLP世界中真正流行起来。
这很有意义,因为您可以在大量数据上训练模型,一旦它理解了语言,您就可以针对更具体的任务对其进行微调。这就是为什么BERT的训练可以分为两个阶段:预训练和微调。
为了实现双向性,BERT使用两种方法进行预训练:
Masked Language Modeling采用蒙面输入。这意味着句子中的某些单词被屏蔽了,填空是BERT的工作。Next Sentence Prediction给出两个句子作为输入,并期望BERT预测一个接着一个的句子。实际上,这两种方法同时发生。
在微调阶段,我们针对特定任务训练BERT。这意味着如果我们想创建问答解决方案,我们只需要训练额外的BERT层。这正是我们在本教程中所做的。我们需要做的就是用为我们的特定目的设计的一组新层替换网络的输出层。作为输入,我们将有一段文本(或上下文)和一个问题,作为输出,我们期望得到问题的答案。
例如,我们的系统应该使用两个句子:“Jim is walk through the woods。” (段落或上下文)和“他叫什么名字?” (问题)提供答案“吉姆”。
在我们深入研究使用ML.NET实现对象检测应用程序之前,我们需要再介绍一件理论上的事情。那是开放神经网络交换(ONNX)文件格式。此文件格式是 AI模型的开源格式,支持框架之间的互操作性。
基本上,您可以在PyTorch等机器学习框架中训练模型,将其保存并将其转换为ONNX格式。然后,您可以在不同的框架(如ML.NET)中使用该ONNX模型。这正是我们在本教程中所做的。您可以在ONNX 网站上找到更多信息 。
在本教程中,我们使用预训练的BERT模型。这种模式在这里 是BERT SQUAD。本质上,我们将此模型导入ML.NET并在我们的应用程序中运行它。
我们可以用ONNX模型做的一件非常有趣和有用的事情是,我们可以使用许多工具来直观地表示模型。当我们在本教程中使用预训练模型时,这非常有用。
我们经常需要知道输入和输出层的名称,这种工具很适合。因此,一旦我们下载了BERT模型,我们就可以使用其中一种工具进行可视化表示。在本指南中,我们使用Netron ,这里只是输出的一部分:
我知道,这太疯狂了,BERT是一个大模型。您可能想知道如何使用它以及为什么需要它?但是,为了使用ONNX模型,我们通常需要知道模型的输入和输出层的名称。这是寻找BERT的方式:
如果您查看我们从中下载模型的BERT-Squad 存储库,您会注意到相关性部分中的一些有趣内容。更准确地说,您会注意到tokenization.py的依赖性 。这意味着我们需要自己执行标记化。 单词标记化是将大量文本样本拆分为单词的过程。这是自然语言处理任务中的一项要求,其中每个单词都需要被捕获并进行进一步分析。有很多方法可以做到这一点。
实际上,我们执行单词编码,为此我们使用Word-Piece Tokenization,如本文所述。它是从tokenzaton.py移植的版本 。 为了实现这个完整的解决方案,我们将您的解决方案构建如下:
在Assets文件夹中,您可以找到下载的.onnx模型和文件夹,其中包含我们想要训练模型的词汇表。该 机器学习文件夹包含所有必要的代码,我们在这个应用程序中使用。在训练和预测类是存在的,就像这些模型数据的类。在单独的文件夹中,我们可以找到用于在Enumerable类型和字符串拆分上为Softmax加载文件和扩展类的帮助程序类。
这个解决方案的灵感来自于Gjeran Vlot的实现,可以在这里找到。
您可能会注意到,在 DataModel文件夹中,我们有两个类,分别用于BERT的输入和预测。 所述 BertInput 类是有表示输入。它们的名称和大小与模型中的层一样:
using Microsoft.ML.Data; namespace BertMlNet.MachineLearning.DataModel { public class BertInput { [VectorType(1)] [ColumnName("unique_ids_raw_output___9:0")] public long[] UniqueIds { get; set; } [VectorType(1, 256)] [ColumnName("segment_ids:0")] public long[] SegmentIds { get; set; } [VectorType(1, 256)] [ColumnName("input_mask:0")] public long[] InputMask { get; set; } [VectorType(1, 256)] [ColumnName("input_ids:0")] public long[] InputIds { get; set; } } }
所述Bertpredictions类用途BERT输出层:
using Microsoft.ML.Data; namespace BertMlNet.MachineLearning.DataModel { public class BertPredictions { [VectorType(1, 256)] [ColumnName("unstack:1")] public float[] EndLogits { get; set; } [VectorType(1, 256)] [ColumnName("unstack:0")] public float[] StartLogits { get; set; } [VectorType(1)] [ColumnName("unique_ids:0")] public long[] UniqueIds { get; set; } } }
using BertMlNet.MachineLearning.DataModel; using Microsoft.ML; using System.Collections.Generic; namespace BertMlNet.MachineLearning { public class Trainer { private readonly MLContext _mlContext; public Trainer() { _mlContext = new MLContext(11); } public ITransformer BuidAndTrain(string bertModelPath, bool useGpu) { var pipeline = _mlContext.Transforms .ApplyOnnxModel(modelFile: bertModelPath, outputColumnNames: new[] { "unstack:1", "unstack:0", "unique_ids:0" }, inputColumnNames: new[] {"unique_ids_raw_output___9:0", "segment_ids:0", "input_mask:0", "input_ids:0" }, gpuDeviceId: useGpu ? 0 : (int?)null); return pipeline.Fit(_mlContext.Data.LoadFromEnumerable(new List<BertInput>())); } } }
在上述方法中,我们构建了管道。这里我们应用ONNX模型并将数据模型连接到BERT ONNX模型的层。请注意,我们有一个标志,可用于在CPU或GPU上训练此模型。最后,我们将此模型拟合到空数据。我们这样做,所以我们可以加载数据模式,即。加载模型。
该预测类就更简单了。它接收经过训练和加载的模型并创建预测引擎。然后它使用这个预测引擎来创建对新图像的预测。
using BertMlNet.MachineLearning.DataModel; using Microsoft.ML; namespace BertMlNet.MachineLearning { public class Predictor { private MLContext _mLContext; private PredictionEngine<BertInput, BertPredictions> _predictionEngine; public Predictor(ITransformer trainedModel) { _mLContext = new MLContext(); _predictionEngine = _mLContext.Model .CreatePredictionEngine<BertInput, BertPredictions>(trainedModel); } public BertPredictions Predict(BertInput encodedInput) { return _predictionEngine.Predict(encodedInput); } } }
有一个辅助类和两个扩展类。辅助类 FileReader有一个读取文本文件的方法。我们稍后使用它从文件中加载词汇。这很简单:
using System.Collections.Generic; using System.IO; namespace BertMlNet.Helpers { public static class FileReader { public static List<string> ReadFile(string filename) { var result = new List<string>(); using (var reader = new StreamReader(filename)) { string line; while ((line = reader.ReadLine()) != null) { if (!string.IsNullOrWhiteSpace(line)) { result.Add(line); } } } return result; } } }
有两个扩展类。一个用于对元素集合执行Softmax操作,另一个用于拆分字符串并一次输出一个结果。
using System; using System.Collections.Generic; using System.Linq; namespace BertMlNet.Extensions { public static class SoftmaxEnumerableExtension { public static IEnumerable<(T Item, float Probability)> Softmax<T>( this IEnumerable<T> collection, Func<T, float> scoreSelector) { var maxScore = collection.Max(scoreSelector); var sum = collection.Sum(r => Math.Exp(scoreSelector(r) - maxScore)); return collection.Select(r => (r, (float)(Math.Exp(scoreSelector(r) - maxScore) / sum))); } } }
using System.Collections.Generic; namespace BertMlNet.Extensions { static class StringExtension { public static IEnumerable<string> SplitAndKeep( this string inputString, params char[] delimiters) { int start = 0, index; while ((index = inputString.IndexOfAny(delimiters, start)) != -1) { if (index - start > 0) yield return inputString.Substring(start, index - start); yield return inputString.Substring(index, 1); start = index + 1; } if (start < inputString.Length) { yield return inputString.Substring(start); } } } }
好的,到目前为止,我们已经探索了解决方案的简单部分。让我们继续处理更复杂和更重要的部分,让我们看看我们是如何实现标记化的。首先,我们定义默认BERT令牌列表。例如,应始终用标记分隔两个句子 [SEP] 以区分它们。该 [CLS] 令牌总是出现在文本的开始,并具体到分类任务。
namespace BertMlNet.Tokenizers { public class Tokens { public const string Padding = ""; public const string Unknown = "[UNK]"; public const string Classification = "[CLS]"; public const string Separation = "[SEP]"; public const string Mask = "[MASK]"; } }
Tokenization的过程在Tokenizer类中完成。有两种公共方法: Tokenize 和 Untokenize。 第一个首先将接收到的文本分成句子。然后对于每个句子,每个单词都被转换为嵌入。请注意,可能会发生一个单词用多个标记表示的情况。
例如,单词“embeddings”表示为标记数组 ['em', '##bed', '##ding', '##s']。该词已被拆分为更小的子词和字符。其中一些子词前面的两个井号只是我们的标记器用来表示这个子词或字符是一个更大词的一部分并在另一个子词之前的方式。
因此,例如,'##bed' 标记与 'bed' 标记是分开的。Tokenize方法正在做的另一件事是返回词汇索引和分段索引。两者都用作BERT输入。要了解更多为什么这样做,请查看这篇文章。
using BertMlNet.Extensions; using System; using System.Collections.Generic; using System.Linq; namespace BertMlNet.Tokenizers { public class Tokenizer { private readonly List<string> _vocabulary; public Tokenizer(List<string> vocabulary) { _vocabulary = vocabulary; } public List<(string Token, int VocabularyIndex, long SegmentIndex)> Tokenize(params string[] texts) { IEnumerable<string> tokens = new string[] { Tokens.Classification }; foreach (var text in texts) { tokens = tokens.Concat(TokenizeSentence(text)); tokens = tokens.Concat(new string[] { Tokens.Separation }); } var tokenAndIndex = tokens .SelectMany(TokenizeSubwords) .ToList(); var segmentIndexes = SegmentIndex(tokenAndIndex); return tokenAndIndex.Zip(segmentIndexes, (tokenindex, segmentindex) => (tokenindex.Token, tokenindex.VocabularyIndex, segmentindex)).ToList(); } public List<string> Untokenize(List<string> tokens) { var currentToken = string.Empty; var untokens = new List<string>(); tokens.Reverse(); tokens.ForEach(token => { if (token.StartsWith("##")) { currentToken = token.Replace("##", "") + currentToken; } else { currentToken = token + currentToken; untokens.Add(currentToken); currentToken = string.Empty; } }); untokens.Reverse(); return untokens; } public IEnumerable<long> SegmentIndex(List<(string token, int index)> tokens) { var segmentIndex = 0; var segmentIndexes = new List<long>(); foreach (var (token, index) in tokens) { segmentIndexes.Add(segmentIndex); if (token == Tokens.Separation) { segmentIndex++; } } return segmentIndexes; } private IEnumerable<(string Token, int VocabularyIndex)> TokenizeSubwords(string word) { if (_vocabulary.Contains(word)) { return new (string, int)[] { (word, _vocabulary.IndexOf(word)) }; } var tokens = new List<(string, int)>(); var remaining = word; while (!string.IsNullOrEmpty(remaining) && remaining.Length > 2) { var prefix = _vocabulary.Where(remaining.StartsWith) .OrderByDescending(o => o.Count()) .FirstOrDefault(); if (prefix == null) { tokens.Add((Tokens.Unknown, _vocabulary.IndexOf(Tokens.Unknown))); return tokens; } remaining = remaining.Replace(prefix, "##"); tokens.Add((prefix, _vocabulary.IndexOf(prefix))); } if (!string.IsNullOrWhiteSpace(word) && !tokens.Any()) { tokens.Add((Tokens.Unknown, _vocabulary.IndexOf(Tokens.Unknown))); } return tokens; } private IEnumerable<string> TokenizeSentence(string text) { // remove spaces and split the , . : ; etc.. return text.Split(new string[] { " ", " ", "\r\n" }, StringSplitOptions.None) .SelectMany(o => o.SplitAndKeep(".,;:\\/?!#$%()=+-*\"'–_`<>&^@{}[]|~'".ToArray())) .Select(o => o.ToLower()); } } }
另一种公共方法是 Untokenize。此方法用于反转该过程。基本上,作为 BERT 的输出,我们将得到各种嵌入。这种方法的目标是将这些信息转换成有意义的句子。
这个类有多个方法来启用这个过程。
该Bert类将所有这些东西放在一起。在构造函数中,我们读取词汇文件并实例化 Train、Tokenizer和 Predictor对象。只有一种公共方法——Predict。此方法接收上下文和问题。作为输出,检索具有概率的答案:
using BertMlNet.Extensions; using BertMlNet.Helpers; using BertMlNet.MachineLearning; using BertMlNet.MachineLearning.DataModel; using BertMlNet.Tokenizers; using System.Collections.Generic; using System.Linq; namespace BertMlNet { public class Bert { private List<string> _vocabulary; private readonly Tokenizer _tokenizer; private Predictor _predictor; public Bert(string vocabularyFilePath, string bertModelPath) { _vocabulary = FileReader.ReadFile(vocabularyFilePath); _tokenizer = new Tokenizer(_vocabulary); var trainer = new Trainer(); var trainedModel = trainer.BuidAndTrain(bertModelPath, false); _predictor = new Predictor(trainedModel); } public (List<string> tokens, float probability) Predict(string context, string question) { var tokens = _tokenizer.Tokenize(question, context); var input = BuildInput(tokens); var predictions = _predictor.Predict(input); var contextStart = tokens.FindIndex(o => o.Token == Tokens.Separation); var (startIndex, endIndex, probability) = GetBestPrediction(predictions, contextStart, 20, 30); var predictedTokens = input.InputIds .Skip(startIndex) .Take(endIndex + 1 - startIndex) .Select(o => _vocabulary[(int)o]) .ToList(); var connectedTokens = _tokenizer.Untokenize(predictedTokens); return (connectedTokens, probability); } private BertInput BuildInput(List<(string Token, int Index, long SegmentIndex)> tokens) { var padding = Enumerable.Repeat(0L, 256 - tokens.Count).ToList(); var tokenIndexes = tokens.Select(token => (long)token.Index).Concat(padding).ToArray(); var segmentIndexes = tokens.Select(token => token.SegmentIndex).Concat(padding).ToArray(); var inputMask = tokens.Select(o => 1L).Concat(padding).ToArray(); return new BertInput() { InputIds = tokenIndexes, SegmentIds = segmentIndexes, InputMask = inputMask, UniqueIds = new long[] { 0 } }; } private (int StartIndex, int EndIndex, float Probability) GetBestPrediction(BertPredictions result, int minIndex, int topN, int maxLength) { var bestStartLogits = result.StartLogits .Select((logit, index) => (Logit: logit, Index: index)) .OrderByDescending(o => o.Logit) .Take(topN); var bestEndLogits = result.EndLogits .Select((logit, index) => (Logit: logit, Index: index)) .OrderByDescending(o => o.Logit) .Take(topN); var bestResultsWithScore = bestStartLogits .SelectMany(startLogit => bestEndLogits .Select(endLogit => ( StartLogit: startLogit.Index, EndLogit: endLogit.Index, Score: startLogit.Logit + endLogit.Logit ) ) ) .Where(entry => !(entry.EndLogit < entry.StartLogit || entry.EndLogit - entry.StartLogit > maxLength || entry.StartLogit == 0 && entry.EndLogit == 0 || entry.StartLogit < minIndex)) .Take(topN); var (item, probability) = bestResultsWithScore .Softmax(o => o.Score) .OrderByDescending(o => o.Probability) .FirstOrDefault(); return (StartIndex: item.StartLogit, EndIndex: item.EndLogit, probability); } } }
public (List<string> tokens, float probability) Predict(string context, string question) { var tokens = _tokenizer.Tokenize(question, context); var input = BuildInput(tokens); var predictions = _predictor.Predict(input); var contextStart = tokens.FindIndex(o => o.Token == Tokens.Separation); var (startIndex, endIndex, probability) = GetBestPrediction(predictions, contextStart, 20, 30); var predictedTokens = input.InputIds .Skip(startIndex) .Take(endIndex + 1 - startIndex) .Select(o => _vocabulary[(int)o]) .ToList(); var connectedTokens = _tokenizer.Untokenize(predictedTokens); return (connectedTokens, probability); }
首先,此方法对问题和传递的上下文(基于哪个BERT应该给出答案的段落)进行标记化。然后我们根据这些信息构建 BertInput。这是在BertInput方法中完成的 。基本上,所有标记化的信息都被填充,因此它可以用作BERT输入并用于初始化 BertInput对象。
然后我们从Predictor得到模型的 预测。然后对这些信息进行额外处理,并从上下文中找到最佳预测。意思是,BERT从上下文中选择最有可能成为答案的单词,然后我们选择最好的单词。最后,这些词是未标记的。
程序正在利用我们在Bert类中实现的内容。首先,让我们定义启动设置:
{ "profiles": { "BERT.Console": { "commandName": "Project", "commandLineArgs": "\"Jim is walking through the woods.\" \"What is his name?\"" } } }
我们定义了两个命令行参数:“Jim is walking throught the woods.” 和“What is his name?”。正如我们已经提到的,第一个是context,第二个是question。的主要方法是最小的:
using System; using System.Text.Json; namespace BertMlNet { class Program { static void Main(string[] args) { var model = new Bert("..\\BertMlNet\\Assets\\Vocabulary\\vocab.txt", "..\\BertMlNet\\Assets\\Model\\bertsquad-10.onnx"); var (tokens, probability) = model.Predict(args[0], args[1]); Console.WriteLine(JsonSerializer.Serialize(new { Probability = probability, Tokens = tokens })); } } }
从技术上讲,我们使用词汇文件的路径和模型的路径创建 Bert 对象。然后我们使用命令行参数调用Predict方法。作为输出,我们得到:
{"Probability":0.9111285,"Tokens":["jim"]}
我们可以看到BERT有91%的把握确定问题的答案是“Jim”并且是正确的。
在本文中,我们了解了BERT的工作原理。更具体地说,我们有机会探索Transformers架构的不同工作方式以及BERT如何利用该架构来理解语言。最后,我们了解了ONNX模型格式以及如何在ML.NET中使用它。
https://rubikscode.net/2021/04/19/machine-learning-with-ml-net-nlp-with-bert/