C---机器学习实用指南第二版-四-

91 阅读1小时+

C++ 机器学习实用指南第二版(四)

原文:annas-archive.org/md5/0094b5c825f8a9257e51e53be7bb10aa

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:使用 BERT 和迁移学习进行情感分析

Transformer 架构是一种神经网络模型,在自然语言处理NLP)领域获得了显著的流行。它首次在 Vaswani 等人于 2017 年发表的一篇论文中提出。Transformer 的主要优势是其处理并行处理的能力,这使得它比 RNNs 更快。Transformer 的另一个重要优势是它处理序列中长距离依赖的能力。这是通过使用注意力机制实现的,允许模型在生成输出时关注输入的特定部分。

近年来,Transformer 已被应用于广泛的 NLP 任务,包括机器翻译、问答和摘要。其成功可以归因于其简单性、可扩展性和在捕捉长期依赖方面的有效性。然而,像任何模型一样,Transformer 也有一些局限性,例如其高计算成本和对大量训练数据的依赖。尽管存在这些局限性,Transformer 仍然是 NLP 研究人员和从业者的一项强大工具。使其成为可能的一个因素是其能够使用和适应已经预训练的网络,以特定任务以更低的计算资源和训练数据量。

转移学习主要有两种方法,称为微调迁移学习。微调是一个进一步调整预训练模型以更好地适应特定任务或数据集的过程。这涉及到解冻预训练模型中的某些或所有层,并在新数据上训练它们。迁移学习的过程通常涉及取一个预训练模型,移除针对原始任务特定的最终层,并添加新层或修改现有层以适应新任务。模型隐藏层的参数通常在训练阶段被冻结。这是与微调的主要区别之一。

本章将涵盖以下主题:

  • Transformer 架构的一般概述

  • 对 Transformer 的主要组件及其协同工作方式的简要讨论

  • 如何将迁移学习技术应用于构建新的情感分析模型的示例

技术要求

本章的技术要求如下:

  • PyTorch 库

  • 支持 C++20 的现代 C++ 编译器

  • CMake 构建系统版本 >= 3.22

本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-second-edition/tree/master/Chapter11/pytorch

请按照以下文档中描述的说明配置开发环境:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/blob/main/env_scripts/README.md

此外,您还可以探索该文件夹中的脚本以查看配置细节。

要构建本章的示例项目,您可以使用以下脚本:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/blob/main/build_scripts/build_ch11.sh

Transformer 架构概述

Transformer 是一种神经网络架构,它首次由谷歌研究人员在论文《Attention Is All You Need》中提出。由于其能够处理长距离依赖关系和注意力机制,它已经在 NLP 和其他领域得到广泛应用。以下方案展示了 Transformer 的一般架构:

图 11.1 – Transformer 架构

图 11.1 – Transformer 架构

Transformer 架构有两个主要组件:编码器和解码器。编码器处理输入序列,而解码器生成输出序列。这些 Transformer 组件的常见元素如下:

  • 自注意力机制:该模型使用自注意力机制来学习输入序列不同部分之间的关系。这使得它能够捕捉到长距离依赖关系,这对于理解自然语言中的上下文非常重要。

  • 交叉注意力机制:这是一种在处理两个或更多序列时使用的注意力机制。在这种情况下,一个序列的元素关注另一个序列的元素,从而使模型能够学习两个输入之间的关系。它用于编码器和解码器部分之间的通信。

  • 多头注意力:Transformer 不是使用单个注意力机制,而是使用多个注意力头,每个头都有其自己的权重。这有助于模拟输入序列的不同方面,并提高模型的表示能力。

  • 位置编码:由于 Transformer 不使用循环或卷积层,它需要一种方法来保留输入序列的位置信息。位置编码用于将此信息添加到输入中。这是通过向嵌入向量添加不同频率的正弦和余弦函数来实现的。

  • 前馈神经网络:在注意力层之间,存在全连接的前馈神经网络。这些网络有助于将注意力层的输出转换为更有意义的表示。

  • 残差连接和归一化:与许多深度学习模型一样,Transformer 包含残差连接和批量归一化,以提高训练的稳定性和收敛性。

让我们详细看看编码器和解码器部分解决的主要差异和任务。

编码器

Transformer 的 编码器 部分负责将输入数据编码为固定长度的向量表示,称为嵌入。这个嵌入捕获了输入中的重要特征和信息,并以更抽象的形式表示它。编码器通常由多层自注意力机制和前馈网络组成。编码器的每一层都有助于细化并改进输入的表示,捕获更复杂的模式和依赖关系。

在内部表示方面,编码器产生的嵌入可以是上下文或位置的。上下文嵌入专注于从文本中捕获语义和句法信息,而位置嵌入编码关于序列中单词的顺序和位置的信息。这两种类型的嵌入都在捕捉句子中单词的上下文和关系方面发挥作用:

  • 上下文嵌入允许模型根据上下文理解单词的意义,考虑到周围的单词及其关系

  • 位置嵌入另一方面提供关于序列中每个单词位置的信息,帮助模型理解文本的顺序和结构

在编码器中,上下文和位置嵌入共同帮助 Transformer 更好地理解和表示输入数据,使其能够在下游任务中生成更准确和有意义的输出。

解码器

Transformer 的 解码器 部分以编码表示作为输入并生成最终输出。它由与编码器类似的多层注意力和前馈网络组成。然而,它还包括额外的层,使其能够预测和生成输出。解码器根据输入序列的编码表示及其自己的先前预测来预测序列中的下一个标记。

Transformer 解码器的输出概率代表每个可能的标记作为序列中下一个单词的可能性。这些概率使用 softmax 函数计算,该函数根据标记与上下文的相关性为其分配概率值。

在训练过程中,解码器使用编码嵌入生成预测并将其与真实输出进行比较。预测输出和真实输出之间的差异用于更新解码器的参数并提高其性能。

输出采样是解码器过程的一个重要部分。它涉及根据输出概率选择下一个标记。有许多采样输出的方法。以下列表显示了其中一些流行的方法:

  • 贪婪搜索:这是最简单的采样方法,在每个步骤根据标记值的 softmax 概率分布选择最可能的标记。虽然这种方法快速且易于实现,但它可能并不总是找到最优解。

  • Top-k 采样:Top-k 采样在每个步骤从 softmax 分布中选择概率最高的前k个标记,而不是选择最可能的标记。这种方法可以帮助多样化样本,防止模型陷入局部最优。

  • 核采样:核采样,也称为 top-p 采样,是 top-k 采样的一个变体,它根据概率从 softmax 分布的顶部选择一个子集的标记。通过在概率范围内选择多个标记,核采样可以提高输出的多样性和覆盖率。

您现在已经了解了主要 Transformer 组件的工作原理以及其中使用的元素。但这也留下了在解码器处理之前如何对输入进行预处理的话题。让我们看看如何将输入文本转换为 Transformer 输入。

分词

分词是将文本序列分解成更小的单元,称为标记的过程。这些标记可以是单个单词、子词,甚至字符,具体取决于特定任务和模型架构。例如,在句子“我爱吃披萨”中,标记将是“I”,“爱”,“吃”,和“披萨”。

在 Transformer 模型中使用了多种分词方法。最常用的有以下几种:

  • 单词分词:这种方法将文本分割成单个单词。这是最常见的方法,适用于翻译和文本分类等任务。

  • 子词分词:在这种方法中,文本被分割成更小的单元,称为子词。这可以提高在单词经常拼写错误或截断的任务上的性能,如机器翻译。

  • 字符分词:这种方法将文本分解成单个字符。对于需要细粒度分析的任务,如情感分析,这可能很有用。

分词方法的选取取决于数据集的特征和任务的需求数据。

在接下来的小节中,我们将使用 BERT 模型,该模型使用[CLS](分类)和[SEP](分隔符)。这些标记在模型架构中具有特定用途。以下是 WordPiece 分词算法的逐步解释:

  1. 初始词汇表:从一个包含模型使用的特殊标记和初始字母表的小型词汇表开始。初始字母表包含单词开头和 WordPiece 前缀后的所有字符。

  2. ##(用于 BERT)将每个单词中的每个字符分开,例如将“word”分割成w ##o ##r ##d。这将从原始单词中的每个字符创建子词。

  3. score = (freq_of_pair)/(freq_of_first_element×freq_of_second_element)。这优先合并那些个体部分在词汇表中出现频率较低的配对。

  4. 合并对:算法合并得分高的对,这意味着算法将出现频率较低的合并对合并成单个元素。

  5. 迭代合并:这个过程会重复进行,直到完成所需数量的合并操作或达到预定义的阈值。此时,创建最终的词汇表。

使用词汇表,我们可以以类似于我们在词汇构建过程中之前所做的方式对输入中的任何单词进行标记化。因此,首先我们在词汇表中搜索整个单词。如果我们找不到,我们就从前面带有##前缀的子词开始移除一个字符,然后再次搜索。这个过程会一直持续到我们找到一个子词。如果我们在一个词汇表中找不到任何单词的标记,我们通常会跳过这个单词。

词汇表通常表示为一个字典或查找表,将每个单词映射到唯一的整数索引。词汇表大小是 Transformer 性能的一个重要因素,因为它决定了模型的复杂性和处理不同类型文本的能力。

单词嵌入

尽管我们使用标记化将单词转换为数字,但这并不为神经网络提供任何语义意义。为了能够表示语义邻近性,我们可以使用嵌入。嵌入是将任意实体映射到特定向量的地方,例如,图中的一个节点,图片中的一个对象,或单词的定义。一组嵌入向量可以被视为向量意义空间。

创建单词嵌入有许多方法。例如,在经典 NLP 中最知名的是 Word2Vec 和 GloVe,它们基于统计分析,实际上是独立的模型。

对于 Transformer,提出了不同的方法。以标记作为输入,我们将它们传递到 MLP 层,该层为每个标记输出嵌入向量。这一层与模型的其他部分一起训练,是内部模型组件。这样,我们可以拥有针对特定训练数据和特定任务的更精细调整的嵌入。

分别使用编码器和解码器部分

原始的 Transformer 架构包含编码器和解码器两部分。但最近发现,这些部分可以分别用于解决不同的任务。基于它们的两个最著名的基架构是 BERT 和 GPT。

BERT代表来自变换器的双向编码器表示。它是一个最先进的 NLP 模型,使用双向方法来理解句子中单词的上下文。与仅考虑一个方向的单词的传统模型不同,BERT 可以查看给定单词之前和之后的单词,以更好地理解其含义。它仅基于编码器变换器部分。这使得它在需要理解上下文的任务中特别有用,例如语义相似度和文本分类。此外,它旨在理解双向上下文,这意味着它可以考虑句子中的前一个和后一个单词。

另一方面,GPT,即生成式预训练变换器,是一个仅基于解码器变换器部分的生成模型。它通过预测序列中的下一个单词来生成类似人类的文本。

在下一节中,我们将使用 PyTorch 库和 BERT 作为基础模型来开发一个情感分析模型。

基于 BERT 的情感分析示例

在本节中,我们将构建一个机器学习模型,该模型可以使用 PyTorch 检测评论情感(检测评论是正面还是负面)。作为训练集,我们将使用大型电影评论数据集,其中包含用于训练的 25,000 条电影评论和用于测试的 25,000 条评论,两者都高度两极分化。

如我们之前所说,我们将使用已经预训练的 BERT 模型。BERT 之所以被选中,是因为它能够理解单词之间的上下文和关系,这使得它在问答、情感分析和文本分类等任务中特别有效。让我们记住,迁移学习是一种机器学习方法,涉及将知识从预训练模型转移到新的或不同的问题域。当特定任务缺乏标记数据时,或者从头开始训练模型过于计算昂贵时,会使用迁移学习。

应用迁移学习算法包括以下步骤:

  1. 选择预训练模型:应根据任务的相关性来选择模型。

  2. 添加新的任务特定头:例如,这可能是全连接线性层与最终的 softmax 分类的组合。

  3. 冻结预训练参数:冻结参数允许模型保留其预学习的知识。

  4. 在新数据集上训练:模型使用预训练权重和新数据的组合进行训练,使其能够在新的层中学习与新领域相关的特定特征和模式。

我们知道 BERT 类模型用于从输入数据中提取一些语义知识;在我们的案例中,它将是文本。BERT 类模型通常以嵌入向量的形式表示提取的知识。这些向量可以用来训练新的模型头,例如,用于分类任务。我们将遵循之前描述的步骤。

导出模型和词汇表

使用 PyTorch 库在 C++中通过某些预训练模型的传统方法是将此模型作为 TorchScript 加载。获取此脚本的一种常见方法是通过追踪 Python 中可用的模型并保存它。在huggingface.co/网站上有很多预训练模型。此外,这些模型还提供了 Python API。因此,让我们编写一个简单的 Python 程序来导出基础 BERT 模型:

  1. 以下代码片段展示了如何导入所需的 Python 模块并加载用于追踪的预训练模型:

    import torch
    from transformers import BertModel, BertTokenizer
    model_name = "bert-base-cased"
    tokenizer =BertTokenizer.from_pretrained(model_name,
                                             torchscript = True)
    bert = BertModel.from_pretrained(model_name, torchscript=True)
    

    我们从transformers模块中导入了BertModelBertTokenizer类,这是 Hugging Face 的库,允许我们使用不同的基于 Transformer 的模型。我们使用了bert-base-cased模型,这是在大型文本语料库上训练以理解通用语言语义的原始 BERT 模型,我们还加载了专门针对 BERT 模型的分词器模块——BertTokenizer。注意,我们还使用了torchscript=True参数来能够追踪和保存模型。此参数告诉库使用适合 Torch JIT 追踪的操作符和模块。

  2. 现在我们有了加载的分词器对象,我们可以按照以下方式对一些样本文本进行分词以进行追踪:

    max_length = 128
    tokenizer_out = tokenizer(text,
                              padding = "max_length",
                              max_length = max_length,
                              truncation = True,
                              return_tensors = "pt", )
    attention_mask = tokenizer_out.attention_mask
    input_ids = tokenizer_out.input_ids
    

    在这里,我们定义了可以生成的最大标记数,即128。我们加载的 BERT 模型一次可以处理最多 512 个标记,因此您应该根据您的任务配置此数字,例如,您可以使用更少的标记数量以满足嵌入式设备上的性能限制。此外,我们告诉分词器截断较长的序列并填充较短的序列到max_length。我们还通过指定return_tensors="pt"使分词器返回 PyTorch 张量。

    我们使用了分词器返回的两个值:input_ids,它是标记值,以及attention_mask,它是一个二进制掩码,对于真实标记填充1,对于不应处理的填充标记填充0

  3. 现在我们有了标记和掩码,我们可以按照以下方式导出模型:

    model.eval()
    traced_script_module = torch.jit.trace(model,
                                           [ input_ids,
                                           attention_mask ])
    traced_script_module.save("bert_model.pt")
    

    我们将模型切换到评估模式,因为它将被用于追踪,而不是用于训练。然后,我们使用torch.jit.trace函数在样本输入上追踪模型,样本输入是我们生成的标记和注意力掩码的元组。我们使用追踪模块的save方法将模型脚本保存到文件中。

  4. 除了模型之外,我们还需要导出分词器的词汇表,如下所示:

    vocab_file = open("vocab.txt", "w")
    for i, j in tokenizer.get_vocab().items():
        vocab_file.write(f"{i} {j}\n")
    vocab_file.close()
    

在这里,我们只是遍历了分词器对象中所有可用的令牌,并将它们作为[value - id]对保存到文本文件中。

实现分词器

我们可以直接使用 PyTorch C++ API 加载保存的脚本模型,但不能对分词器做同样的事情。此外,PyTorch C++ API 中没有分词器的实现。因此,我们必须自己实现分词器:

  1. 最简单的分词器实际上可以很容易地实现。它可以为头文件定义以下内容:

    #include <torch/torch.h>
    #include <string>
    #include <unordered_map>
    class Tokenizer {
     public:
      Tokenizer(const std::string& vocab_file_path,
                int max_len = 128);
      std::pair<torch::Tensor, torch::Tensor> tokenize(
          const std::string text);
     private:
      std::unordered_map<std::string, int> vocab_;
      int max_len_{0};
    }
    

    我们定义了一个Tokenizer类,它有一个构造函数,该构造函数接受词汇文件名和要生成的令牌序列的最大长度。我们还定义了一个单独的方法tokenize,它接受输入文本作为参数。

  2. 构造函数可以如下实现:

    Tokenizer::Tokenizer(const std::string& vocab_file_path,
                         int max_len)
        : max_len_{max_len} {
      auto file = std::ifstream(vocab_file_path);
      std::string line;
      while (std::getline(file, line)) {
        auto sep_pos = line.find_first_of(' ');
        auto token = line.substr(0, sep_pos);
        auto id = std::stoi(line.substr(sep_pos + 1));
        vocab_.insert({token, id});
      }
    }
    

    我们简单地打开给定的文本文件,逐行读取。我们将每一行分割成两个组件,即令牌字符串值和相应的 ID。这些组件由空格字符分隔。此外,我们将idstring转换为integer值。所有解析的令牌 ID 对都保存到std::unordered_map容器中,以便能够有效地搜索令牌 ID。

  3. tokenize方法的实现稍微复杂一些。我们定义如下:

    std::pair<torch::Tensor, torch::Tensor> Tokenizer::tokenize(const std::string text) {
      std::string pad_token = "[PAD]";
      std::string start_token = "[CLS]";
      std::string end_token = "[SEP]";
      auto pad_token_id = vocab_[pad_token];
      auto start_token_id = vocab_[start_token];
      auto end_token_id = vocab_[end_token];
    

    在这里,我们从加载的词汇表中获得了特殊的令牌 ID 值。这些令牌 ID 是 BERT 模型正确处理输入所必需的。

  4. 当我们的输入文本太短时,PAD 令牌用于标记空令牌。可以这样做:

      std::vector<int> input_ids(max_len_, pad_token_id);
      std::vector<int> attention_mask(max_len_, 0);
      input_ids[0] = start_token_id;
      attention_mask[0] = 1;
    

    就像在 Python 程序中一样,我们创建了两个向量,一个用于令牌 ID,另一个用于注意力掩码。我们使用pad_token_id作为令牌 ID 的默认值,并用零填充注意力掩码。然后,我们将start_token_id作为第一个元素,并在注意力掩码中放入相应的值。

  5. 定义了输出容器后,我们现在定义输入文本处理的中间对象,如下所示:

      std::string word;
      std::istringstream ss(text);
    

    我们将输入文本字符串移动到stringstream对象中,以便能够逐词读取。我们还定义了相应的单词字符串对象。

  6. 顶层处理周期可以如下定义:

      int input_id = 1;
      while (getline(ss, word, ' ')) {
      // search token in the vocabulary and increment input_id
      if (input_id == max_len_ - 1) {
        break;
      }
    }
    

    在这里,我们使用了getline函数,通过空格字符作为分隔符将输入字符串流分割成单词。这是一种简单的分割输入文本的方法。通常,分词器会使用更复杂的策略进行分割。但对于我们的任务和我们的数据集来说,这已经足够了。此外,我们还添加了检查,如果我们达到最大序列长度,我们就停止文本处理,因此我们截断它。

  7. 然后,我们必须识别第一个单词中最长可能的、存在于词汇表中的前缀;它可能是一个完整的单词。实现开始如下:

      size_t start = 0;
    while (start < word.size()) {
      size_t end = word.size();
      std::string token;
      bool has_token = false;
      while (start < end) {
        // search the prefix in the vocabulary
        end--;
      }
      if (input_id == max_len_ - 1) {
        break;
      }
      if (!has_token) {
        break;
      }
      start = end;
    }
    

    在这里,我们定义了start变量来跟踪单词前缀的开始位置,它被初始化为 0,即当前单词的开始位置。我们定义了end变量来跟踪前缀的结束位置,它被初始化为当前单词的长度,即单词的最后一个位置。最初,它们指向单词的开始和结束。然后,在内循环中,我们通过递减end变量来连续减小前缀的大小。每次递减后,如果我们没有在词汇表中找到前缀,我们就重复这个过程,直到单词的末尾。此外,我们还确保在成功搜索到标记后,交换startend变量以分割单词,并继续对单词剩余部分的前缀搜索。这样做是因为一个单词可以由多个标记组成。此外,在这段代码中,我们检查了最大标记序列长度以停止整个标记搜索过程。

  8. 下一个步骤是我们对词汇表执行前缀搜索:

      auto token = word.substr(start, end - start);
    if (start > 0) 
      token = "##" + token;
    auto token_iter = vocab_.find(token);
    if (token_iter != vocab_.end()) {
      attention_mask[input_id] = 1;
      input_ids[input_id] = token_iter->second;
      ++input_id;
      has_token = true;
      break;
    }
    

    在这里,我们使用substr函数从原始单词中提取前缀。如果start变量不是0,我们正在处理单词的内部部分,因此执行了添加##特殊前缀的操作。我们使用了无序映射容器中的find方法来查找标记(前缀)。如果搜索成功,我们将标记 ID 放置在input_ids容器的下一个位置,在attention_mask中做出相应的标记,增加input_id索引以移动当前序列位置,并中断循环以开始处理下一个单词部分。

  9. 在实现填充输入 ID 和注意力掩码的代码之后,我们将它们放入 PyTorch 张量对象中,并按如下方式返回输出:

      attention_mask[input_id] = 1;
      input_ids[input_id] = end_token_id;
      auto input_ids_tensor = torch::tensor(
                                 input_ids).unsqueeze(0);
      auto attention_masks_tensor = torch::tensor(
                                      attention_mask).unsqueeze(0);
    return std::make_pair(input_ids_tensor,
                          attention_masks_ tensor);
    

    在将输入 ID 和掩码转换为张量之前,我们使用end_token_id值最终确定了标记 ID 序列。然后,我们使用torch.tensor函数创建张量对象。这个函数可以接受不同的输入,其中之一是只包含数值的std::vector。我们还使用了unsqueeze函数向张量中添加批处理维度。我们还将最终的张量作为标准对返回。

在以下小节中,我们将使用实现的标记器实现数据集加载器类。

实现数据集加载器

我们必须开发解析器和数据加载器类,以便将数据集以适合与 PyTorch 一起使用的方式移动到内存中:

  1. 让我们从解析器开始。我们拥有的数据集组织如下:有两个文件夹用于训练集和测试集,每个文件夹中包含两个子文件夹,分别命名为posneg,正负评论文件分别放置在这些子文件夹中。数据集中的每个文件恰好包含一条评论,其情感由其所在的文件夹决定。在下面的代码示例中,我们将定义读取类的接口:

    #include <string>
    #include <vector>
    class ImdbReader {
     public:
        ImdbReader(const std::string& root_path);
        size_t get_pos_size() const;
        size_t get_neg_size() const;
        const std::string& get_pos(size_t index) const;
        const std::string& get_neg(size_t index) const;
    private:
    using Reviews = std::vector<std::string>;
    void read_ directory(const std::string& path,
                         Reviews& reviews);
     private:
        Reviews pos_samples_;
        Reviews neg_samples_;
        size_t max_size_{0};
    };
    

    我们定义了两个向量pos_samples_neg_samples_,它们包含从相应文件夹中读取的评论。

  2. 我们将假设这个类的对象应该用放置数据集之一(训练集或测试集)的根文件夹路径进行初始化。我们可以按以下方式初始化:

    int main(int argc, char** argv) {
      if (argc > 0) {
        auto root_path = fs::path(argv[1]);
        … ImdbReader train_reader(root_path / "train");
        ImdbReader test_reader(root_path / "test");
      }
    }
    

    这个类最重要的部分是constructorread_directory方法。

  3. 构造函数是主要点,其中我们填充容器pos_samples_neg_samples_,以包含来自posneg文件夹的实际评论:

    namespace fs = std::filesystem;
    ImdbReader::ImdbReader(const std::string& root_path) {
      auto root = fs::path(root_path);
      auto neg_path = root / "neg";
      auto pos_path = root / "pos";
      if (fs::exists(neg_path) && fs::exists(pos_path)) {
        auto neg = std::async(std::launch::async, [&]() {
          read_directory(neg_path, neg_samples_);
        });
        auto pos = std::async(std::launch::async, [&]() {
          read_directory(pos_path, pos_samples_);
        });
        neg.get();
        pos.get();
      } else {
    throw std::invalid_argument("ImdbReader incorrect 
                                 path");
      }
    }
    
  4. read_directory方法实现了遍历给定目录中文件的逻辑,并按以下方式读取:

    void ImdbReader::read_directory(const std::string& path,
                                    Reviews& reviews) {
      for (auto& entry : fs::directory_iterator(path)) {
        if (fs::is_regular_file(entry)) {
          std::ifstream file(entry.path());
          if (file) {
            std::stringstream buffer;
            buffer << file.rdbuf();
            reviews.push_back(buffer.str());
          }
        }
      }
    }
    

    我们使用了标准库目录迭代器类fs::directory_iterator来获取文件夹中的每个文件。这个类的对象返回fs::directory_entry类的对象,这个对象可以用is_regular_file方法来确定它是否是一个常规文件。我们用path方法获取了这个条目的文件路径。我们使用std::ifstream类型对象的rdbuf方法将整个文件读取到一个字符串对象中。

    现在已经实现了ImdbReader类,我们可以进一步开始数据集的实现。我们的数据集类应该返回一对项:一个表示标记化文本,另一个表示情感值。此外,我们需要开发一个自定义函数来将批次中的张量向量转换为单个张量。如果我们想使 PyTorch 与我们的自定义训练数据兼容,这个函数是必需的。

  5. 让我们定义一个自定义训练数据样本的ImdbSample类型。我们将与torch::data::Dataset类型一起使用它:

    using ImdbData = std::pair<torch::Tensor, torch::Tensor>;
    using ImdbExample = torch::data::Example<ImdbData,
                                             torch::Tensor>;
    

    ImdbData代表训练数据,包含用于标记序列和注意力掩码的两个张量。ImdbSample代表整个样本,包含一个目标值。张量包含10,分别表示正面或负面情感。

  6. 以下代码片段展示了ImdbDataset类的声明:

    class ImdbDataset : public torch::data::Dataset<ImdbDataset, ImdbExample> {
     public:
        ImdbDataset(const std::string& dataset_path,
                    std::shared_ptr<Tokenizer> tokenizer);
        // torch::data::Dataset implementation
        ImdbExample get(size_t index) override;
        torch::optional<size_t> size() const override;
     private:
        ImdbReader reader_;
        std::shared_ptr<Tokenizer> tokenizer_;
    };
    

    我们从torch::data::Dataset类继承了我们的数据集类,以便我们可以用它来初始化数据加载器。PyTorch 数据加载器对象负责从训练对象中采样随机对象并从中制作批次。我们的ImdbDataset类对象应该用ImdbReaderTokenizer对象的根数据集路径进行初始化。构造函数的实现很简单;我们只是初始化了读取器并存储了分词器的指针。请注意,我们使用了分词器的指针来在训练集和测试集之间共享它。我们重写了torch::data::Dataset类中的两个方法:getsize方法。

  7. 以下代码展示了我们如何实现size方法:

    torch::optional<size_t> ImdbDataset::size() const {
        return reader_.get_pos_size() + reader_.get_neg_size();
    }
    

    size方法返回ImdbReader对象中的评论数量。

  8. get方法的实现比之前的方法更复杂,如下面的代码所示:

    ImdbExample ImdbDataset::get(size_t index) {
      torch::Tensor target;
      const std::string* review{nullptr};
      if (index < reader_.get_pos_size()) {
        review = &reader_.get_pos(index);
        target = torch::tensor(1, torch::dtype(torch::kLong));
      } else {
        review =
            &reader_.get_neg(index - reader_.get_pos_size());
        target = torch::tensor(0, torch::dtype(torch::kLong));
      }
      // encode text
      auto tokenizer_out = tokenizer_->tokenize(*review);
      return {tokenizer_out, target.squeeze()};
    }
    

    首先,我们从给定的索引(函数参数值)中获取了评论文本和情感值。在 size 方法中,我们返回了总的正面和负面评论数量,所以如果输入索引大于正面评论的数量,那么这个索引指向的是一个负面的评论。然后,我们从它中减去正面评论的数量。

    在我们得到了正确的索引后,我们也得到了相应的文本评论,将其地址分配给 review 指针,并初始化了 target 张量。使用 torch::tensor 函数初始化 target 张量。这个函数接受任意数值和诸如所需类型等张量选项。

    对于评论文本,我们仅使用分词器对象创建了两个包含标记 ID 和情感掩码的张量。它们被打包在 tokenizer_out 对象对中。我们返回了训练张量对和单个目标张量。

  9. 为了能够有效地使用批量训练,我们创建了一个特殊的类,这样 PyTorch 就能将我们的非标准样本类型转换为批处理张量。在最简单的情况下,我们将得到训练样本的 std::vector 对象而不是单个批处理张量。这是通过以下方式完成的:

    torch::data::transforms::Collation<ImdbExample> {
      ImdbExample apply_batch(std::vector<ImdbExample> examples)
          override {
        std::vector<torch::Tensor> input_ids;
        std::vector<torch::Tensor> attention_masks;
        std::vector<torch::Tensor> labels;
        input_ids.reserve(examples.size());
        attention_masks.reserve(examples.size());
        labels.reserve(examples.size());
        for (auto& example : examples) {
          input_ids.push_back(std::move(example.data.first));
          attention_masks.push_back(
              std::move(example.data.second));
          labels.push_back(std::move(example.target));
        }
        return {{torch::stack(input_ids),
                 torch::stack(attention_masks)},
                torch::stack(labels)};
      }
    }
    

我们从特殊的 PyTorch torch::data::transforms::Collation 类型继承了我们自己的类,并通过 ImdbExample 类的模板参数对其进行特殊化。有了这样一个类,我们重写了虚拟的 apply_batch 函数,其实现在输入是包含 ImdbExample 对象的 std::vector 对象,并返回单个 ImdbExample 对象。这意味着我们将所有输入 ID、注意力掩码和目标张量合并到三个单独的张量中。这是通过为输入 ID、注意力掩码和目标张量创建三个单独的容器来完成的。它们通过简单地遍历输入样本来填充。然后,我们仅使用 torch::stack 函数将这些容器合并(堆叠)成单个张量。这个类将在后续的数据加载器类型对象构建中使用。

实现模型

下一步是创建一个 model 类。我们已经有了一个将要用作预训练部分的导出模型。我们创建了一个简单的分类头,包含两个线性全连接层和一个 dropout 层用于正则化:

  1. 这个类的头文件看起来如下:

    #include <torch/script.h>
    #include <torch/torch.h>
    class ModelImpl : public torch::nn::Module {
     public:
        ModelImpl() = delete;
        ModelImpl(const std::string& bert_model_path);
        torch::Tensor forward(at::Tensor input_ids,
                              at::Tensor attention_masks);
     private:
        torch::jit::script::Module bert_;
        torch::nn::Dropout dropout_;
        torch::nn::Linear fc1_;
        torch::nn::Linear fc2_;
    };
    TORCH_MODULE(Model);
    

    我们包含了 torch\script.h 头文件以使用 torch::jit::script::Module 类。这个类的实例将被用作之前导出的 BERT 模型的表示。参见 bert_ 成员变量。我们还定义了线性层和 dropout 层的成员变量,它们是 torch::jit::script::Module 类的实例。我们从 torch::nn::Module 类继承了我们的 ModelImpl 类,以便将其集成到 PyTorch 自动梯度系统中。

  2. 构造函数的实现如下:

    ModelImpl::ModelImpl(const std::string& bert_model_path)
        : dropout_(register_module(
              "dropout",
              torch::nn::Dropout(
                  torch::nn::DropoutOptions().p(0.2)))),
          fc1_(register_module(
              "fc1",
              torch::nn::Linear(
                  torch::nn::LinearOptions(768, 512)))),
          fc2_(register_module(
              "fc2",
              torch::nn::Linear(
                  torch::nn::LinearOptions(512, 2)))) {
      bert_ = torch::jit::load(bert_model_path);
    }
    

    我们使用了torch::jit::load来加载我们从 Python 导出的模型。这个函数接受一个参数——模型文件名。此外,我们还初始化了 dropout 和线性层,并在父torch::nn::Module对象中注册了它们。fc1_线性层是输入层;它接收 BERT 模型的 768 维输出。fc2_线性层是输出层。它将内部 512 维状态处理成两个类别。

  3. 主模型功能是在forward函数中实现的,如下所示:

    torch::Tensor ModelImpl::forward(
        at::Tensor input_ids,
        at::Tensor attention_masks) {
      std::vector<torch::jit::IValue> inputs = {
          input_ids, attention_masks};
      auto bert_output = bert_.forward(inputs);
      auto pooler_output =
          bert_output.toTuple()->elements()[1].toTensor();
      auto x = fc1_(pooler_output);
      x = torch::nn::functional::relu(x);
      x = dropout_(x);
      x = fc2_(x);
      x = torch::softmax(x, /*dim=*/1);
      return x;
    }
    

    此函数接收输入标记 ID 以及相应的注意力掩码,并返回一个包含分类结果的二维张量。我们将情感分析视为一个分类任务。forward实现有两个部分。一部分是我们使用加载的 BERT 模型对输入进行预处理;这种预处理只是使用预训练的 BERT 模型主干进行推理。第二部分是我们将 BERT 输出通过我们的分类头。这是可训练的部分。要使用 BERT 模型,即torch::jit:script::Module对象,我们将输入打包到torch::jit::Ivalue对象的std::vector容器中。从torch::Tensor的转换是自动完成的。

    然后,我们使用了 PyTorch 的标准forward函数进行推理。这个函数返回一个torch::jit元组对象;返回类型实际上取决于最初被追踪的模型。因此,要从torch::jit值对象中获取 PyTorch 张量,我们显式地使用了toTuple方法来说明如何解释输出结果。然后,我们通过使用elements方法访问第二个元组元素,该方法为元组元素提供了索引操作符。最后,为了获取张量,我们使用了jit::Ivalue对象的toTensor方法,这是一个元组元素。

    我们使用的 BERT 模型返回两个张量。第一个表示输入标记的嵌入值,第二个是池化输出。池化输出是输入文本的[CLS]标记的嵌入。产生此输出的线性层权重是在 BERT 预训练期间从下一个句子预测(分类)目标中训练出来的。因此,这些是在以下文本分类任务中使用的理想值。这就是为什么我们取 BERT 模型返回的元组的第二个元素。

    forward函数的第二部分同样简单。我们将 BERT 的输出传递给fc1_线性层,随后是relu激活函数。在此操作之后,我们得到了512个内部隐藏状态。然后,这个状态被dropout_模块处理,以向模型中引入一些正则化。最后阶段是使用fc2_输出线性模块,它返回一个二维向量,随后是softmax函数。softmax函数将 logits 转换为范围在[0,1]之间的概率值,因为来自线性层的原始 logits 可以具有任意值,需要将它们转换为目标值。

现在,我们已经描述了训练过程所需的所有组件。让我们看看模型训练是如何实现的。

训练模型

训练的第一步是创建数据集对象,可以按照以下方式完成:

auto tokenizer = std::make_shared<Tokenizer>(vocab_path);
ImdbDataset train_dataset(dataset_path / "train", tokenizer);

我们仅通过传递对应文件的路径就创建了tokenizertrain_dataset对象。测试数据集也可以用相同的方式创建,使用相同的tokenizer对象。现在我们有了数据集,我们创建数据加载器如下:

int batch_size = 8;
auto train_loader = torch::data::make_data_loader(
    train_dataset.map(Stack()),
    torch::data::DataLoaderOptions()
        .batch_size(batch_size)
        .workers(8));

我们指定了批大小,并使用make_data_loader函数创建了数据加载器对象。此对象使用数据集有效地加载和组织批量训练样本。我们使用train_dataset对象的map转换函数和一个我们的Stack类实例来允许 PyTorch 将我们的训练样本合并成张量批次。此外,我们还指定了一些数据加载器选项,即批大小和用于加载数据和预处理的线程数。

我们创建模型对象如下:

torch::DeviceType device = torch::cuda::is_available()
    ? torch::DeviceType::CUDA
    : torch::DeviceType::CPU;
Model model(model_path);
model->to(device);

我们使用了torch::cuda::is_available函数来确定系统是否可用 CUDA 设备,并相应地初始化device变量。使用 CUDA 设备可以显著提高模型的训练和推理。模型是通过一个构造函数创建的,该构造函数接受导出 BERT 模型的路径。在模型对象初始化后,我们将此对象移动到特定的设备。

训练所需的最后一个组件是一个优化器,我们创建如下:

torch::optim::AdamW optimizer(model->parameters(),
    torch::optim::AdamWOptions(1e-5));

我们使用了AdamW优化器,这是流行的 Adam 优化器的一个改进版本。为了构建优化器对象,我们将模型参数和学习率选项作为构造函数参数传递。

训练周期可以定义为以下内容:

for (int epoch = 0; epoch < epochs; ++epoch) {
  model->train();
  for (auto& batch : (*train_loader)) {
    optimizer.zero_grad();
    auto batch_label = batch.target.to(device);
    auto batch_input_ids =
        batch.data.first.squeeze(1).to(device);
    auto batch_attention_mask =
        batch.data.second.squeeze(1).to(device);
    auto output =
        model(batch_input_ids, batch_attention_mask);
    torch::Tensor loss =
        torch::cross_entropy_loss(output, batch_label);
    loss.backward();
    torch::nn::utils::clip_grad_norm_(model->parameters(),
                                      1.0);
    optimizer.step();
  }
}

有两个嵌套循环。一个是遍历 epoch 的循环,还有一个是内部循环,遍历批次。每个内部循环的开始就是新 epoch 的开始。我们将模型切换到训练模式。这个切换可以只做一次,但通常,你会有一些测试代码将模型切换到评估模式,因此这个切换会将模型返回到所需的状态。在这里,为了简化,我们省略了测试代码。它看起来与训练代码非常相似,唯一的区别是禁用了梯度计算。在内部循环中,我们使用了基于范围的for循环 C++语法来遍历批次。对于每个批次,首先,我们通过调用优化器对象的zero_grad函数来清除梯度值。然后,我们将批次解耦成单独的张量对象。此外,如果可用,我们将这些张量移动到 GPU 设备上。这是通过.to(device)调用完成的。我们使用squeeze方法从模型输入张量中移除了一个额外的维度。这个维度是在自动批次创建过程中出现的。

一旦所有张量都准备好了,我们就用我们的模型进行了预测,得到了output张量。这个输出被用于torch::cross_entropy_loss损失函数,通常用于多类分类。它接受一个包含每个类概率的张量和一个 one-hot 编码的标签张量。然后,我们使用loss张量的backward方法来计算梯度。此外,我们通过设置一个上限值来使用clip_grad_norm_函数剪辑梯度,以防止它们爆炸。一旦梯度准备好了,我们就使用优化器的step函数根据优化器算法更新模型权重。

使用我们使用的设置,这个架构可以在 500 个训练 epoch 中实现超过 80%的电影评论情感分析准确率。

摘要

本章介绍了 Transformer 架构,这是一个在 NLP 和其他机器学习领域广泛使用的强大模型。我们讨论了 Transformer 架构的关键组件,包括分词、嵌入、位置编码、编码器、解码器、注意力机制、多头注意力、交叉注意力、残差连接、归一化层、前馈层和采样技术。

最后,在本章的最后部分,我们开发了一个应用程序,以便我们可以对电影评论进行情感分析。我们将迁移学习技术应用于使用预训练模型在新模型中学习到的特征,该模型是为我们的特定任务设计的。我们使用 BERT 模型生成输入文本的嵌入表示,并附加了一个线性层分类头以对评论情感进行分类。我们实现了简单的分词器和数据集加载器。我们还开发了分类头的完整训练周期。

我们使用迁移学习而不是微调来利用更少的计算资源,因为微调技术通常涉及在新的数据集上重新训练一个完整的预训练模型。

在下一章中,我们将讨论如何保存和加载模型参数。我们还将查看机器学习库中为此目的存在的不同 API。保存和加载模型参数可能是训练过程中的一个相当重要的部分,因为它允许我们在任意时刻停止和恢复训练。此外,保存的模型参数可以在模型训练后用于评估目的。

进一步阅读

第四部分:生产和部署挑战

C++的关键特性是程序能够在各种硬件平台上编译和运行。你可以在数据中心最快的 GPU 上训练你的复杂机器学习ML)模型,并将其部署到资源有限的微型移动设备上。这部分将向你展示如何使用各种 ML 框架的 C++ API 来保存和加载训练好的模型,以及如何跟踪和可视化训练过程,这对于 ML 从业者来说至关重要,因为他们能够控制和检查模型的训练性能。此外,我们将学习如何构建在 Android 设备上使用 ML 模型的程序;特别是,我们将创建一个使用设备摄像头的目标检测系统。

本部分包括以下章节:

  • 第十二章导出和导入模型

  • 第十三章跟踪和可视化 ML 实验

  • 第十四章在移动平台上部署模型

第十二章:导出和导入模型

在本章中,我们将讨论如何在训练期间和之后保存和加载模型参数。这很重要,因为模型训练可能需要几天甚至几周。保存中间结果允许我们在以后进行评估或生产使用时加载它们。

这种常规的保存操作在随机应用程序崩溃的情况下可能有益。任何 机器学习ML)框架的另一个重要特性是它导出模型架构的能力,这使我们能够在框架之间共享模型,并使模型部署更加容易。本章的主要内容是展示如何使用不同的 C++ 库导出和导入模型参数,如权重和偏置值。本章的第二部分全部关于 开放神经网络交换ONNX)格式,该格式目前在不同的 ML 框架中越来越受欢迎,可以用于共享训练模型。此格式适用于共享模型架构以及模型参数。

本章将涵盖以下主题:

  • C++ 库中的 ML 模型序列化 API

  • 深入了解 ONNX 格式

技术要求

以下为本章的技术要求:

  • Dlib

  • mlpack

  • F``lashlight

  • pytorch

  • onnxruntime 框架

  • 支持 C++20 的现代 C++ 编译器

  • CMake 构建系统版本 >= 3.8

本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter12

C++ 库中的 ML 模型序列化 API

在本节中,我们将讨论 DlibF``lashlightmlpackpytorch 库中可用的 ML 模型共享 API。在不同的 C++ 库之间共享 ML 模型主要有三种类型:

  • 共享模型参数(权重)

  • 共享整个模型的架构

  • 共享模型架构及其训练参数

在以下各节中,我们将查看每个库中可用的 API,并强调它支持哪种类型的共享。

使用 Dlib 进行模型序列化

Dlib 库使用 decision_function 和神经网络对象的序列化 API。让我们通过实现一个真实示例来学习如何使用它。

首先,我们将定义神经网络、回归核和训练样本的类型:

using namespace Dlib;
using NetworkType = loss_mean_squared<fc<1, input<matrix<double>>>>;
using SampleType = matrix<double, 1, 1>;
using KernelType = linear_kernel<SampleType>;

然后,我们将使用以下代码生成训练数据:

size_t n = 1000;
std::vector<matrix<double>> x(n);
std::vector<float> y(n);
std::random_device rd;
std::mt19937 re(rd());
std::uniform_real_distribution<float> dist(-1.5, 1.5);
// generate data
for (size_t i = 0; i < n; ++i) {
  xi = i;
  y[i] = func(i) + dist(re);
}

在这里,x 代表预测变量,而 y 代表目标变量。目标变量 y 被均匀随机噪声盐化,以模拟真实数据。这些变量具有线性依赖关系,该关系由以下函数定义:

double func(double x) {
  return 4\. + 0.3 * x;
}

生成数据后,我们使用vector_normalizer类型的对象对其进行归一化。这种类型的对象在训练后可以重复使用,以使用学习到的均值和标准差对数据进行归一化。以下代码片段展示了其实现方式:

vector_normalizer<matrix<double>> normalizer_x;
normalizer_x.train(x);
for (size_t i = 0; i < x.size(); ++i) {
  x[i] = normalizer_x(x[i]);
}

最后,我们使用krr_trainer类型的对象训练核岭回归的decision_function对象:

void TrainAndSaveKRR(const std::vector<matrix<double>>& x,
                     const std::vector<float>& y) {
  krr_trainer<KernelType> trainer;
  trainer.set_kernel(KernelType());
  decision_function<KernelType> df = trainer.train(x, y);
  serialize("Dlib-krr.dat") << df;
}

注意,我们使用KernelType对象的实例初始化了训练器对象。

现在我们有了训练好的decision_function对象,我们可以使用serialize函数返回的流对象将其序列化到文件中:

serialize("Dlib-krr.dat") << df;

此函数将文件存储的名称作为输入参数,并返回一个输出流对象。我们使用了<<运算符将回归模型学习到的权重放入文件。在先前的代码示例中使用的序列化方法仅保存模型参数。

同样的方法可以用来序列化Dlib库中的几乎所有机器学习模型。以下代码展示了如何使用它来序列化神经网络的参数:

void TrainAndSaveNetwork(
    const std::vector<matrix<double>>& x,
    const std::vector<float>& y) {
  NetworkType network;
  sgd solver;
  dnn_trainer<NetworkType> trainer(network, solver);
  trainer.set_learning_rate(0.0001);
  trainer.set_mini_batch_size(50);
  trainer.set_max_num_epochs(300);
  trainer.be_verbose();
  trainer.train(x, y);
  network.clean();
  serialize("Dlib-net.dat") << network;
  net_to_xml(network, "net.xml");
}

对于神经网络,还有一个net_to_xml函数,它保存模型结构。然而,库 API 中没有函数可以将保存的结构加载到我们的程序中。这是用户的责任来实现加载函数。

如果我们希望在不同框架之间共享模型,可以使用net_to_xml函数,如Dlib文档中所示。

为了检查参数序列化是否按预期工作,我们可以生成新的测试数据来评估加载的模型:

std::cout << "Target values \n";
std::vector<matrix<double>> new_x(5);
for (size_t i = 0; i < 5; ++i) {
  new_x[i].set_size(1, 1);
  new_xi = i;
  new_x[i] = normalizer_x(new_x[i]);
  std::cout << func(i) << std::endl;
}

注意,我们已经重用了normalizer对象。一般来说,normalizer对象的参数也应该进行序列化和加载,因为在评估过程中,我们需要将新数据转换为我们用于训练数据的相同统计特性。

要在Dlib库中加载序列化的对象,我们可以使用deserialize函数。此函数接受文件名并返回一个输入流对象:

void LoadAndPredictKRR(
    const std::vector<matrix<double>>& x) {
  decision_function<KernelType> df;
  deserialize("Dlib-krr.dat") >> df;
  // Predict
  std::cout << "KRR predictions \n";
  for (auto& v : x) {
    auto p = df(v);
    std::cout << static_cast<double>(p) << std::endl;
  }
}

如前所述,在Dlib库中,序列化仅存储模型参数。因此,要加载它们,我们需要使用在序列化之前具有相同属性的模型对象。

对于回归模型,这意味着我们应该实例化一个与相同核类型相对应的决策函数对象。

对于神经网络模型,这意味着我们应该实例化一个与序列化时使用的相同类型的网络对象,如下面的代码块所示:

void LoadAndPredictNetwork(
    const std::vector<matrix<double>>& x) {
  NetworkType network;
  deserialize("Dlib-net.dat") >> network;
  // Predict
  auto predictions = network(x);
  std::cout << "Net predictions \n";
  for (auto p : predictions) {
    std::cout << static_cast<double>(p) << std::endl;
  }
}

在本节中,我们了解到Dlib序列化 API 允许我们保存和加载机器学习模型参数,但在序列化和加载模型架构方面选项有限。在下一节中,我们将探讨Shogun库模型序列化 API。

使用 Flashlight 进行模型序列化

Flashlight库可以将模型和参数保存和加载到二进制格式中。它内部使用Cereal C++库进行序列化。以下示例展示了这一功能。

如前例所示,我们首先创建一些示例训练数据:

int64_t n = 10000;
auto x = fl::randn({n});
auto y = x * 0.3f + 0.4f;
// Define dataset
std::vector<fl::Tensor> fields{x, y};
auto dataset = std::make_shared<fl::TensorDataset>(fields);
fl::BatchDataset batch_dataset(dataset, /*batch_size=*/64);

在这里,我们创建了一个包含随机数据的向量x,并通过应用线性依赖公式来创建我们的目标变量y。我们将独立和目标向量包装到一个名为batch_datasetBatchDataset对象中,我们将使用它来训练一个示例神经网络。

以下代码展示了我们的神经网络定义:

fl::Sequential model;
model.add(fl::View({1, 1, 1, -1}));
model.add(fl::Linear(1, 8));
model.add(fl::ReLU());
model.add(fl::Linear(8, 16));
model.add(fl::ReLU());
model.add(fl::Linear(16, 32));
model.add(fl::ReLU());
model.add(fl::Linear(32, 1));

如您所见,这是我们之前示例中使用的相同的正向传播网络,但这次是为 Flashlight 设计的。

以下代码示例展示了如何训练模型:

auto loss = fl::MeanSquaredError();
float learning_rate = 0.01;
float momentum = 0.5;
auto sgd = fl::SGDOptimizer(model.params(), 
                            learning_rate,
                            momentum);
const int epochs = 5;
for (int epoch_i = 0; epoch_i < epochs; ++epoch_i) {
  for (auto& batch : batch_dataset) {
    sgd.zeroGrad();
    auto predicted = model(fl::input(batch[0]));
    auto local_batch_size = batch[0].shape().dim(0);
    auto target =
        fl::reshape(batch[1], {1, 1, 1, local_batch_size});
    auto loss_value = loss(predicted, fl::noGrad(target));
    loss_value.backward();
    sgd.step();
  }
}

在这里,我们使用了之前使用的相同训练方法。首先,我们定义了loss对象和sgd优化器对象。然后,我们使用两个循环来训练模型:一个循环遍历 epoch,另一个循环遍历批次。在内循环中,我们将模型应用于训练批次数据以获取新的预测值。然后,我们使用loss对象使用批次目标值计算 MSE 值。我们还使用了损失值变量的backward方法来计算梯度。最后,我们使用sgd优化器对象的step方法更新模型参数。

现在我们有了训练好的模型,我们有两种方法可以在Flashlight库中保存它:

  1. 序列化整个模型及其架构和权重。

  2. 仅序列化模型权重。

对于第一种选项——即序列化整个模型及其架构——我们可以这样做:

fl::save("model.dat", model);

在这里,model.dat是我们将保存模型的文件名。要加载此类文件,我们可以使用以下代码:

fl::Sequential model_loaded;
fl::load("model.dat", model_loaded);

在这种情况下,我们创建了一个名为model_loaded的新空对象。这个新对象只是一个没有特定层的fl::Sequential容器对象。所有层和参数值都是通过fl::load函数加载的。一旦我们加载了模型,我们就可以这样使用它:

auto predicted = model_loaded(fl::noGrad(new_x));

在这里,new_x是我们用于评估目的的一些新数据。

当您存储整个模型时,这种方法对于包含不同模型但具有相同输入和输出接口的应用程序可能很有用,因为它可以帮助您轻松地在生产中更改或升级模型,例如。

第二种选项,仅保存网络的参数(权重)值,如果我们需要定期重新训练模型,或者如果我们只想共享或重用模型或其参数的某些部分,这可能是有用的。为此,我们可以使用以下代码:

fl::save("model_params.dat", model.params());

在这里,我们使用了model对象的params方法来获取所有模型参数。此方法返回所有模型子模块的参数的std::vector序列。因此,您只能管理其中的一些。要加载已保存的参数,我们可以使用以下代码:

std::vector<fl::Variable> params;
fl::load("model_params.dat", params);
for (int i = 0; i < static_cast<int>(params.size()); ++i) {
  model.setParams(params[i], i);
}

首先,我们创建了空的 params 容器。然后,使用 fl::load 函数将参数值加载到其中。为了能够更新特定子模块的参数值,我们使用了 setParams 方法。'setParams' 方法接受一个值和一个整数位置,我们想要设置这个值。我们保存了所有模型参数,以便我们可以按顺序将它们放回模型中。

不幸的是,没有方法可以将其他格式的模型和权重加载到 Flashlight 库中。因此,如果您需要从其他格式加载,您必须编写一个转换器并使用 setParams 方法设置特定值。在下一节中,我们将深入了解 mlpack 库的序列化 API。

使用 mlpack 进行模型序列化

mlpack 库仅实现了模型参数序列化。这种序列化基于存在于 Armadillo 数学库中的功能,该库被用作 mlpack 的后端。这意味着我们可以使用 mlpack API 以不同的文件格式保存参数值。具体如下:

  • .csv,或者可选的 .txt

  • .txt

  • .txt

  • .pgm

  • .ppm

  • .bin

  • .bin

  • .hdf5.hdf.h5.he5

让我们看看使用 mlpack 创建模型和参数管理的最小示例。首先,我们需要一个模型。以下代码展示了我们可以使用的创建模型的功能:

using ModelType = FFN<MeanSquaredError, ConstInitialization>;
ModelType make_model() {
  MeanSquaredError loss;
  ConstInitialization init(0.);
  ModelType model(loss, init);
  model.Add<Linear>(8);
  model.Add<ReLU>();
  model.Add<Linear>(16);
  model.Add<ReLU>();
  model.Add<Linear>(32);
  model.Add<ReLU>();
  model.Add<Linear>(1);
  return model;
}

create_model 函数创建了一个具有多个线性层的前馈网络。请注意,我们使此模型使用 MSE 作为损失函数并添加了零参数初始化器。现在我们有了模型,我们需要一些数据来训练它。以下代码展示了如何创建线性相关数据:

size_t n = 10000;
arma::mat x = arma::randn(n).t();
arma::mat y = x * 0.3f + 0.4f;

在这里,我们创建了两个单维向量,类似于我们在 Flashlight 示例中所做的,但使用了 Armadillo 矩阵 API。请注意,我们使用了 t() 转置方法对 x 向量进行操作,因为 mlpack 使用列维度作为其训练特征。

现在,我们可以连接所有组件并执行模型训练:

ens::Adam optimizer;
auto model = make_model();
model.Train(x, y, optimizer);

在这里,我们创建了 Adam 算法优化器对象,并在模型的 Train 方法中使用我们之前创建的两个数据向量。现在,我们有了训练好的模型,准备保存其参数。这可以按以下方式完成:

data::Save("model.bin", model.Parameters(), true);

默认情况下,data::Save 函数会根据提供的文件名扩展名自动确定要保存的文件格式。在这里,我们使用了模型对象的 Parameters 方法来获取参数值。此方法返回一个包含所有值的矩阵。我们还传递了 true 作为第三个参数,以便在失败的情况下 save 函数抛出异常。默认情况下,它将只返回 false;这是您必须手动检查的事情。

我们可以使用 mlpack::data::Load 函数来加载参数值,如下所示:

auto new_model = make_model();
data::Load("model.bin", new_model.Parameters());

在这里,我们创建了new_model对象;这是一个与之前相同的模型,但参数初始化为零。然后,我们使用mlpack::data::Load函数从文件中加载参数值。再次使用Parameters方法获取内部参数值矩阵的引用,并将其传递给load函数。我们将load函数的第三个参数设置为true,以便在出现错误时可以抛出异常。

现在我们已经初始化了模型,我们可以用它来进行预测:

arma::mat predictions;
new_model.Predict(new_x, predictions);

在这里,我们创建了一个输出矩阵prediction,并使用new_model对象的Predict方法进行模型评估。请注意,new_x是我们希望对其获取预测的一些新数据。

注意,你不能将其他框架的文件格式加载到 mlpack 中,因此如果你需要,你必须创建转换器。在下一节中,我们将查看pytorch库的序列化 API。

使用 PyTorch 进行模型序列化

在本节中,我们将讨论在pytorch C++库中可用的两种网络参数序列化方法:

  • torch::save函数

  • 一个torch::serialize::OutputArchive类型的对象,用于将参数写入OutputArchive对象

让我们从准备神经网络开始。

初始化神经网络

让我们从生成训练数据开始。以下代码片段显示了我们可以如何做到这一点:

torch::DeviceType device = torch::cuda::is_available()
? torch::DeviceType::CUDA
: torch::DeviceType::CPU;

通常,我们希望尽可能利用硬件资源。因此,首先,我们通过使用torch::cuda::is_available()调用检查系统中是否有带有 CUDA 技术的 GPU 可用:

std::random_device rd;
std::mt19937 re(rd());
std::uniform_real_distribution<float> dist(-0.1f, 0.1f);

我们定义了dist对象,以便我们可以在-11的范围内生成均匀分布的实数:

size_t n = 1000;
torch::Tensor x;
torch::Tensor y;
{
  std::vector<float> values(n);
  std::iota(values.begin(), values.end(), 0);
  std::shuffle(values.begin(), values.end(), re);
  std::vector<torch::Tensor> x_vec(n);
  std::vector<torch::Tensor> y_vec(n);
  for (size_t i = 0; i < n; ++i) {
    x_vec[i] = torch::tensor(
        values[i],
        torch::dtype(torch::kFloat).device(
                                    device).requires_grad(false));
    y_vec[i] = torch::tensor(
        (func(values[i]) + dist(re)),
        torch::dtype(torch::kFloat).device(
                                    device).requires_grad(false));
  }
  x = torch::stack(x_vec);
  y = torch::stack(y_vec);
}

然后,我们生成了 1,000 个预测变量值并将它们打乱。对于每个值,我们使用在之前的示例中使用的线性函数计算目标值——即func。下面是这个过程的示例:

float func(float x) {
  return 4.f + 0.3f * x;
}

然后,所有值都通过torch::tensor函数调用移动到torch::Tensor对象中。请注意,我们使用了之前检测到的设备来创建张量。一旦我们将所有值移动到张量中,我们就使用torch::stack函数将预测值和目标值连接到两个不同的单张量中。这是必要的,以便我们可以使用pytorch库的线性代数例程进行数据归一化:

auto x_mean = torch::mean(x, /*dim*/ 0);
auto x_std = torch::std(x, /*dim*/ 0);
x = (x - x_mean) / x_std;

最后,我们使用了torch::meantorch::std函数来计算预测值的平均值和标准差,并将它们进行了归一化处理。

在以下代码中,我们定义了NetImpl类,该类实现了我们的神经网络:

class NetImpl : public torch::nn::Module {
 public:
  NetImpl() {
    l1_ = torch::nn::Linear(torch::nn::LinearOptions(
                                1, 8).with_bias(true));
    register_module("l1", l1_);
    l2_ = torch::nn::Linear(torch::nn::LinearOptions(
                                8, 4).with_bias(true));
    register_module("l2", l2_);
    l3_ = torch::nn::Linear(torch::nn::LinearOptions(
                                4, 1).with_bias(true));
    register_module("l3", l3_);
    // initialize weights
    for (auto m : modules(false)) {
      if (m->name().find("Linear") != std::string::npos) {
        for (auto& p : m->named_parameters()) {
          if (p.key().find("weight") != std::string::npos) {
            torch::nn::init::normal_(p.value(), 0, 0.01);
                    }
          if (p.key().find("bias") != std::string::npos) {
            torch::nn::init::zeros_(p.value());
          }
        }
      }
    }
  }
torch::Tensor forward(torch::Tensor x) {
  auto y = l1_(x);
  y = l2_(y);
  y = l3_(y);
  return y;
}
private:
  torch::nn::Linear l1_{nullptr};
  torch::nn::Linear l2_{nullptr};
  torch::nn::Linear l3_{nullptr};
}
TORCH_MODULE(Net);

在这里,我们将我们的神经网络模型定义为一个具有三个全连接神经元层和线性激活函数的网络。每个层都是torch::nn::Linear类型。

在我们模型的构造函数中,我们使用小的随机值初始化了所有网络参数。我们通过遍历所有网络模块(参见modules方法调用)并应用torch::nn::init::normal_函数到由named_parameters()模块方法返回的参数来实现这一点。偏差使用torch::nn::init::zeros_函数初始化为零。named_parameters()方法返回由字符串名称和张量值组成的对象,因此对于初始化,我们使用了它的value方法。

现在,我们可以使用我们生成的训练数据来训练模型。以下代码展示了我们如何训练我们的模型:

Net model;
model->to(device);
// initialize optimizer -----------------------------------
double learning_rate = 0.01;
torch::optim::Adam optimizer(model->parameters(),
torch::optim::AdamOptions(learning_rate).weight_decay(0.00001));
// training
int64_t batch_size = 10;
int64_t batches_num = static_cast<int64_t>(n) / batch_size;
int epochs = 10;
for (int epoch = 0; epoch < epochs; ++epoch) {
  // train the model
  // -----------------------------------------------
  model->train();  // switch to the training mode
  // Iterate the data
  double epoch_loss = 0;
  for (int64_t batch_index = 0; batch_index < batches_num;
       ++batch_index) {
    auto batch_x =
        x.narrow(0, batch_index * batch_size, batch_size)
            .unsqueeze(1);
    auto batch_y =
        y.narrow(0, batch_index * batch_size, batch_size)
            .unsqueeze(1);
    // Clear gradients
    optimizer.zero_grad();
    // Execute the model on the input data
    torch::Tensor prediction = model->forward(batch_x);
    torch::Tensor loss =
        torch::mse_loss(prediction, batch_y);
    // Compute gradients of the loss and parameters of
    // our model
    loss.backward();
    // Update the parameters based on the calculated
    // gradients.
    optimizer.step();
  }
}

为了利用所有我们的硬件资源,我们将模型移动到选定的计算设备。然后,我们初始化了一个优化器。在我们的例子中,优化器使用了Adam算法。之后,我们在每个 epoch 上运行了一个标准的训练循环,其中对于每个 epoch,我们取训练批次,清除优化器的梯度,执行前向传递,计算损失,执行反向传递,并使用优化器步骤更新模型权重。

从数据集中选择一批训练数据,我们使用了张量的narrow方法,该方法返回了一个维度减少的新张量。此函数接受新的维度数量作为第一个参数,起始位置作为第二个参数,以及要保留的元素数量作为第三个参数。我们还使用了unsqueeze方法来添加一个批次维度;这是 PyTorch API 进行前向传递所必需的。

如我们之前提到的,我们可以使用两种方法在 C++ API 中的pytorch序列化模型参数(Python API 提供了更多的功能)。让我们来看看它们。

使用 torch::save 和 torch::load 函数

我们可以采取的第一种保存模型参数的方法是使用torch::save函数,该函数递归地保存传递的模块的参数:

torch::save(model, "pytorch_net.pt");

为了正确地与我们的自定义模块一起使用,我们需要使用register_module模块的方法将所有子模块在父模块中注册。

要加载保存的参数,我们可以使用torch::load函数:

Net model_loaded;
torch::load(model_loaded, "pytorch_net.pt");

该函数将读取自文件的值填充到传递的模块参数中。

使用 PyTorch 存档对象

第二种方法是用torch::serialize::OutputArchive类型的对象,并将我们想要保存的参数写入其中。以下代码展示了如何实现我们模型的SaveWeights方法。此方法将我们模块中存在的所有参数和缓冲区写入archive对象,然后它使用save_to方法将它们写入文件:

void NetImpl::SaveWeights(const std::string& file_name) {
  torch::serialize::OutputArchive archive;
  auto parameters = named_parameters(true /*recurse*/);
  auto buffers = named_buffers(true /*recurse*/);
  for (const auto& param : parameters) {
    if (param.value().defined()) {
      archive.write(param.key(), param.value());
    }
  }
  for (const auto& buffer : buffers) {
    if (buffer.value().defined()) {
      archive.write(buffer.key(), buffer.value(),
                    /*is_buffer*/ true);
    }
  }
  archive.save_to(file_name);
}

保存缓冲区张量也很重要。可以使用 named_buffers 模块的 named_buffers 方法从模块中检索缓冲区。这些对象代表用于评估不同模块的中间值。例如,我们可以是批归一化模块的运行均值和标准差值。在这种情况下,如果我们使用序列化来保存中间步骤并且由于某种原因训练过程停止,我们需要它们继续训练。

要加载以这种方式保存的参数,我们可以使用 torch::serialize::InputArchive 对象。以下代码展示了如何为我们的模型实现 LoadWeights 方法:

void NetImpl::LoadWeights(const std::string& file_name) {
  torch::serialize::InputArchive archive;
  archive.load_from(file_name);
  torch::NoGradGuard no_grad;
  auto parameters = named_parameters(true /*recurse*/);
  auto buffers = named_buffers(true /*recurse*/);
  for (auto& param : parameters) {
      archive.read(param.key(), param.value());
  }
  for (auto& buffer : buffers) {
      archive.read(buffer.key(), buffer.value(),
          /*is_buffer*/ true);
  }
}

在这里,LoadWeights 方法使用 archive 对象的 load_from 方法从文件中加载参数。首先,我们使用 named_parametersnamed_buffers 方法从我们的模块中获取参数和缓冲区,并使用 archive 对象的 read 方法逐步填充它们的值。

注意,我们使用 torch::NoGradGuard 类的实例来告诉 pytorch 库我们不会执行任何模型计算或图相关操作。这样做是必要的,因为 pytorch 库构建计算图和任何无关操作都可能导致错误。

现在,我们可以使用新的 model_loaded 模型实例,并带有 load 参数来评估一些测试数据上的模型。请注意,我们需要使用 eval 方法将模型切换到评估模式。生成的测试数据值也应使用 torch::tensor 函数转换为张量对象,并将其移动到与我们的模型使用的相同计算设备上。以下代码展示了我们如何实现这一点:

model_loaded->to(device);
model_loaded->eval();
std::cout << "Test:\n";
for (int i = 0; i < 5; ++i) {
  auto x_val = static_cast<float>(i) + 0.1f;
  auto tx = torch::tensor(
      x_val, torch::dtype(torch::kFloat).device(device));
  tx = (tx - x_mean) / x_std;
  auto ty = torch::tensor(
      func(x_val),
      torch::dtype(torch::kFloat).device(device));
  torch::Tensor prediction = model_loaded->forward(tx);
  std::cout << "Target:" << ty << std::endl;
  std::cout << "Prediction:" << prediction << std::endl;
}

在本节中,我们探讨了 pytorch 库中的两种序列化类型。第一种方法涉及使用 torch::savetorch::load 函数,分别轻松保存和加载所有模型参数。第二种方法涉及使用 torch::serialize::InputArchivetorch::serialize::OutputArchive 类型的对象,这样我们就可以选择我们想要保存和加载的参数。

在下一节中,我们将讨论 ONNX 文件格式,它允许我们在不同的框架之间共享我们的 ML 模型架构和模型参数。

深入探讨 ONNX 格式

ONNX 格式是一种特殊的文件格式,用于在不同框架之间共享神经网络架构和参数。它基于 Google 的 Protobuf 格式和库。这种格式存在的原因是测试和在不同的环境和设备上运行相同的神经网络模型。

通常,研究人员会使用他们熟悉的编程框架来开发模型,然后在不同环境中运行这个模型,用于生产目的或者他们想要与其他研究人员或开发者共享模型。这种格式得到了所有主流框架的支持,包括 PyTorch、TensorFlow、MXNet 以及其他。然而,这些框架的 C++ API 对这种格式的支持不足,在撰写本文时,它们只为处理 ONNX 格式提供了 Python 接口。尽管如此,微软提供了onnxruntime框架,可以直接使用不同的后端,如 CUDA、CPU 或甚至 NVIDIA TensorRT 来运行推理。

在深入探讨使用框架解决我们具体用例的细节之前,考虑某些限制因素是很重要的,这样我们可以全面地处理问题陈述。有时,由于缺少某些操作符或函数,导出为 ONNX 格式可能会出现问题,这可能会限制可以导出的模型类型。此外,对张量的动态维度和条件操作符的支持可能有限,这限制了使用具有动态计算图和实现复杂算法的模型的能力。这些限制取决于目标硬件。你会发现嵌入式设备有最多的限制,而且其中一些问题只能在推理运行时发现。然而,使用 ONNX 有一个很大的优势——通常,这样的模型可以在各种不同的张量数学加速硬件上运行。

与 ONNX 相比,TorchScript 对模型操作符和结构的限制更少。通常,可以导出具有所有所需分支的动态计算图模型。然而,在您必须推断模型的地方可能会有硬件限制。例如,通常无法使用移动 GPU 或 NPUs 进行 TorchScript 推理。ExecTorch 应该在将来解决这个问题。

为了尽可能多地利用可用硬件,我们可以使用特定供应商的不同推理引擎。通常,可以将 ONNX 格式或使用其他方法的模型转换为内部格式,以在特定的 GPU 或 NPU 上进行推理。此类引擎的例子包括 Intel 硬件的 OpenVINO、NVIDIA 的 TensorRT、基于 ARM 处理器的 ArmNN 以及 Qualcomm NPUs 的 QNN。

现在我们已经了解了如何最好地利用这个框架,接下来让我们了解如何使用 ResNet 神经网络架构进行图像分类。

使用 ResNet 架构进行图像分类

通常,作为开发者,我们不需要了解 ONNX 格式内部是如何工作的,因为我们只对保存模型的文件感兴趣。如前所述,内部上,ONNX 格式是一个 Protobuf 格式的文件。以下代码展示了 ONNX 文件的第一部分,它描述了如何使用 ResNet 神经网络架构进行图像分类:

ir_version: 3
graph {
  node {
  input: "data"
  input: "resnetv24_batchnorm0_gamma"
  input: "resnetv24_batchnorm0_beta"
  input: "resnetv24_batchnorm0_running_mean"
  input: "resnetv24_batchnorm0_running_var"
  output: "resnetv24_batchnorm0_fwd"
  name: "resnetv24_batchnorm0_fwd"
  op_type: "BatchNormalization"
  attribute {
      name: "epsilon"
      f: 1e-05
      type: FLOAT
  }
  attribute {
      name: "momentum"
      f: 0.9
      type: FLOAT
  }
  attribute {
      name: "spatial"
      i: 1
      type: INT
  }
}
node {
  input: "resnetv24_batchnorm0_fwd"
  input: "resnetv24_conv0_weight"
  output: "resnetv24_conv0_fwd"
  name: "resnetv24_conv0_fwd"
  op_type: "Conv"
  attribute {
      name: "dilations"
      ints: 1
      ints: 1
      type: INTS
  }
  attribute {
      name: "group"
      i: 1
      type: INT
  }
  attribute {
      name: "kernel_shape"
      ints: 7
      ints: 7
      type: INTS
  }
  attribute {
      name: "pads"
      ints: 3
      ints: 3
      ints: 3
      ints: 3
      type: INTS
  }
  attribute {
      name: "strides"
      ints: 2
      ints: 2
      type: INTS
  }
}
...
}

通常,ONNX 文件以二进制格式提供,以减少文件大小并提高加载速度。

现在,让我们学习如何使用onnxruntime API 加载和运行 ONNX 模型。ONNX 社区为公开可用的模型库中最流行的神经网络架构提供了预训练模型(github.com/onnx/models)。

有许多现成的模型可以用于解决不同的机器学习任务。例如,我们可以使用ResNet-50模型来进行图像分类任务(github.com/onnx/models/tree/main/validated/vision/classification/resnet/model/resnet50-v1-7.onnx)。

对于这个模型,我们必须下载相应的包含图像类别描述的synset文件,以便能够以人类可读的方式返回分类结果。您可以在github.com/onnx/models/blob/main/validated/vision/classification/synset.txt找到该文件。

为了能够使用onnxruntime C++ API,我们必须使用以下头文件:

#include <onnxruntime_cxx_api.h>

然后,我们必须创建全局共享的onnxruntime环境和模型评估会话,如下所示:

Ort::Env env;
Ort::Session session(env,
                     "resnet50-v1-7.onnx",
                     Ort::SessionOptions{nullptr});

session对象将模型的文件名作为其输入参数,并自动加载它。在这里,我们传递了下载的模型的名称。最后一个参数是SessionOptions类型的对象,它可以用来指定特定的设备执行器,例如 CUDA。env对象包含一些共享的运行时状态。最有价值的状态是日志数据和日志级别,这些可以通过构造函数参数进行配置。

一旦我们加载了一个模型,我们可以访问其参数,例如模型输入的数量、模型输出的数量和参数名称。如果您事先不知道这些信息,这些信息将非常有用,因为您需要输入参数名称来运行推理。我们可以按照以下方式发现此类模型信息:

void show_model_info(const Ort::Session& session) {
  Ort::AllocatorWithDefaultOptions allocator;

在这里,我们创建了一个函数头并初始化了字符串内存分配器。现在,我们可以打印输入参数信息:

  auto num_inputs = session.GetInputCount();
  for (size_t i = 0; i < num_inputs; ++i) {
    auto input_name = session.GetInputNameAllocated(i,
                                           allocator);
    std::cout << "Input name " << i << " : " << input_name
              << std::endl;
    Ort::TypeInfo type_info = session.GetInputTypeInfo(i);
    auto tensor_info =
        type_info.GetTensorTypeAndShapeInfo();
    auto tensor_shape = tensor_info.GetShape();
    std::cout << "Input shape " << i << " : ";
    for (size_t j = 0; j < tensor_shape.size(); ++j)
      std::cout << tensor_shape[j] << " ";
    std::cout << std::endl;
  }

一旦我们发现了输入参数,我们可以按照以下方式打印输出参数信息:

 auto num_outputs = session.GetOutputCount();
  for (size_t i = 0; i < num_outputs; ++i) {
    auto output_name = session.GetOutputNameAllocated(i,
                                             allocator);
  std::cout << "Output name " << i << " : " <<
                       output_name << std::endl;
  Ort::TypeInfo type_info = session.GetOutputTypeInfo(i);
  auto tensor_info = type_info.GetTensorTypeAndShapeInfo();
  auto tensor_shape = tensor_info.GetShape();
  std::cout << "Output shape " << i << " : ";
  for (size_t j = 0; j < tensor_shape.size(); ++j)
    std::cout << tensor_shape[j] << " ";
  std::cout << std::endl;
  }
}

在这里,我们使用了session对象来发现模型属性。通过使用GetInputCountGetOutputCount方法,我们得到了相应的输入和输出参数的数量。然后,我们使用GetInputNameAllocatedGetOutputNameAllocated方法通过它们的索引来获取参数名称。请注意,这些方法需要allocator对象。在这里,我们使用了在show_model_info函数顶部初始化的默认对象。

我们可以通过使用相应的参数索引,使用GetInputTypeInfoGetOutputTypeInfo方法获取额外的参数类型信息。然后,通过使用这些参数类型信息对象,我们可以使用GetTensorTypeAndShapeInfo方法获取张量信息。这里最重要的信息是使用tensor_onfo对象的GetShape方法获取的张量形状。它很重要,因为我们需要为模型输入和输出张量使用特定的形状。形状表示为整数向量。现在,使用show_model_info函数,我们可以获取模型输入和输出参数信息,创建相应的张量,并将数据填充到它们中。

在我们的案例中,输入是一个大小为1 x 3 x 224 x 224的张量,它代表了用于分类的 RGB 图像。onnxruntime会话对象接受Ort::Value类型对象作为输入并将它们作为输出填充。

下面的代码片段展示了如何为模型准备输入张量:

constexpr const int width = 224;
constexpr const int height = 224;
std::array<int64_t, 4> input_shape{1, 3, width, height};
std::vector<float> input_image(3 * width * height);
read_image(argv[3], width, height, input_image);
auto memory_info = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator,
                                              OrtMemTypeCPU);
Ort::Value input_tensor =
    Ort::Value::CreateTensor<float>(memory_ info,
                                    input_image.data(),
                                    input_image.size(),
                                    input_shape.data(),
                                    input_shape.size());

首先,我们定义了代表输入图像宽度和高度的常量。然后,我们创建了input_shape对象,它定义了张量的完整形状,包括其批次维度。有了形状,我们创建了input_image向量来保存确切的图像数据。这个数据容器被read_image函数填充,我们将在稍后对其进行详细探讨。最后,我们使用Ort::Value::CreateTensor函数创建了input_tensor对象,它接受memory_info对象和数据以及形状容器的引用。memory_info对象使用分配输入张量在主机 CPU 设备上的参数创建。输出张量也可以用同样的方式创建:

std::array<int64_t, 2> output_shape{1, 1000};
std::vector<float> result(1000);
Ort::Value output_tensor =
    Ort::Value::CreateTensor<float>(memory_ info,
                                    result.data(),
                                    result.size(),
                                    output_shape.data(),
                                    output_shape.size());

注意到onnxruntime API 允许我们创建一个空的输出张量,它将被自动初始化。我们可以这样做:

Ort::Value output_tensor{nullptr};

现在,我们可以使用Run方法进行评估:

const char* input_names[] = {"data"};
const char* output_names[] = {"resnetv17_dense0_fwd"};
Ort::RunOptions run_options;
session.Run(run_options,
            input_names,
            &input_tensor,
            1,
            output_names,
            &output_tensor,
            1);

在这里,我们定义了输入和输出参数的名称和常量,并使用默认初始化创建了run_options对象。run_options对象可以用来配置日志的详细程度,而Run方法可以用来评估模型。请注意,输入和输出张量作为指针传递到数组中,并指定了相应的元素数量。在我们的案例中,我们指定了单个输入和输出元素。

该模型的输出是针对ImageNet数据集的 1,000 个类别的图像得分(概率),该数据集用于训练模型。以下代码展示了如何解码模型的输出:

std::map<size_t, std::string> classes = read_classes("synset.txt");
std::vector<std::pair<float, size_t>> pairs;
for (size_t i = 0; i < result.size(); i++) {
  if (result[i] > 0.01f) {  // threshold check
    pairs.push_back(std::make_pair(
        output[i], i + 1));  // 0 –//background
  }
}
std::sort(pairs.begin(), pairs.end());
std::reverse(pairs.begin(), pairs.end());
pairs.resize(std::min(5UL, pairs.size()));
for (auto& p : pairs) {
  std::cout << "Class " << p.second << " Label "
            << classes.at(p.second) << « Prob « << p.first
            << std::endl;
}

在这里,我们遍历了结果张量数据中的每个元素——即我们之前初始化的result向量对象。在模型评估期间,这个result对象被填充了实际的数据值。然后,我们将得分值和类别索引放入相应的对向量中。这个向量按得分降序排序。然后,我们打印了得分最高的五个类别。

在本节中,我们通过onnxruntime框架的示例了解了如何处理 ONNX 格式。然而,我们仍需要学习如何将输入图像加载到张量对象中,这是我们用于模型输入的部分。

将图像加载到 onnxruntime 张量中

让我们学习如何根据模型的输入要求和内存布局加载图像数据。之前,我们初始化了一个相应大小的input_image向量。模型期望输入图像是归一化的,并且是三个通道的 RGB 图像,其形状为N x 3 x H x W,其中N是批处理大小,HW至少应为 224 像素宽。归一化假设图像被加载到[0, 1]范围内,然后使用均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225]进行归一化。

假设我们有一个以下函数定义来加载图像:

void read_image(const std::string& file_name,
                       int width,
                       int height,
                       std::vector<float>& image_data)
...
}

让我们编写它的实现。为了加载图像,我们将使用OpenCV库:

// load image
auto image = cv::imread(file_name, cv::IMREAD_COLOR);
if (!image.cols || !image.rows) {
  return {};
}
if (image.cols != width || image.rows != height) {
  // scale image to fit
  cv::Size scaled(
      std::max(height * image.cols / image.rows, width),
      std::max(height, width * image.rows / image.cols));
  cv::resize(image, image, scaled);
  // crop image to fit
  cv::Rect crop((image.cols - width) / 2,
                (image.rows - height) / 2, width, height);
  image = image(crop);
}

在这里,我们使用cv::imread函数从文件中读取图像。如果图像的尺寸不等于已指定的尺寸,我们需要使用cv::resize函数调整图像大小,然后如果图像的尺寸超过指定的尺寸,还需要裁剪图像。

然后,我们必须将图像转换为浮点类型和 RGB 格式:

image.convertTo(image, CV_32FC3);
cv::cvtColor(image, image, cv::COLOR_BGR2RGB);

格式化完成后,我们可以将图像分成三个单独的通道,分别是红色、绿色和蓝色。我们还应该对颜色值进行归一化。以下代码展示了如何进行这一操作:

std::vector<cv::Mat> channels(3);
cv::split(image, channels);
std::vector<double> mean = {0.485, 0.456, 0.406};
std::vector<double> stddev = {0.229, 0.224, 0.225};
size_t i = 0;
for (auto& c : channels) {
  c = ((c / 255) - mean[i]) / stddev[i];
  ++i;
}

在这里,每个通道都被减去相应的均值,并除以相应的标准差,以进行归一化处理。

然后,我们应该将通道连接起来:

cv::vconcat(channels[0], channels[1], image);
cv::vconcat(image, channels[2], image);
assert(image.isContinuous());

在这种情况下,归一化后的通道被cv::vconcat函数连接成一个连续的图像。

以下代码展示了如何将 OpenCV 图像复制到image_data向量中:

std::vector<int64_t> dims = {1, 3, height, width};
std::copy_n(reinterpret_cast<float*>(image.data),
image.size().area(),
image_data.begin());

在这里,图像数据被复制到一个由指定维度初始化的浮点向量中。使用cv::Mat::data类型成员访问 OpenCV 图像数据。我们将图像数据转换为浮点类型,因为该成员变量是unsigned char *类型。使用标准的std::copy_n函数复制像素数据。这个函数被用来填充input_image向量中的实际图像数据。然后,使用input_image向量数据的引用在CreateTensor函数中初始化Ort::Value对象。

在 ONNX 格式示例中,还使用了一个可以从synset文件中读取类定义的函数。我们将在下一节中查看这个函数。

读取类定义文件

在这个例子中,我们使用了read_classes函数来加载对象映射。在这里,键是一个图像类索引,值是一个文本类描述。这个函数很简单,逐行读取synset文件。在这样的文件中,每一行包含一个数字和一个由空格分隔的类描述字符串。以下代码展示了其定义:

using Classes = std::map<size_t, std::string>;
Classes read_classes(const std::string& file_name) {
  Classes classes;
  std::ifstream file(file_name);
  if (file) {
    std::string line;
    std::string id;
    std::string label;
    std::string token;
    size_t idx = 1;
   while (std::getline(file, line)) {
      std::stringstream line_stream(line);
      size_t i = 0;
      while (std::getline(line_stream, token, ' ')) {
        switch (i) {
          case 0:
            id = token;
            break;
          case 1:
            label = token;
            break;
        }
        token.clear();
        ++i;
      }
      classes.insert({idx, label});
      ++idx;
    }
  }
  return classes;

注意,我们在内部while循环中使用了std::getline函数来对单行字符串进行分词。我们通过指定定义分隔符字符值的第三个参数来实现这一点。

在本节中,我们学习了如何加载synset文件,该文件表示类名与它们 ID 之间的对应关系。我们使用这些信息将作为分类结果得到的类 ID 映射到其字符串表示形式,并将其展示给用户。

摘要

在本章中,我们学习了如何在不同的机器学习框架中保存和加载模型参数。我们了解到,我们在FlashlightmlpackDlibpytorch库中使用的所有框架都有一个用于模型参数序列化的 API。通常,这些函数很简单,与模型对象和一些输入输出流一起工作。我们还讨论了可以用于保存和加载整体模型架构的序列化 API。在撰写本文时,我们使用的某些框架并不完全支持此类功能。例如,Dlib库可以以 XML 格式导出神经网络,但不能加载它们。PyTorch C++ API 缺少导出功能,但它可以加载和评估从 Python API 导出并使用 TorchScript 功能加载的模型架构。然而,pytorch库确实提供了对库 API 的访问,这允许我们从 C++中加载和评估保存为 ONNX 格式的模型。然而,请注意,您可以从之前导出为 TorchScript 并加载的 PyTorch Python API 中导出模型到 ONNX 格式。

我们还简要地了解了 ONNX 格式,并意识到它是一种在不同的机器学习框架之间共享模型非常流行的格式。它支持几乎所有用于有效地序列化复杂神经网络模型的操作和对象。在撰写本文时,它得到了所有流行的机器学习框架的支持,包括 TensorFlow、PyTorch、MXNet 和其他框架。此外,微软提供了 ONNX 运行时实现,这使得我们可以在不依赖任何其他框架的情况下运行 ONNX 模型的推理。

在本章末尾,我们开发了一个 C++应用程序,可以用来在 ResNet-50 模型上进行推理,该模型是在 ONNX 格式下训练和导出的。这个应用程序是用 onnxruntime C++ API 制作的,这样我们就可以加载模型并在加载的图像上进行分类评估。

在下一章中,我们将讨论如何将使用 C++库开发的机器学习模型部署到移动设备上。

进一步阅读

第十三章:跟踪和可视化机器学习实验

机器学习ML)的世界里,可视化实验跟踪系统扮演着至关重要的角色。这些工具提供了一种理解复杂数据、跟踪实验并就模型开发做出明智决策的方法。

在机器学习中,可视化数据对于理解模式、关系和趋势至关重要。数据可视化工具允许工程师创建图表、图形和图表,帮助他们探索和分析数据。有了合适的可视化工具,工程师可以快速识别模式和异常,这些可以用来提高模型性能。

实验跟踪系统旨在跟踪多个实验的进度。它们允许工程师比较结果、识别最佳实践并避免重复错误。实验跟踪工具还有助于可重复性,确保实验可以准确且高效地重复。

选择合适的可视化与实验跟踪工具至关重要。有许多开源和商业选项可供选择,每个选项都有其优点和缺点。在选择工具时,重要的是要考虑诸如易用性、与其他工具的集成以及项目具体需求等因素。

在本章中,我们将简要讨论TensorBoard,这是最广泛使用的实验可视化系统之一。我们还将了解它能够提供哪些类型的可视化以及使用 C++时面临的挑战。至于跟踪系统,我们将讨论MLflow 框架,并提供一个使用 C++的实战示例。此示例涵盖了项目设置、定义实验、记录指标以及可视化训练过程,展示了实验跟踪工具在增强机器学习开发过程中的强大功能。

到本章结束时,你应该清楚地了解为什么这些工具对机器学习工程师至关重要,以及它们如何帮助你取得更好的成果。

本章涵盖了以下主题:

  • 理解可视化与实验跟踪系统

  • 使用 MLflow 的 REST API 进行实验跟踪

技术要求

本章的技术要求如下:

  • Flashlight 库 0.4.0

  • MLflow 2.5.0

  • cpp-httplib v0.16.0

  • nlohmann json v3.11.2

  • 支持 C++20 的现代 C++编译器

  • CMake 构建系统版本 >= 3.22

本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-second-edition/tree/master/Chapter13/flashlight

理解实验的可视化和跟踪系统

ML 实验的视觉化和跟踪系统是 ML 开发过程中的关键组件。这些系统共同使工程师能够构建更稳健和有效的 ML 模型。它们还有助于确保开发过程中的可重复性和透明度,这对于科学严谨性和协作至关重要。

可视化工具提供了数据的图形表示,使工程师能够看到在原始数据中可能难以检测到的模式、趋势和关系。这可以帮助工程师深入了解其模型的行为,识别改进领域,并在模型设计和超参数调整方面做出明智的决定。

实验跟踪系统允许工程师记录和组织实验,包括模型架构、超参数和训练数据。这些系统提供了整个实验过程的概述,使得比较不同的模型并确定哪些模型表现最佳变得更加容易。

接下来,我们将探讨 TensorBoard 的一些关键特性,这是一个强大的可视化工具,并了解 MLflow 这一有效实验跟踪系统的基本组件。

TensorBoard

TensorBoard 是一个用于 ML 模型的可视化工具,它提供了对模型性能和训练进度的洞察。它还提供了一个交互式仪表板,用户可以在其中探索图表、直方图、散点图以及其他与实验相关的可视化。

这里是 TensorBoard 的一些关键特性:

  • 损失准确率精确率召回率F1 分数

  • 直方图图:TensorBoard 还提供了直方图图,以更好地理解模型性能。这些图表可以帮助用户了解层权重和梯度值的分布。

  • 图形:TensorBoard 中的图形提供了模型架构的视觉表示。用户可以创建图形来分析输入和输出之间的相关性,或比较不同的模型。

  • 图像:TensorBoard 允许您显示图像数据,并将此类可视化连接到训练时间线。这可以帮助用户分析输入数据、中间输出或卷积滤波器结果的可视化。

  • 嵌入投影仪:TensorBoard 中的嵌入投影仪允许用户使用如主成分分析(PCA)等技术,在较低维度中探索高维数据。此功能有助于可视化复杂的数据集。

  • 比较:在 TensorBoard 中,比较功能使用户能够并排比较多个模型的性能,从而轻松地识别出表现最佳的模型。

不幸的是,TensorBoard 与 C++ ML 框架的集成并不容易。原生 C++支持仅存在于 TensorFlow 框架中。此外,只有一个第三方开源库允许我们使用 TensorBoard,而且它并没有得到积极维护。

TensorBoard 可以与各种基于 Python 的深度学习框架集成,包括 TensorFlow、PyTorch 等。因此,如果你在 Python 中训练模型,将其视为一个可以帮助你了解模型性能、识别潜在问题并就超参数、数据预处理和模型设计做出明智决策的工具是有意义的。

否则,为了可视化训练数据,你可以使用基于 gnuplot 的库,如 CppPlot,正如我们在前几章中所做的那样。参见 第三章 中的 2D 散点图和线图可视化示例。

MLflow

MLflow 是一个为 机器学习操作MLOps)设计的开源框架,帮助团队管理、跟踪和扩展他们的机器学习项目。它提供了一套用于构建、训练和部署模型以及监控其性能和实验的工具和功能。

MLflow 的主要组件如下:

  • 实验跟踪:MLflow 允许用户跟踪他们的实验,包括超参数、代码版本和指标。这有助于理解不同配置对模型性能的影响。

  • 代码可重现性:使用 MLflow,用户可以通过跟踪代码版本和依赖关系轻松重现他们的实验。这确保了实验之间的一致性,并使得识别问题更加容易。

  • 模型注册:MLflow 提供了一个模型注册组件,用户可以在其中存储、版本控制和管理工作模型。这允许团队内部轻松协作和共享模型。

  • 与其他工具的集成:MLflow 与流行的数据科学和机器学习工具集成,如 Jupyter Notebook、TensorFlow、PyTorch 等。这实现了与现有工作流程的无缝集成。对于非 Python 环境,MLflow 提供了 REST API。

  • 部署选项:MLflow 提供了各种部署模型的选择,包括 Docker 容器、Kubernetes 和云平台。这种灵活性使用户能够根据他们的需求选择最佳的部署策略。

内部,MLflow 使用数据库来存储关于实验、模型和参数的元数据。默认情况下,它使用 SQLite,但也支持其他数据库,如 PostgreSQL 和 MySQL。这为存储需求提供了可扩展性和灵活性。MLflow 使用唯一的标识符来跟踪平台内的对象和操作。这些标识符用于将实验的不同组件链接在一起,例如运行及其关联的参数。这使得重现实验和理解工作流程不同部分之间的关系变得容易。它还提供了一个 REST API,用于以编程方式访问模型注册、跟踪和模型生命周期管理等功能。它使用 YAML 配置文件来自定义和配置 MLflow 的行为,并使用 Python API 以便于与 MLflow 组件和工作流程集成。

因此,我们可以总结说,可视化和实验跟踪系统是数据科学家和工程师理解、分析和优化其机器学习模型的重要工具。这些系统允许用户跟踪不同模型的性能,比较结果,识别模式,并在模型开发和部署方面做出明智的决定。

为了说明如何将实验跟踪工具集成到机器学习工作流程中,我们将在下一节提供一个具体的示例。

使用 MLflow 的 REST API 进行实验跟踪

让我们考虑一个涉及回归模型的实验示例。我们将使用 MLflow 记录多个实验的性能指标和模型参数。在训练模型时,我们将使用图表可视化结果,以显示准确性和损失曲线随时间的变化。最后,我们将使用跟踪系统比较不同实验的结果,以便我们可以选择性能最佳的模型并进一步优化它。

此示例将演示如何无缝地将实验跟踪集成到 C++ 机器学习工作流程中,提供有价值的见解并提高研究的整体质量。

在您可以使用 MLflow 之前,您需要安装它。您可以使用 pip 安装 MLflow:

pip install mlflow

然后,你需要启动一个服务器,如下所示:

mlflow server --backend-store-uri file:////samples/Chapter13/mlruns

此命令在 http://localhost:5000 启动本地跟踪服务器,并将跟踪数据保存到 /samples/Chapter13/mlruns 目录。如果您需要从远程机器访问 MLflow 服务器,可以使用 --host--port 参数启动命令。

启动跟踪服务器后,我们可以使用 REST API 与之通信。此 API 的访问点托管在 http://localhost:5000/api/2.0/mlflow/。MLflow 使用 JSON 作为其 REST API 的数据表示。

为了实现与跟踪服务器通信的 REST 客户端,我们将使用两个额外的库:

  • cpp-httplib:用于实现 HTTP 通信

  • nlohmann json:用于实现 REST 请求和响应

注意,基本的线性回归模型将使用 Flashlight 库实现。

接下来,我们将学习如何连接所有这些部分。我们将首先介绍实现 REST 客户端。

实现 MLflow 的 REST C++客户端

MLflow 有两个主要概念:实验运行。它们共同提供了一个结构化的方法来管理和跟踪机器学习工作流程。它们帮助我们组织项目,确保可重复性,并促进团队成员之间的协作。

在 MLflow 中,我们可以组织和跟踪我们的机器学习实验。一个实验可以被视为与特定项目或目标相关的所有运行的一个容器。它允许您跟踪您模型的多个版本,比较它们的性能,并确定最佳版本。

以下是一个实验的关键特性:

  • 名称:每个实验都有一个独特的名称来标识它。

  • 标签:您可以为实验添加标签,根据不同的标准对其进行分类。

  • 工件位置:工件是在实验过程中生成的文件,例如图像、日志等。MLflow 允许您存储和版本化这些工件。

运行代表实验的单次执行或实验内的特定任务。运行用于记录每次执行的详细信息,例如其开始时间、结束时间、参数和指标。

运行的关键特性如下:

  • 开始时间:运行开始的时间

  • 结束时间:运行完成的时间

  • 参数:模型参数,如批量大小、学习率等

  • 模型:运行期间执行的模型代码

  • 输出:运行产生的结果,包括指标、工件等

在运行过程中,用户可以记录参数、指标和模型表示。

现在我们已经了解了 MLflow 跟踪结构的主要概念,让我们来实现 MLflow REST 客户端:

  1. 首先,我们将REST客户端的所有实现细节放在一个单独的MLFlow类中。头文件应如下所示:

    class MLFlow {
     public:
      MLFlow(const std::string& host, size_t port);
      void set_experiment(const std::string& name);
      void start_run();
      void end_run();
      void log_metric(const std::string& name, float value,
                      size_t epoch);
      void log_param(const std::string& name, 
                     const std::string& value);
      template <typename T>
      void log_param(const std::string& name, T value) {
        log_param(name, std::to_string(value));
      }
     private:
      httplib::Client http_client_;
      std::string experiment_id_;
      std::string run_id_;
    }
    

    我们让构造函数接受主机和跟踪服务器的端口以进行通信。然后,我们定义了启动命名实验并在其中运行的方法,以及记录命名指标和参数的方法。之后,我们声明了一个httplib::Client类的实例,该实例将用于与跟踪服务器进行 HTTP 通信。最后,我们提供了成员变量,即当前实验和运行的 ID。

  2. 现在,让我们学习如何实现这些方法。构造函数的实现如下:

    MLFlow::MLFlow(const std::string& host, size_t port)
         : http_client_(host, port) {
    }
    

    在这里,我们使用主机和端口值初始化httplib::Client实例,以初始化与跟踪服务器的连接。

  3. 以下代码显示了set_experiment方法的实现:

    void MLFlow::set_experiment(const std::string& name) {
      auto res = http_client_.Get(
          "/api/2.0/mlflow/experiments/"
          "get-by-name?experiment_name=" +
          name);
      if (check_result(res, 404)) {
        // Create a new experiment
        nlohmann::json request;
        request["name"] = name;
        res = http_client_.Post(
            "/api/2.0/mlflow/experiments/create",
            request.dump(), "application/json");
        handle_result(res);
        // Remember experiment ID
        auto json = nlohmann::json::parse(res->body);
        experiment_id_ =
            json["experiment_id"].get<std::string>();
      } else if (check_result(res, 200)) {
        // Remember experiment ID
        auto json = nlohmann::json::parse(res->body);
        experiment_id_ = json["experiment"]["experiment_id"]
                             .get<std::string>();
      } else {
        handle_result(res);
      }
    }
    

    此方法为以下运行初始化实验。此方法有两个部分——一个用于新实验,另一个用于现有实验:

    1. 首先,我们使用以下代码检查服务器上是否存在具有给定名称的实验:
    auto res = http_client_.Get(
        "/api/2.0/mlflow/experiments/get-byname?experiment_name=" + 
        name)
    

    通过比较结果与404202代码,我们确定不存在这样的实验或它已经存在。

    1. 由于没有现有实验,我们创建了一个基于 JSON 的请求来创建新实验,如下所示:
      nlohmann::json request;
      request["name"] = name;
    
    1. 然后,我们将它作为 HTTP 请求的正文传递给服务器,如下所示:
    res = http_client_.Post("/api/2.0/mlflow/experiments/create",
                            request.dump(), "application/json");
    handle_result(res);
    
    1. 使用了nlohmann::json对象的dump方法将 JSON 转换为字符串表示。在得到结果后,我们使用handle_result函数检查错误(此函数将在稍后详细讨论)。在res变量中,我们取了experiment_id值,如下所示:
      auto json = nlohmann::json::parse(res->body);
      experiment_id_ = json["experiment_id"].get<std::string>();
    

    在这里,我们使用nlohmann::json::parse函数解析服务器返回的字符串,并将experiment_id值从 JSON 对象读取到我们的类成员变量中。

    1. 在方法的第二部分,当服务器上存在实验时,我们在 JSON 对象中解析响应并获取experiment_id值。

    有两个名为handle_result的函数用于检查响应代码,并在需要时报告错误。第一个函数用于检查响应是否包含某些特定代码,其实现如下:

    bool check_result(const httplib::Result& res, int code) {
      if (!res) {
        throw std::runtime_error(
          "REST error: " + httplib::to_ string(res.error()));
        }
        return res->status == code;
    }
    

    在这里,我们使用布尔转换运算符检查httplib::Result对象是否有有效的响应。如果有通信错误,我们抛出运行时异常。否则,我们返回响应代码的比较结果。

    1. 第二个handle_result函数用于检查我们是否从服务器获得了成功的答案。以下代码片段显示了其实现方式:
    void handle_result(const httplib::Result& res) {
      if (check_result(res, 200))
        return;
      std::ostringstream oss;
      oss << "Request error status: " << res->status << " "
          << httplib::detail::status_message(res->status);
      oss << ", message: " << std::endl
          << res->body;
      throw std::runtime_error(oss.str());
    }
    

    我们使用之前的handle_result函数来检查响应是否有效,并获得了200响应代码。如果是真的,我们就没问题。然而,在失败的情况下,我们必须做出详细的报告并抛出运行时异常。

    这些函数有助于简化响应错误处理代码,并使其更容易调试通信。

  4. 我们接下来要讨论的两个方法是start_runend_run。这些方法标记了一个单独运行的界限,在此范围内我们可以记录指标、参数和工件。在生产代码中,将此类功能封装到某种 RAII 抽象中是有意义的,但我们为了简单起见创建了两个方法。

    start_run方法可以如下实现:

    void MLFlow::start_run() {
      nlohmann::json request;
      request["experiment_id"] = experiment_id_;
      request["start_time"] =
          std::chrono::duration_ cast<std::chrono::milliseconds>(
              std::chrono::system_ clock::now().time_since_epoch())
              .count();
      auto res =
          http_client_.Post("/api/2.0/mlflow/runs/create",
                            request.dump(), "application/json");
      handle_result(res);
      auto json = nlohmann::json::parse(res->body);
      run_id_ = json["run"]["info"]["run_id"];
    }
    

    在这里,我们发送了一个基于 JSON 的请求来创建一个运行。此请求填充了当前的experiment_id值和运行的开始时间。然后,我们向服务器发送请求并获得了响应,我们使用handle_result函数检查了它。如果我们收到答案,我们将其解析到nlohmann::json对象中,并获取run_id值。run_id值存储在对象成员中,将在后续请求中使用。在调用此方法后,跟踪服务器将所有指标和参数写入这个新的运行。

  5. 要完成运行,我们必须通知服务器。end_run方法正是如此:

    void MLFlow::end_run() {
      nlohmann::json request;
      request["run_id"] = run_id_;
      request["status"] = "FINISHED";
      request["end_time"] =
          std::chrono::duration_cast<std::chrono::milliseconds>(
              std::chrono::system_clock::now()
                  .time_since_epoch())
              .count();
      auto res = http_client_.Post(
          "/api/2.0/mlflow/runs/update", request.dump(),
          "application/json");
      handle_result(res);
    }
    

在这里,我们发送了一个基于 JSON 的请求,该请求包含run_id值、完成状态和结束时间。然后,我们将此请求发送到跟踪服务器并检查响应。请注意,我们发送了运行的开始和结束时间,此时服务器使用这些时间来计算运行持续时间。因此,您将能够看到运行持续时间如何取决于其参数。

现在我们有了设置实验和定义运行的方法,我们需要方法来记录指标值和运行参数。

记录指标值和运行参数

指标和参数之间的区别在于,指标是在运行期间的一系列值。您可以记录所需数量的单个指标值。通常,这个数字等于纪元或批次,MLflow 将为这些指标显示实时图表。然而,单个参数在每次运行中只能记录一次,通常是一个训练特征,如学习率。

指标通常是一个数值,因此我们使log_metric方法接受一个浮点数和一个值参数。请注意,此方法接受指标名称和纪元索引以生成同一指标的多个不同值。方法实现如下代码片段:

void MLFlow::log_metric(const std::string& name,
                        float value, size_t epoch) {
  nlohmann::json request;
  request["run_id"] = run_id_;
  request["key"] = name;
  request["value"] = value;
  request["step"] = epoch;
  request["timestamp"] =
      std::chrono::duration_
      cast<std::chrono::milliseconds>(
          std::chrono::system_clock::now()
              .time_ since_epoch())
          .count();
  auto res = http_client_.Post(
      "/api/2.0/mlflow/runs/log-metric", request.dump(),
      "application/json");
  handle_result(res);
}

在这里,我们创建了一个基于 JSON 的请求,该请求包含run_id值,将指标名称作为key字段,指标值,将纪元索引作为step字段,以及时间戳值。然后,我们将请求发送到跟踪服务器并检查响应。

参数值可以具有任意值类型,因此我们使用了 C++模板来编写一个处理不同值类型的方法。这里有两个log_param函数——第一个是一个模板函数,它将任何合适的参数值转换为字符串,而第二个只接受参数名称和字符串值作为参数。模板可以像这样实现:

template <typename T>
void log_param(const std::string& name, T value) {
  log_param(name, std::to_string(value));
}

此模板简单地将调用重定向到第二个函数,在将值转换为字符串后使用std::to_string函数。因此,如果值的类型无法转换为字符串,将发生编译错误。

第二个log_param函数的实现可以在以下代码片段中看到:

void MLFlow::log_param(const std::string& name,
                       const std::string& value) {
  nlohmann::json request;
  request["run_id"] = run_id_;
  request["key"] = name;
  request["value"] = value;
  auto res = http_client_.Post("/api/2.0/mlflow/runs/log-parameter", 
                               request.dump(), "application/json");
  handle_result(res);
}

在这里,我们创建了一个基于 JSON 的请求,该请求包含当前的run_id值,参数名称作为key字段,以及值。然后,我们仅发送请求并检查响应。

MLflow 中的 REST API 比这更丰富;我们在这里只介绍了基本功能。例如,它还可以接受 JSON 格式的模型架构,记录输入数据集,管理实验和模型,等等。

既然我们已经了解了与 MLflow 服务器通信的基本功能,让我们学习如何实现回归任务的实验跟踪会话。

将实验跟踪集成到线性回归训练中

在本节中,我们将使用Flashlight库来实现一个线性回归模型并进行训练。我们的代码从初始化 Flashlight 并连接到 MLflow 服务器开始,如下所示:

fl::init();
MLFlow mlflow("127.0.0.1","5000");
mlflow.set_experiment("Linear regression");

在这里,我们假设跟踪服务器已经在本地主机上启动。之后,我们将实验名称设置为线性回归。现在,我们可以定义必要的参数并开始运行:

int batch_size = 64;
float learning_rate = 0.0001;
float momentum = 0.5;
int epochs = 100;
mlflow.start_run();

配置运行后,我们可以加载用于训练和测试的数据集,定义一个模型,并根据我们之前定义的参数创建一个优化器和损失函数:

// load datasets
auto train_dataset = make_dataset(/*n=*/10000, batch_size);
auto test_dataset = make_dataset(/*n=*/1000, batch_size);
// Define a model
fl::Sequential model;
model.add(fl::View({1, 1, 1, -1}));
model.add(fl::Linear(1, 1));
// define MSE loss
auto loss = fl::MeanSquaredError();
// Define optimizer
auto sgd = fl::SGDOptimizer(model.params(), learning_rate, momentum);
// Metrics meter
fl::AverageValueMeter meter;

注意,我们使用了之前定义的所有参数,除了 epoch 编号。现在,我们准备好定义训练周期,如下所示:

for (int epoch_i = 0; epoch_i < epochs; ++epoch_i) {
  meter.reset();
  model.train();
  for (auto& batch : *train_dataset) {
    sgd.zeroGrad();
    // Forward propagation
    auto predicted = model(fl::input(batch[0]));
    // Calculate loss
    auto local_batch_size = batch[0].shape().dim(0);
    auto target =
        fl::reshape(batch[1], {1, 1, 1, local_batch_ size});
    auto loss_value = loss(predicted, fl::noGrad(target));
    // Backward propagation
    loss_value.backward();
    // Update parameters
    sgd.step();
    meter.add(loss_value.scalar<float>());
  }
  // Train metrics logging
  // ...
  // Calculate and log test metrics
  // ...
}

训练周期的主体看起来很正常,因为我们已经在之前章节中实现了它。请注意,我们有两个嵌套周期——一个用于 epochs,另一个用于 batches。在训练 epoch 的开始,我们清除了用于平均训练损失指标的平均仪表,并将模型置于训练模式。然后,我们清除了梯度,进行了前向传递,计算了损失值,进行了反向传递,使用优化器步骤更新了模型权重,并将损失值添加到平均值仪表对象中。在内部周期之后,训练完成。在此阶段,我们可以将平均训练损失指标值记录到跟踪服务器,如下所示:

// train metrics logging
auto avr_loss_value = meter.value()[0];
mlflow.log_metric("train loss", avr_loss_value, epoch_i);

在这里,我们记录了具有epoch_i索引的 epoch 的训练损失值,并将其命名为train loss。对于每个 epoch,这种记录将为指标添加一个新值,我们将在 MLflow UI 中看到训练损失随 epoch 变化的实时图表。此图表将在以下小节中展示。

在训练周期之后,对于每个第 10 个 epoch,我们计算测试损失指标,如下所示:

// Every 10th epoch calculate test metric
if (epoch_i % 10 == 0) {
  fl::AverageValueMeter test_meter;
  model.eval();
  for (auto& batch : *test_dataset) {
    // Forward propagation
    auto predicted = model(fl::input(batch[0]));
    // Calculate loss
    auto local_batch_size = batch[0].shape().dim(0);
    auto target =
        fl::reshape(batch[1], {1, 1, 1, local_batch_ size});
    auto loss_value = loss(predicted, fl::noGrad(target));
    // Add loss value to test meter
    test_meter.add(loss_value.scalar<float>());
  }
  // Logging the test metric
  // ...
}

一旦我们确认当前 epoch 是第 10 个,我们为测试损失指标定义了一个额外的平均值仪表对象,并实现了评估模式。然后,我们计算每个批次的损失值,并将这些值添加到平均值仪表中。在此阶段,我们可以实现测试数据集的损失计算,并将测试指标记录到跟踪服务器:

// logging the test metric
auto avr_loss_value = test_meter.value()[0];
mlflow.log_metric("test loss", avr_loss_value, epoch_i);

在这里,我们记录了具有epoch_i索引的每个 epoch 的测试损失值,并将其命名为test loss。MLflow 也将为此指标提供图表。我们将能够将此图表与训练指标图表重叠,以检查是否存在诸如过拟合等问题。

现在我们已经完成了训练周期,我们可以结束运行并记录其参数,如下所示:

mlflow.end_run();
mlflow.log_param("epochs", epochs);
mlflow.log_param("batch_size", batch_size);
mlflow.log_param("learning_rate", learning_rate);
mlflow.log_param("momentum", momentum);

在这里,我们使用end_run调用记录了运行参数。这是使用 MLflow API 时的一个要求。请注意,参数值可以有不同的类型,并且它们只记录了一次。

现在,让我们看看 MLflow 将如何显示具有不同训练参数的程序运行。

实验跟踪过程

以下图显示了跟踪服务器启动后的 MLflow UI:

图 13.1 – 无实验和运行的 MLflow UI 概览

图 13.1 – 无实验和运行的 MLflow UI 概览

如我们所见,没有实验和运行信息。在执行一组参数的程序运行后,UI 将如下所示:

图 13.2 – 单个实验和一个运行下的 MLflow UI 概览

图 13.2 – 单个实验和一个运行下的 MLflow UI 概览

如您所见,线性回归出现在左侧面板中。同时,在右侧表格中为运行记录了新的条目。请注意,peaceful-ray-50的运行名称是自动生成的。在这里,我们可以看到开始时间和运行所需的时间。点击运行名称将打开运行详情页面,其外观如下:

图 13.3 – MLflow UI 中运行详情概览

图 13.3 – MLflow UI 中运行详情概览

在这里,我们可以看到开始时间和日期,与此次运行关联的实验 ID,运行 ID 和其持续时间。请注意,这里可能还会提供其他信息,例如用户名、使用的数据集、标签和模型源。这些附加属性也可以通过 REST API 进行配置。

在底部,我们可以看到参数表,其中我们可以找到从我们的代码中记录的参数。还有一个指标表,它显示了我们的训练和测试损失值指标的最终值。

如果我们点击模型指标标签,将显示以下页面:

图 13.4 – MLflow UI 中的模型指标页面

图 13.4 – MLflow UI 中的模型指标页面

在这里,我们可以看到训练和测试损失指标图。这些图显示了损失值随时间变化的情况。通常,将训练和测试损失图重叠起来查看一些依赖关系是有用的。我们可以通过点击显示在图 13**.3中的页面上的指标名称来实现这一点。点击训练损失后,将显示以下页面:

图 13.5 – 训练损失指标图

图 13.5 – 训练损失指标图

在这里,我们可以看到单个指标的图。在这个页面上,我们可以为图表配置一些可视化参数,例如平滑度和步长。然而,在这种情况下,我们感兴趣的是Y 轴字段,它允许我们将额外的指标添加到同一图表中。如果我们添加测试损失指标,我们将看到以下页面:

图 13.6 – 指标图的叠加

图 13.6 – 指标图的叠加

现在,我们有两个重叠的训练和测试指标图。在这个可视化中,我们可以看到在前几个时间步中,测试损失大于训练损失,但在第 15 个时间步之后,损失值非常相似。这意味着没有模型过拟合。

在这种情况下,我们查看了 MLflow UI 中单个训练运行的常规区域。对于更复杂的情况,将会有包含工件和模型源的页面,但在这里我们跳过了它们。

接下来,让我们学习如何处理实验中的多个运行。我们再次运行了我们的应用程序,但动量值不同。MLflow 显示我们有两个相同实验的运行,如下所示:

图 13.7 – 两个运行实验

图 13.7 – 具有两个运行的实验

如我们所见,实验中有两个运行。它们之间只有两个细微的差异——名称和持续时间。要比较运行,我们必须点击运行名称前方的两个复选框,如图所示:

图 13.8 – 选择两个运行

图 13.8 – 选择两个运行

在选择两个运行后,比较按钮出现在运行表的最上方。点击此按钮将打开以下页面:

图 13.9 – 运行比较页面概述

此页面显示了并排的两个运行,并显示了各种指标和参数之间的不同可视化。请注意,运行参数的差异也将被突出显示。从左上角的面板中,您可以选择您想要比较的参数和指标。通过这样做,我们可以看到具有较低动量值的新的运行表现较差。这在上面的图中表示,其中线条连接参数和指标,并且有带有值的刻度。这也可以在底部的指标行中看到,在那里您可以比较最终的指标值。

在本节中,我们学习了如何使用 MLflow 用户界面来探索实验运行行为,以及如何查看指标可视化以及如何比较不同的运行。所有跟踪信息都由跟踪服务器保存,可以在服务器重启后使用,因此它对于机器学习从业者来说是一个非常实用的工具。

摘要

可视化和实验跟踪系统是机器学习工程师的必备工具。它们使我们能够了解模型的性能,分析结果,并改进整体流程。

TensorBoard 是一个流行的可视化系统,它提供了关于模型训练的详细信息,包括指标、损失曲线、直方图等。它支持多个框架,包括 TensorFlow,并允许我们轻松比较不同的运行。

MLflow 是一个开源框架,为模型生命周期管理提供端到端解决方案。它包括实验跟踪、模型注册、工件管理以及部署等功能。MLflow 帮助团队协作、重现实验并确保可重复性。

TensorBoard 和 MLflow 都是功能强大的工具,可以根据您的需求一起使用或单独使用。

在理解了 TensorBoard 和 MLflow 之后,我们实现了一个带有实验跟踪的线性回归训练示例。通过这样做,我们学习了如何实现 MLflow 服务器的 REST API 客户端,以及如何使用它来记录实验的指标和参数。然后,我们探索了 MLflow 用户界面,在那里我们学习了如何查看实验及其运行详情,以及如何查看指标图,并学习了如何比较不同的运行。

在下一章中,我们将学习如何在 Android 移动平台上使用 C++为计算机视觉应用机器学习模型。

进一步阅读

第十四章:在移动平台上部署模型

在本章中,我们将讨论在运行 Android 操作系统的移动设备上部署机器学习ML)模型。机器学习可以用于改善移动设备上的用户体验,尤其是我们可以创建更多自主功能,使我们的设备能够学习和适应用户行为。例如,机器学习可用于图像识别,使设备能够识别照片和视频中的对象。此功能对于增强现实或照片编辑工具等应用程序可能很有用。此外,由机器学习驱动的语音识别可以使语音助手更好地理解和响应自然语言命令。自主功能开发的另一个重要好处是它们可以在没有互联网连接的情况下工作。这在连接有限或不稳定的情况下尤其有用,例如在偏远地区旅行或自然灾害期间。

在移动设备上使用 C++可以使我们编写更快速、更紧凑的程序。由于现代编译器可以针对目标 CPU 架构优化程序,我们可以利用尽可能多的计算资源。C++不使用额外的垃圾回收器进行内存管理,这可能会对程序性能产生重大影响。程序大小可以减小,因为 C++不使用额外的虚拟机VM)并且直接编译成机器码。此外,使用 C++可以通过更精确的资源使用和相应调整来帮助优化电池寿命。这些事实使 C++成为资源有限的移动设备的正确选择,并且可以用于解决重计算任务。

到本章结束时,你将学习如何使用 PyTorch 和 YOLOv5 在 Android 移动平台上通过相机实现实时对象检测。但本章并非 Android 开发的全面介绍;相反,它可以作为在 Android 平台上进行机器学习和计算机视觉实验的起点。它提供了一个完整的、所需的最小项目示例,你可以根据你的任务对其进行扩展。

本章涵盖了以下主题:

  • 创建 Android C++开发所需的最小项目

  • 实现对象检测所需的最小 Kotlin 功能

  • 在项目的 C++部分初始化图像捕获会话

  • 使用 OpenCV 处理原生相机图像并绘制结果

  • 使用 PyTorch 脚本在 Android 平台上启动 YOLOv5 模型

技术要求

以下为本章节的技术要求:

  • Android Studio,Android 软件开发工具包SDK),以及 Android 本地开发 工具包NDK

  • PyTorch 库

  • 支持 C++20 的现代 C++编译器

  • CMake 构建系统版本 >= 3.22

本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-Second-edition/tree/main/Chapter14

在 Android 上开发目标检测

关于如何将机器学习模型部署到 Android 移动设备上,有许多方法。我们可以使用 PyTorch、ExecuTorch、TensorFlow Lite、NCNN、ONNX Runtime 或其他工具。在本章中,我们将使用 PyTorch 框架,因为我们已经在之前的章节中讨论过它,并且因为它允许我们使用几乎任何 PyTorch 模型,同时功能限制最小。不幸的是,我们只能使用目标设备的 CPU 进行推理。其他框架,如 ExecuTorch、TensorFlow Lite、NCNN 和 ONNX Runtime,允许您使用其他推理后端,例如板载 GPU 或神经处理单元NPU)。然而,这个选项也带来一个显著的限制,即缺少某些操作符或函数,这可能会限制可以在移动设备上部署的模型类型。动态形状支持通常有限,这使得处理不同维度的数据变得困难。

另一个挑战是受限的控制流,这限制了使用具有动态计算图和实现高级算法的模型的能力。这些限制可能会使得使用前面描述的框架在移动平台上部署机器学习模型变得更加困难。因此,当在移动设备上部署机器学习模型时,模型的功能和所需性能之间存在权衡。为了平衡功能和性能,开发者必须仔细评估他们的需求,并选择满足他们特定需求的框架。

PyTorch 框架的移动版本

Maven 仓库中有一个名为org.pytorch:pytorch_android_lite的 PyTorch 移动设备二进制分发版。然而,这个分发版已经过时。因此,要使用最新版本,我们需要从源代码构建它。我们可以像编译其常规版本一样做,但需要额外的 CMake 参数来启用移动模式。您还必须安装 Android NDK,它包括适当的 C/C++编译器和构建应用程序所需的 Android 原生库。

安装 Android 开发工具最简单的方法是下载 Android Studio IDE,并使用该 IDE 中的 SDK Manager 工具。您可以在cmdline-tools包下找到 SDK Manager。然而,您需要在系统中安装 Java;对于 Ubuntu,您可以按照以下方式安装 Java:

sudo apt install default-jre

以下命令行脚本展示了如何安装 CLI 开发所需的所有包:

# make the folder where to install components
mkdir android
cd android
# download command line tools
wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
# unzip them and move to the correct folder
unzip commandlinetools-linux-9477386_latest.zip
mv cmdline-tools latest
mkdir cmdline-tools
mv latest cmdline-tools
# install SDK, NDK and build tools for Android using sdkmanager utility
yes | ./cmdline-tools/latest/bin/sdkmanager --licenses
yes | ./cmdline-tools/latest/bin/sdkmanager "platform-tools"
yes | ./cmdline-tools/latest/bin/sdkmanager "platforms;android-35"
yes | ./cmdline-tools/latest/bin/sdkmanager "build-tools;35.0.0"
yes | ./cmdline-tools/latest/bin/sdkmanager "system-images;android-35;google_apis;arm64-v8a"
yes | ./cmdline-tools/latest/bin/sdkmanager --install "ndk;26.1.10909125"

在这里,我们使用了sdkmanager管理工具来安装所有所需的组件及其适当的版本。使用此脚本,NDK 的路径将如下所示:

android/ndk/26.1.10909125/

安装了构建工具和 NDK 后,我们可以继续编译 PyTorch 移动版本。

以下代码片段展示了如何使用命令行环境检出 PyTorch 并构建它:

cd /home/[USER]
git clone https://github.com/pytorch/pytorch.git
cd pytorch/
git checkout v2.3.1
git submodule update --init --recursive
export ANDROID_NDK=[Path to the installed NDK]
export ANDROID_ABI='arm64-v8a'
export ANDROID_STL_SHARED=1
$START_DIR/android/pytorch/scripts/build_android.sh \
-DBUILD_CAFFE2_MOBILE=OFF \
-DBUILD_SHARED_LIBS=ON \
-DUSE_VULKAN=OFF \
-DCMAKE_PREFIX_PATH=$(python -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())') \
-DPYTHON_EXECUTABLE=$(python -c 'import sys; print(sys.executable)') \

在这里,我们假设 /home/[USER] 是用户的家目录。构建 PyTorch 移动版本时的主要要求是声明 ANDROID_NDK 环境变量,它应该指向 Android NDK 安装目录。可以使用 ANDROID_ABI 环境变量来指定 arm64-v8a 架构。

我们使用了 PyTorch 源代码分发中的 build_android.sh 脚本来构建移动 PyTorch 二进制文件。这个脚本内部使用 CMake 命令,这就是为什么它需要将 CMake 参数定义作为参数。请注意,我们传递了 BUILD_CAFFE2_MOBILE=OFF 参数来禁用构建 Caffe2 的移动版本,因为当前版本中该库已被弃用,难以使用。我们使用的第二个重要参数是 BUILD_SHARED_LIBS=ON,这使我们能够构建共享库。我们还通过使用 DUSE_VULKAN=OFF 禁用了 Vulkan API 支持,因为它仍然是实验性的,并且存在一些编译问题。其他配置的参数是用于中间构建代码生成的 Python 安装路径。

现在我们已经有了移动 PyTorch 库,即 libc10.solibtorch.so,我们可以开始开发应用程序了。我们将基于 YOLOv5 神经网络架构构建一个目标检测应用程序。

YOLOv5 是一种基于 You Only Look Once (YOLO) 架构的目标检测模型。它是一个最先进的深度学习模型,能够以高精度和速度在图像和视频中检测物体。该模型相对较小且轻量,便于在资源受限的设备上部署。此外,它足够快速,这对于需要分析实时视频流的实时应用来说非常重要。它是一个开源软件,这意味着开发者可以自由访问代码并根据他们的需求进行修改。

使用 TorchScript 进行模型快照

在本节中,我们将讨论如何获取 YOLOv5 模型的 TorchScript 文件,以便我们可以在我们的移动应用程序中使用它。在前面的章节中,我们讨论了如何保存和加载模型参数,以及如何使用 ONNX 格式在框架之间共享模型。当我们使用 PyTorch 框架时,我们还可以使用另一种方法在 Python API 和 C++ API 之间共享模型,称为 TorchScript

此方法使用实时模型跟踪来获取一种特殊类型的模型定义,该定义可以由 PyTorch 引擎执行,不受 API 限制。在 PyTorch 中,只有 Python API 可以创建此类定义,但我们可以使用 C++ API 来加载模型并执行它。此外,PyTorch 框架的移动版本不允许我们使用功能齐全的 C++ API 来编程神经网络。然而,正如之前所说,TorchScript 允许我们导出和运行具有复杂控制流和动态形状的模型,这在目前对于 ONNX 和其他在其他移动框架中使用的格式来说是不完全可能的。

目前,YOLOv5 PyTorch 模型可以直接导出为 TorchScript,以便在移动 CPU 上进行推理。例如,有针对 TensorFlow Lite 和 NCNN 框架优化的 YOLOv5 模型,但我们将不讨论这些情况,因为我们主要使用 PyTorch。我必须说,使用 NCNN 将允许你通过 Vulkan API 使用移动 GPU,而使用 TensorFlow Lite 或 ONNX Runtime for Android 将允许你使用某些设备的移动 NPU。然而,你需要通过减少一些功能或使用 TensorFlow 进行开发来将模型转换为另一种格式。

因此,在这个例子中,我们将使用 TorchScript 模型来进行目标检测。为了获取 YOLOv5 模型,我们必须执行以下步骤:

  1. 从 GitHub 克隆模型仓库并安装依赖项;在终端中运行以下命令:

    git clone https://github.com/ultralytics/yolov5
    cd yolov5
    jit script of the model optimized for mobile:
    
    

    python export.py --weights yolov5s.torchscript --include torchscript --optimize

第二步的脚本会自动跟踪模型并为我们保存 TorchScript 文件。在我们完成这些步骤后,将会有yolo5s.torchscript文件,我们将能够加载并在 C++中使用它。

现在,我们已经具备了所有先决条件,可以继续创建我们的 Android Studio 项目。

Android Studio 项目

在本节中,我们将使用 Android Studio IDE 来创建我们的移动应用程序。我们可以使用默认的objectdetection并选择Kotlin作为编程语言,然后 Android Studio 将创建一个特定的项目结构;以下示例显示了其最有价值的部分:

app
|--src
|  `--main
|    |--cpp
|    |  |—CmakeLists.txt
|    |  `—native-lib.cpp
|    |--java
|    |  `--com
|    |    `--example
|    |       `--objectdetection
|    |         `--MainActivity.kt
|    |--res
|    |  `--layout
|    |    `--activity_main.xml
|    |--values
|       |--colors.xml
|       |--strings.xml
|       |--styles.xml
|          `—…
|--build.gradle
`--...

cpp文件夹包含整个项目的 C++部分。在这个项目中,Android Studio IDE 将 C++部分创建为一个配置了 CMake 构建生成系统的本地共享库项目。java文件夹包含项目的 Kotlin 部分。在我们的案例中,它是一个定义主活动的单个文件——该活动用于将 UI 元素和事件处理器连接起来。res文件夹包含项目资源,例如 UI 元素和字符串定义。

我们还需要在main文件夹下创建一个名为jniLibs的文件夹,其结构如下:

app
|--src
|  |--main
|  |--…
|  |--JniLibs
|     `--arm64-v8a
|        |--libc10.so
|        |--libtorch_cpu.so
|        |--libtorch_global_deps.so
|        `—libtorch.so
`...

Android Studio 要求我们将额外的本地库放置在这样的文件夹中,以便正确地将它们打包到最终应用程序中。它还允许arm64-v8a文件夹,因为它们只编译了这种 CPU 架构。如果您有其他架构的库,您必须创建具有相应名称的文件夹。

此外,在上一小节中,我们学习了如何获取 YOLOv5 torch 脚本模型。模型文件及其对应文件,以及类 ID,应放置在assets文件夹中。此文件夹应创建在JniLibs文件夹旁边,处于同一文件夹级别,如下所示:

app
|--src
|  `--main
|     |--...
|     |--cpp
|     |--JniLibs
|     |--assests
|     |  |--yolov5.torchscript
|     |  `--classes.txt
|     `—...
`...

将字符串类名称映射到模型返回的数值 ID 的文件可以从github.com/ultralytics/yolov5/blob/master/data/coco.yaml下载。

在我们的示例中,我们简单地将 YAML 文件转换为文本文件,以简化其解析。

该 IDE 使用 Gradle 构建系统进行项目配置,因此有两个名为build.gradle.kts的文件,一个用于应用程序模块,另一个用于项目属性。查看我们示例中的应用程序模块的build.gradle文件。有两个变量定义了 PyTorch 源代码文件夹和 OpenCV Android SDK 文件夹的路径。如果您更改这些路径,则需要更新它们的值。预构建的 OpenCV Android SDK 可以从官方 GitHub 仓库(github.com/opencv/opencv/releases)下载,并直接解压。

项目的 Kotlin 部分

在这个项目中,我们将使用本地的 C++部分来绘制捕获的图片,并带有检测到的对象的边界框和类标签。因此,Kotlin 部分将没有 UI 代码和声明。然而,Kotlin 部分将用于请求和检查所需的相机访问权限。如果权限被授予,它还将启动相机捕获会话。所有 Kotlin 代码都将位于MainActivity.kt文件中。

保留相机方向

在我们的项目中,我们跳过了设备旋转处理的实现,以使代码更简单,并仅展示与目标检测模型一起工作的最有趣的部分。因此,为了使我们的代码稳定,我们必须禁用横屏模式,这可以在AndroidManifest.xml文件中完成,如下所示:

…
<activity
    …
    android:screenOrientation="portrait">
…

我们将屏幕方向指令添加到活动实体中。这不是一个好的解决方案,因为有些设备只能在横屏模式下工作,我们的应用程序将无法与它们一起工作。在实际的生产型应用程序中,您应该处理不同的方向模式;例如,对于大多数智能手机,这种脏解决方案应该可以工作。

处理相机权限请求

在 Android NDK 中没有 C++ API 来请求权限。我们只能从 Java/Kotlin 侧或通过 JNI 从 C++请求所需的权限。编写 Kotlin 代码请求相机权限比编写 JNI 调用要简单。

第一步是修改MainActivity类的声明,以便能够处理权限请求结果。如下所示完成:

class MainActivity
    : NativeActivity(),
      ActivityCompat.OnRequestPermissionsResultCallback {
  …
}

在这里,我们继承了MainActivity类自OnRequestPermissionsResultCallback接口。这给了我们重写onRequestPermissionsResult方法的可能性,在那里我们将能够检查结果。然而,要获取结果,我们必须首先发出请求,如下所示:

override fun onResume() {
  super.onResume() val cameraPermission =
      android.Manifest.permission
          .CAMERA if (checkSelfPermission(
                          cameraPermission) !=
                      PackageManager.PERMISSION_GRANTED) {
    requestPermissions(arrayOf(cameraPermission),
                       CAM_PERMISSION_CODE)
  }
  else {
    val camId =
        getCameraBackCameraId() if (camId.isEmpty()){
            Toast
                .makeText(
                    this,
                    "Camera probably won't work on this
                    device !",
                    Toast.LENGTH_LONG)
                .show() finish()} initObjectDetection(camId)
  }
}

我们重写了Activity类的onResume方法。此方法在每次我们的应用程序开始工作或从后台恢复时都会被调用。我们使用所需的相机权限常量值初始化了cameraPermission变量。然后,我们使用checkSelfPermission方法检查我们是否已经授予了此权限。如果没有相机权限,我们使用requestPermissions方法请求它。

注意,我们在回调方法中使用了CAM_PERMISSION_CODE代码来识别我们的请求。如果我们被授予访问相机的权限,我们尝试获取背面相机的 ID 并为该相机初始化对象检测管道。如果我们无法访问相机,我们使用finish方法和相应的消息结束 Android 活动。在onRequestPermissionsResult方法中,我们检查是否已授予所需的权限,如下所示:

override fun onRequestPermissionsResult(requestCode
                                        : Int, permissions
                                        : Array<out String>,
                                          grantResults
                                        : IntArray) {
  super.onRequestPermissionsResult(requestCode,
                                   permissions,
                                   grantResults)
    if (requestCode == CAM_PERMISSION_CODE &&
      grantResults[0] != PackageManager.PERMISSION_GRANTED)
    {
       Toast.makeText(this,
                     "This app requires camera permission",
                     Toast.LENGTH_SHORT).show()
                     finish()
    }
}

首先,我们调用了父方法以保留标准应用程序行为。然后,我们检查了权限识别代码CAM_PERMISSION_CODE以及权限是否被授予。在失败情况下,我们只显示错误消息并结束 Android 活动。

如我们之前所说,在成功的情况下,我们寻找背面相机的 ID,如下所示:

private fun getCameraBackCameraId(): String {
  val camManager = getSystemService(
      Context.CAMERA_SERVICE)as CameraManager
  for (camId in camManager.cameraIdList) {
      val characteristics =
          camManager.getCameraCharacteristics(camId)
      val hwLevel = characteristics.get(
     CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)
      val facing = characteristics.get(
          CameraCharacteristics.LENS_FACING)
      if (hwLevel != INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY &&
          facing == LENS_FACING_BACK) {
              return camId
      }
  }
  return ""
}

我们获取了CameraManager对象的实例,并使用此对象遍历设备上的每个相机。对于每个相机对象,我们询问其特征、支持的硬件级别以及该相机面向的方向。如果一个相机是常规的遗留设备并且面向背面,我们返回其 ID。如果我们没有找到合适的设备,我们返回一个空字符串。

在授予相机访问权限和相机 ID 之后,我们调用了initObjectDetection函数来开始图像捕获和对象检测。这个函数和stopObjectDetection函数是通过 JNI 从 C++部分提供给 Kotlin 部分的函数。stopObjectDetection函数用于停止相机捕获会话,如下所示:

override fun onPause() {
  super.onPause()
  stopObjectDetection()
}

在重写的onPause活动方法中,我们只是停止了相机捕获会话。此方法在每次 Android 应用程序关闭或进入后台时都会被调用。

原生库加载

有两个方法,initObjectDetectionstopObjectDetection,它们是 JNI 调用原生库中用 C++实现的函数。为了将原生库与 Java 或 Kotlin 代码连接起来,我们使用 JNI。这是一个标准的机制,用于从 Kotlin 或 Java 调用 C/C++函数。

首先,我们必须使用System.LoadLibrary调用加载原生库,并将其放置在我们活动的伴随对象中。然后,我们必须通过将它们声明为external来定义原生库中实现的方法。以下代码片段展示了如何在 Kotlin 中定义这些方法:

private external fun initObjectDetection(camId: String)
private external fun stopObjectDetection()
companion object {
  init {
      System.loadLibrary("object-detection")
  }
}

这样的声明允许 Kotlin 找到相应的原生库二进制文件,加载它,并访问函数。JNI 通过提供一组 API 来实现,允许 Java 代码调用原生代码,反之亦然。JNI API 由一组可以从 Java 或原生代码调用的函数组成。这些函数允许你执行诸如从原生代码创建和访问 Java 对象、从原生代码调用 Java 方法以及从 Java 访问原生数据结构等任务。

内部,JNI 通过将 Java 对象和类型映射到它们的对应原生版本来工作。这种映射是通过JNIEnv接口完成的,它提供了访问JNIEnv的方法,用于查找相应的原生方法并传递必要的参数。同样,当原生方法返回一个值时,使用JNIEnv将原生值转换为 Java 对象。JVM 管理 Java 和原生对象内存。然而,原生代码必须显式地分配和释放自己的内存。JNI 提供了分配和释放内存的函数,以及用于在 Java 和原生内存之间复制数据的函数。JNI 代码必须是线程安全的。这意味着任何由 JNI 访问的数据都必须正确同步,以避免竞争条件。使用 JNI 可能会有性能影响。原生代码通常比 Java 代码快,但通过 JNI 调用原生代码会有开销。

在下一节中,我们将讨论项目的 C++部分。

项目的原生 C++部分

本示例项目的主要功能是在原生 C++部分实现的。它旨在使用 OpenCV 库处理摄像头图像,并使用 PyTorch 框架进行目标检测模型推理。这种做法允许你在需要时将此解决方案移植到其他平台,并允许你使用标准桌面工具,如 OpenCV 和 PyTorch,来开发和调试将在移动平台上使用的算法。

本项目中主要有两个 C++类。Detector类是应用程序外观,它实现了与 Android 活动图像捕获管道的连接,并将对象检测委托给第二个类YOLOYOLO类实现了对象检测模型的加载及其推理。

下面的子节将描述这些类的实现细节。

使用 JNI 初始化对象检测

我们通过讨论 JNI 函数声明结束了 Kotlin 部分的讨论。initObjectDetectionstopObjectDetection 的相应 C++ 实现在 native-lib.cpp 文件中。此文件由 Android Studio IDE 自动创建,用于原生活动项目。以下代码片段展示了 initObjectDetection 函数的定义:

#include <jni.h>
...
std::shared_ptr<ObjectDetector> object_detector_;
extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_initObjectDetection(
    JNIEnv* env,
    jobject /* this */,
    jstring camId) {
  auto camera_id = env->GetStringUTFChars(camId, nullptr);

LOGI("Camera ID: %s", camera_id);
  if (object_detector_) {
    object_detector_->allow_camera_session(camera_id);
    object_detector_->configure_resources();
  } else
    LOGE("Object Detector object is missed!");
}

我们遵循 JNI 规则,使函数声明正确且在 Java/Kotlin 部分可见。函数名称包括完整的 Java 包名,包括命名空间,我们前两个必需的参数是 JNIEnv*jobject 类型。第三个参数是字符串,对应于相机 ID;这是在函数的 Kotlin 声明中存在的参数。

在函数实现中,我们检查 ObjectDetector 对象是否已经实例化,如果是这样,我们使用相机 ID 调用 allow_camera_session 方法,然后调用 configure_resources 方法。这些调用使 ObjectDetector 对象记住要使用哪个相机以及初始化,配置输出窗口,并初始化图像捕获管道。

在 Kotlin 部分我们使用的第二个函数是 stopObjectDetection,其实现如下:

extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_stopObjectDetection(
    JNIEnv*,
    jobject /* this */) {
  if (object_detector_) {
    object_detector_->release_resources();
  } else
    LOGE("Object Detector object is missed!");
}

在这里,我们只是释放了用于图像捕获管道的资源,因为当应用程序挂起时,对相机设备的访问被阻止。当应用程序再次激活时,initObjectDetection 函数将被调用,图像捕获管道将重新初始化。

您可以看到我们使用了 LOGILOGE 函数,其定义如下:

#include <android/log.h>
#define LOG_TAG "OBJECT-DETECTION"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,
                                      LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,
                                      LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,
                                      LOG_TAG, __VA_ARGS__)
#define ASSERT(cond, fmt, ...)                                \
  if (!(cond))
  {                                              \
     __android_log_assert(#cond, LOG_TAG, fmt, ##__VA_ARGS__); \
  }

我们定义了这些函数,以便更容易地将消息记录到 Android 的 logcat 子系统中。这一系列函数使用相同的标签进行记录,并且比原始的 __android_log_xxx 函数具有更少的参数。此外,日志级别被编码在函数名称中。

主应用程序循环

此项目将使用 Native App Glue 库。这是一个帮助 Android 开发者创建原生应用的库。它提供了一个抽象层,在 Java 代码和原生代码之间,使得使用这两种语言开发应用程序变得更加容易。

使用这个库,我们可以拥有一个带有循环的标准 main 函数,该循环持续运行,更新 UI,处理用户输入,并响应系统事件。以下代码片段展示了我们如何在 native-lib.cpp 文件中实现 main 函数:

extern "C" void android_main(struct android_app* app) {
  LOGI("Native entry point");
  object_detector_ = std::make_shared<ObjectDetector>(app);
  app->onAppCmd = ProcessAndroidCmd;
  while (!app->destroyRequested) {
    struct android_poll_source* source = nullptr;
    auto result = ALooper_pollOnce(0, nullptr, nullptr,
                                   (void**)&source);
    ASSERT(result != ALOOPER_POLL_ERROR,
           "ALooper_pollOnce returned an error");
    if (source != nullptr) {
      source->process(app, source);
    }
    if (object_detector_)
      object_detector_->draw_frame();
  }
  object_detector_.reset();
}

android_main 函数接收 android_app 类型的实例,而不是常规的 argcargv 参数。android_app 是一个 C++ 类,它提供了对 Android 框架的访问权限,并允许你与系统服务进行交互。此外,你可以使用它来访问设备硬件,例如传感器和摄像头。

android_main 主函数是我们本地模块的起点。因此,我们在这里初始化了全局的 object_detector_ 对象,使其对 initObjectDetectionstopObjectDetection 函数可用。对于初始化,ObjectDetector 实例接收 android_app 对象的指针。

然后,我们将命令处理函数附加到 Android 应用程序对象上。最后,我们启动了主循环,它一直工作到应用程序被销毁(关闭)。在这个循环中,我们使用 ALooper_pollOnce Android NDK 函数获取到命令(事件)轮询对象的指针。

我们调用了该对象的 process 方法,通过 app 对象将当前命令派发到我们的 ProcessAndroidCmd 函数。在循环结束时,我们使用我们的目标检测器对象抓取当前摄像头图像,并在 draw_frame 方法中对其进行处理。

ProcessAndroidCmd 函数的实现如下:

 static void ProcessAndroidCmd(struct android_app* /*app*/,
                              int32_t cmd) {
  if (object_detector_) {
  switch (cmd) {
    case APP_CMD_INIT_WINDOW:
      object_detector_->configure_resources();
      break;
    case APP_CMD_TERM_WINDOW:
      object_detector_->release_resources();
      break;
  }
}

在这里,我们只处理了两个与应用程序窗口初始化和终止相对应的命令。我们使用它们在目标检测器中初始化和清除图像捕获管道。当窗口创建时,我们根据捕获分辨率配置其尺寸。窗口终止命令允许我们清除捕获资源,以防止访问已阻塞的摄像头设备。

这就是关于 native-lib.cpp 文件的所有信息。接下来的小节将探讨 ObjectDetector 类的实现细节。

ObjectDetector 类概述

这是应用程序整个目标检测管道的主界面。以下列表显示了它所实现的功能项:

  • 摄像头设备访问管理

  • 应用程序窗口尺寸配置

  • 图像捕获管道管理

  • 将摄像头图像转换为 OpenCV 矩阵对象

  • 将对象检测结果绘制到应用程序窗口中

  • 将对象检测委托给 YOLO 推理对象

在我们开始查看这些项目细节之前,让我们看看构造函数、析构函数和一些辅助方法的实现。构造函数的实现如下:

ObjectDetector::ObjectDetector(android_app *app) : android_app_(app) {
  yolo_ = std::make_shared<YOLO>(app->activity->assetManager);
}

我们只是保存了 android_app 对象的指针,并创建了 YOLO 类推理对象。此外,我们使用 android_app 对象获取到 AssetManager 对象的指针,该对象用于加载打包到 Android 应用程序包APK)中的文件。析构函数的实现如下:

ObjectDetector::~ObjectDetector() {
  release_resources();
  LOGI("Object Detector was destroyed!");
}
void ObjectDetector::release_resources() {
  delete_camera();
  delete_image_reader();
  delete_session();
}

我们调用了release_resources方法,这是关闭已打开的相机设备和清除捕获管道对象的地方。以下代码片段显示了通过initObjectDetection函数从 Kotlin 部分使用的方法:

void ObjectDetector::allow_camera_session(std::string_view camera_id) {
  camera_id_ = camera_id;
}

allow_camera_session中,我们保存了相机 ID 字符串;具有此 ID 的设备将在configure_resources方法中打开。正如我们所知,只有当所需的权限被授予且 Android 设备上有后置摄像头时,相机 ID 才会传递给ObjectDetector。因此,我们定义了is_session_allowed如下:

bool ObjectDetector::is_session_allowed() const {
  return !camera_id_.empty();
}

这里,我们只是检查了相机 ID 是否不为空。

以下小节将详细展示主要功能项。

相机设备和应用程序窗口配置

ObjectDetection类中有一个名为create_camera的方法,它实现了创建相机管理器对象和打开相机设备,如下所示:

void ObjectDetector::create_camera() {
  camera_mgr_ = ACameraManager_create();
  ASSERT(camera_mgr_, "Failed to create Camera Manager");
  ACameraManager_openCamera(camera_mgr_, camera_id_.c_str(),
                            &camera_device_callbacks,
                            &camera_device_);
  ASSERT(camera_device_, "Failed to open camera");
}

camera_mgr_ObjectDetector成员变量,初始化后用于打开相机设备。打开的相机设备的指针将存储在camera_device_成员变量中。注意,我们使用相机 ID 字符串打开特定设备。camera_device_callbacks变量定义如下:

namespace {
void onDisconnected(
    [[maybe_unused]] void* context,
    [[maybe_unused]] ACameraDevice* device) {
  LOGI("Camera onDisconnected");
}
void onError([[maybe_unused]] void* context,
             [[maybe_unused]] ACameraDevice* device,
             int error) {
  LOGE("Camera error %d", error);
}
ACameraDevice_stateCallbacks camera_device_callbacks = {
    .context = nullptr,
    .onDisconnected = onDisconnected,
    .onError = onError,
};
}  // namespace

我们定义了带有指向函数引用的ACameraDevice_stateCallbacks结构对象,这些函数简单地报告相机是否已打开或关闭。在其他应用程序中,这些处理程序可以执行一些更有用的操作,但由于 API 要求,我们无法用空值初始化它们。

create_camera方法在ObjectDetection类的configure_resources方法中被调用。每次应用程序激活时都会调用此方法,其实现如下:

void ObjectDetector::configure_resources() {
  if (!is_session_allowed() || !android_app_ ||
      !android_app_->window) {
          LOGE("Can't configure output window!");
      return;
  }
  if (!camera_device_)
      create_camera();
  // configure output window size and format
  ...
  if (!image_reader_ && !session_output_) {
      create_image_reader();
      create_session();
  }
}

在开始时,我们检查了所有必需的资源:相机 ID、android_app对象,以及该对象是否指向应用程序窗口。然后,我们创建了一个相机管理器对象并打开了一个相机设备。使用相机管理器,我们获取了相机传感器的方向以配置应用程序窗口的适当宽度和高度。此外,使用图像捕获宽度和高度值,我们配置了窗口尺寸,如下所示:

ACameraMetadata *metadata_obj{nullptr};
ACameraManager_getCameraCharacteristics(camera_mgr_,
                                        camera_id_.c_str(),
                                        &metadata_obj);
ACameraMetadata_const_entry entry;
ACameraMetadata_getConstEntry(metadata_obj,
                              ACAMERA_SENSOR_ ORIENTATION,
                              &entry);
orientation_ = entry.data.i32[0];
bool is_horizontal = orientation_ == 0 || orientation_ == 270;
auto out_width = is_horizontal ? width_ : height_;
auto out_height = is_horizontal ? height_ : width_;
ANativeWindow_setBuffersGeometry(android_app_->window,
                                 out_width,
                                 out_height,
                                 WINDOW_FORMAT_RGBA_8888);

这里,我们使用了ACameraManager_getCameraCharacteristics函数来获取相机元数据特性对象。然后,我们使用ACameraMetadata_getConstEntry函数读取ACAMERA_SENSOR_ORIENTATION属性。之后,我们根据使用的方向,基于ANativeWindow_setBuffersGeometry函数选择适当的宽度和高度顺序,以设置应用程序输出窗口尺寸和渲染缓冲区格式。

我们设置的格式是竖屏模式下的32800像素高度和600像素宽度。这种方向处理非常简单,只需正确处理输出窗口缓冲区即可。之前,我们已禁用了应用中的横屏模式,因此我们将忽略相机传感器的方向在相机图像解码中的使用。

configure_resources方法结束时,我们创建了相机读取器对象并初始化了捕获管道。

图像捕获管道构建

之前,我们看到在捕获管道初始化之前,我们创建了图像读取器对象。这是在create_image_reader方法中完成的,如下所示:

void ObjectDetector::create_image_reader() {
  constexpr int32_t MAX_BUF_COUNT = 4;
  auto status = AImageReader_new(
      width_, height_, AIMAGE_FORMAT_YUV_420_888,
      MAX_BUF_COUNT, &image_reader_);
  ASSERT(image_reader_ && status == AMEDIA_OK,
         "Failed to create AImageReader");
}

我们使用AImageReader_new创建了一个具有特定宽度和高度、YUV 格式和四个图像缓冲区的AImageReader对象。我们使用的宽度和高度值与输出窗口尺寸配置中使用的相同。我们使用 YUV 格式,因为它是最多相机设备的原生图像格式。使用四个图像缓冲区是为了使图像捕获稍微独立于它们的处理。这意味着当我们在读取另一个缓冲区并处理它时,图像读取器将用相机数据填充一个图像缓冲区。

捕获会话初始化是一个复杂的过程,需要创建几个对象并将它们相互连接。create_session方法如下实现:

void ObjectDetector::create_session() {
  ANativeWindow* output_ native_window;
  AImageReader_getWindow(image_reader_,
                         &output_ native_window);
  ANativeWindow_acquire(output_native_window);
  ACaptureSessionOutputContainer_create(&output_container_);
  ACaptureSessionOutput_create(output_native_window,
                               &session_ output_);
  ACaptureSessionOutputContainer_add(output_container_,
                                     session_output_);
  ACameraOutputTarget_create(output_native_window,
                             &output_target_);
  ACameraDevice_createCaptureRequest(
      camera_ device_, TEMPLATE_PREVIEW, &capture_request_);
  ACaptureRequest_ addTarget(capture_request_,
                             output_target_);
  ACameraDevice_createCaptureSession(camera_device_,
                                     output_container_,
                                     &session_callbacks,
                                     &capture_session_);
  // Start capturing continuously
  ACameraCaptureSession_setRepeatingRequest(capture_session_,
                                            nullptr,
                                            1,
                                            &capture_request_,
                                            nullptr);
}

我们从图像读取器对象中获取并获取了一个原生窗口。窗口获取意味着我们获取了窗口的引用,系统不应删除它。这个图像读取器窗口将被用作捕获管道的输出,因此相机图像将被绘制到其中。

然后,我们创建了会话输出对象和会话输出的容器。会话可以有几个输出,它们应该放入一个容器中。每个会话输出都是一个具体表面或窗口输出的连接对象;在我们的情况下,它是图像读取器窗口。

在配置了会话输出后,我们创建了捕获请求对象,并确保其输出目标是图像读取窗口。我们为打开的相机设备配置了捕获请求,并设置了预览模式。之后,我们实例化了捕获会话对象,并将其指向打开的相机设备,该设备包含我们之前创建的输出容器。

最后,我们通过设置会话的重复请求来开始捕获。会话与捕获请求之间的关系如下:我们创建了一个配置了可能输出列表的捕获会话,捕获请求指定了实际将使用的表面。可能有多个捕获请求和多个输出。在我们的情况下,我们有一个单一的捕获请求和一个单一的输出,它将连续重复。因此,总的来说,我们将像视频流一样捕获摄像头的实时图片。以下图片显示了捕获会话中图像数据流的逻辑方案:

图 14.1 – 捕获会话中的逻辑数据流

图 14.1 – 捕获会话中的逻辑数据流

这不是实际的数据流方案,而是逻辑方案,显示了捕获会话对象是如何连接的。虚线表示请求路径,实线表示逻辑图像数据路径。

捕获图像和输出窗口缓冲区管理

当我们讨论主应用程序循环时,我们提到了draw_frame方法,该方法在处理命令后在此循环中被调用。此方法用于从图像读取对象中获取捕获的图像,然后检测其上的对象并在应用程序窗口中绘制检测结果。以下代码片段显示了draw_frame方法实现:

void ObjectDetector::draw_frame() {
  if (image_reader_ == nullptr)
      return;
  AImage *image = nullptr;
  auto status = AImageReader_acquireNextImage(image_reader_, &image);

  if (status != AMEDIA_OK) {
      return;
  }
ANativeWindow_acquire(android_app_->window);
ANativeWindow_Buffer buf;
if (ANativeWindow_lock(android_app_->window,
                       &buf,
                       nullptr) < 0) {
  AImage_delete(image);
  return;
}
    process_image(&buf, image);
    AImage_delete(image);
    ANativeWindow_unlockAndPost(android_app_->window);
    ANativeWindow_release(android_app_->window);
}

我们获取了图像读取对象接收到的下一张图像。记住我们初始化了它以拥有四个图像缓冲区。因此,我们在主循环中逐个从这些缓冲区获取图像,同时我们处理一张图像时,捕获会话会填充另一个已经处理过的图像。这是以循环方式完成的。拥有来自摄像头的图像,我们获取并锁定应用程序窗口,但如果锁定失败,我们删除当前图像引用,停止处理,并进入主循环的下一个迭代。否则,如果我们成功锁定应用程序窗口,我们处理当前图像,检测其上的对象,并将检测结果绘制到应用程序窗口中——这是在process_image方法中完成的。此方法接受AImageANativeWindow_Buffer对象。

当我们锁定应用程序窗口时,我们获得用于绘制的内部缓冲区的指针。在处理图像并绘制结果后,我们解锁应用程序窗口以使其缓冲区可供系统使用,释放对窗口的引用,并删除对图像对象的引用。因此,这种方法主要关于资源管理,而真正的图像处理是在process_image方法中完成的,我们将在下一小节中讨论。

捕获图像处理

process_image方法实现了以下任务:

  1. 将 Android YUV 图像数据转换为 OpenCV 矩阵。

  2. 将图像矩阵调度到 YOLO 对象检测器。

  3. 将检测结果绘制到 OpenCV 矩阵中。

  4. 将 OpenCV 结果矩阵复制到 RGB(红色、蓝色、绿色)窗口缓冲区。

让我们逐一查看这些任务的实现。process_image 方法的签名如下所示:

void ObjectDetector::process_image(
    ANativeWindow_Buffer* buf,
    AImage* image);

此方法接受用于结果绘制的应用程序窗口缓冲区对象和用于实际处理的图像对象。为了能够处理图像,我们必须将其转换为某种适当的数据结构格式;在我们的情况下,这就是 OpenCV 矩阵。我们首先检查图像格式属性,如下所示:

int32_t src_format = -1;
AImage_getFormat(image, &src_format);
ASSERT(AIMAGE_FORMAT_YUV_420_888 == src_format,
       "Unsupported image format for displaying");
int32_t num_src_planes = 0;
AImage_getNumberOfPlanes(image, &num_src_planes);
ASSERT(num_src_planes == 3,
      "Image for display has unsupported number of planes");
int32_t src_height;
AImage_getHeight(image, &src_height);
int32_t src_width;
AImage_getWidth(image, &src_width);

我们检查了图像格式为 YUV(亮度(Y)、蓝色亮度(U)和红色亮度(V)),并且图像有三个平面,因此我们可以继续其转换。然后,我们获得了图像尺寸,这些尺寸将在以后使用。之后,我们验证了从 YUV 平面数据中提取的输入数据,如下所示:

int32_t y_stride{0};
AImage_getPlaneRowStride(image, 0, &y_stride);
int32_t uv_stride1{0};
AImage_getPlaneRowStride(image, 1, &uv_stride1);
int32_t uv_stride2{0};
AImage_getPlaneRowStride(image, 1, &uv_stride2);
uint8_t *y_pixel{nullptr}, *uv_pixel1{nullptr}, *uv_pixel2{nullptr};
int32_t y_len{0}, uv_len1{0}, uv_len2{0};
AImage_getPlaneData(image, 0, &y_pixel, &y_len);
AImage_getPlaneData(image, 1, &uv_pixel1, &uv_len1);
AImage_getPlaneData(image, 2, &uv_pixel2, &uv_len2);

我们获得了步长、数据大小以及实际 YUV 平面数据的指针。在此格式中,图像数据分为三个组件:亮度(y),表示亮度,以及两个色度组件(uv),它们表示颜色信息。y 成分通常以全分辨率存储,而 uv 成分可能被子采样。这允许更有效地存储和传输视频数据。Android YUV 图像使用 uv 的半分辨率。步长将使我们能够正确访问平面缓冲区中的行数据;这些步长取决于图像分辨率和数据内存布局。

拥有 YUV 平面数据及其步长和长度后,我们将它们转换为 OpenCV 矩阵对象,如下所示:

cv::Size actual_size(src_width, src_height);
cv::Size half_size(src_width / 2, src_height / 2);
cv::Mat y(actual_size, CV_8UC1, y_pixel, y_stride);
cv::Mat uv1(half_size, CV_8UC2, uv_pixel1, uv_stride1);
cv::Mat uv2(half_size, CV_8UC2, uv_pixel2, uv_stride2);

我们创建了两个 cv::Size 对象来存储 Y 平面的原始图像大小以及 uv 平面的半大小。然后,我们使用这些大小、数据指针和步长为每个平面创建一个 OpenCV 矩阵。我们没有将实际数据复制到 OpenCV 矩阵对象中;它们将使用传递给初始化的数据指针。这种视图创建方法节省了内存和计算资源。y-平面矩阵具有 8 位单通道类型,而 uv 矩阵具有 8 位双通道类型。我们可以使用这些矩阵与 OpenCV 的 cvtColorTwoPlane 函数将它们转换为 RGBA 格式,如下所示:

cv::mat rgba_img_;
...
long addr_diff = uv2.data - uv1.data;
if (addr_diff > 0) {
  cvtColorTwoPlane(y, uv1, rgba_img_, cv::COLOR_YUV2RGBA_NV12);
} else {
  cvtColorTwoPlane(y, uv2, rgba_img_, cv::COLOR_YUV2RGBA_NV21);
}

我们使用地址差异来确定 uv 平面的顺序:正差异表示 NV12 格式,而负差异表示 NV21 格式。NV12NV21 是 YUV 格式的类型,它们在色度平面中 uv 成分的顺序上有所不同。在 NV12 中,u 成分先于 v 成分,而在 NV21 中则相反。这种平面顺序在内存消耗和图像处理性能中发挥作用,因此选择使用哪种格式取决于实际任务和项目。此外,格式可能取决于实际的相机设备,这就是我们添加此检测的原因。

cvtColorTwoPlane函数接受y平面和uv平面矩阵作为输入参数,并将 RGBA 图像矩阵输出到rgba_img_变量中。最后一个参数是告诉函数它应该执行什么实际转换的标志。现在,这个函数只能将 YUV 格式转换为 RGB 或 RGBA 格式。

正如我们之前所说的,我们的应用程序仅在纵向模式下工作,但为了使图像看起来正常,我们需要将其旋转如下:

cv::rotate(rgba_img_, rgba_img_, cv::ROTATE_90_CLOCKWISE);

即使我们的方向已经固定,Android 相机传感器返回的图像仍然是旋转的,所以我们使用了cv::rotate函数使其看起来是垂直的。

准备好 RGBA 图像后,我们将其传递给YOLO对象检测器,并获取检测结果。对于每个结果项,我们在已经用于检测的图像矩阵上绘制矩形和标签。这些步骤的实现方式如下:

auto results = yolo_->detect(rgba_img_);
for (auto& result : results) {
  int thickness = 2;
  rectangle(rgba_img_, result.rect.tl(), result.rect.br(),
            cv::Scalar(255, 0, 0, 255), thickness,
            cv::LINE_4);
  cv::putText(rgba_ img_, result.class_name,
              result.rect.tl(), cv::FONT_HERSHEY_DUPLEX,
              1.0, CV_RGB(0, 255, 0), 2);
}

我们调用了YOLO对象的detect方法,并获取了results容器。这个方法将在稍后讨论。然后,对于容器中的每个项,我们为检测到的对象绘制一个边界框和文本标签。我们使用了带有rgba_img_目标图像参数的 OpenCV rectangle函数。此外,文本也被渲染到rgba_img_对象中。检测结果是yolo.h头文件中定义的结构,如下所示:

struct YOLOResult {
  int class_index;
  std::string class_name;
  float score;
  cv::Rect rect;
};

因此,一个检测结果具有类别索引和名称属性、模型置信度分数以及图像坐标中的边界框。对于我们的结果可视化,我们只使用了矩形和类别名称属性。

process_image方法最后执行的任务是将生成的图像渲染到应用程序窗口缓冲区中。其实现方式如下:

cv::Mat buffer_mat(src_width,
                   src_height,
                   CV_8UC4,
                   buf->bits,
                   buf->stride * 4);
rgba_img_.copyTo(buffer_mat);

我们创建了 OpenCV 的buffer_mat矩阵来包装给定的窗口缓冲区。然后,我们简单地使用 OpenCV 的copyTo方法将带有渲染矩形和类别标签的 RGBA 图像放入buffer_mat对象中。buffer_mat是 OpenCV 对 Android 窗口缓冲区的视图。我们创建它是为了遵循在configure_resources方法中配置的窗口缓冲区格式,即WINDOW_FORMAT_RGBA_8888格式。因此,我们创建了一个 8 位 4 通道类型的 OpenCV 矩阵,并使用缓冲区步进信息来满足内存布局访问。这样的视图使我们能够编写更少的代码,并使用 OpenCV 例程进行内存管理。

我们讨论了我们的对象检测应用程序的主要外观,在接下来的小节中,我们将讨论 YOLO 模型推理的实现细节以及其结果如何解析到YOLOResult结构中。

YOLO 包装器初始化

YOLO类的公共 API 中只有构造函数和detect方法。我们已经看到YOLO对象是在ObjectDetector类的构造函数中初始化的,detect方法是在process_image方法中使用的。YOLO类的构造函数只接受资产管理器对象作为单个参数,其实现方式如下:

YOLO::YOLO(AAssetManager* asset_manager) {
  const std::string model_file_name = "yolov5s.torchscript";
  auto model_buf = read_asset(asset_manager,
                           model_file_name);
  model_ = torch::jit::_load_for_mobile(
      std::make_unique<ReadAdapter>(model_buf));
  const std::string classes_file_name = "classes.txt";
  auto classes_buf = read_asset( asset_manager,
                             classes_file_name);
  VectorStreamBuf<char> stream_buf(classes_buf);
  std::istream is(&stream_buf);
  load_classes(is);
}

记住,我们已经将yolov5s.torchscriptclasses.txt文件添加到我们项目的assets文件夹中。这些文件可以通过AAssetManager类对象在应用程序中访问;此对象是从android_main函数中的android_app对象中获取的。因此,在构造函数中,我们通过调用read_asset函数加载模型二进制文件和类列表文件。然后,使用torch::jit::_load_for_mobile函数使用模型二进制数据加载和初始化 PyTorch 脚本模块。

注意,脚本模型应该以针对移动设备优化的方式保存,并使用相应的函数加载。当编译 PyTorch for mobile 时,常规的torch::jit::load功能会自动禁用。让我们看看read_asset函数,该函数以std::vector<char>对象的形式从应用程序包中读取资源。以下代码展示了其实现:

std::vector<char> read_asset(AAssetManager* asset_manager,
                             const std::string& name) {
  std::vector<char> buf;
  AAsset* asset = AAssetManager_open(
      asset_manager, name.c_str(), AASSET_MODE_UNKNOWN);
  if (asset != nullptr) {
    LOGI("Open asset %s OK", name.c_str());
    off_t buf_size = AAsset_getLength(asset);
    buf.resize(buf_size + 1, 0);
    auto num_read =AAsset_read(
                      asset, buf.data(), buf_size);
    LOGI("Read asset %s OK", name.c_str());
    if (num_read == 0)
      buf.clear();
    AAsset_close(asset);
    LOGI("Close asset %s OK", name.c_str());
  }
  return buf;
}

我们使用了四个 Android 框架函数来从应用程序包中读取资产。AAssetManager_open函数打开了资产,并返回指向AAsset对象的非空指针。此函数假设资产的路径是文件路径格式,并且此路径的根是assets文件夹。在我们打开资产后,我们使用AAsset_getLength函数获取文件大小,并使用std::vector::resize方法为std::vector<char>分配内存。然后,我们使用AAsset_read()函数将整个文件读取到buf对象中。

此函数执行以下操作:

  • 它获取要读取的资产对象的指针

  • 需要使用内存缓冲区的void*指针来读取

  • 它测量要读取的字节大小

因此,正如你所看到的,资源 API 基本上与标准 C 库 API 的文件操作相同。当我们完成与资源对象的协作后,我们使用AAsset_close函数通知系统我们不再需要访问此资源。如果你的资源以.zip存档格式存储,你应该检查AAsset_read函数返回的字节数,因为 Android 框架是分块读取存档的。

你可能会注意到我们没有直接将字符向量传递给torch::jit::_load_for_mobile函数。此函数不与标准 C++流和类型一起工作;相反,它接受一个指向caffe2::serialize::ReadAdapterInterface类对象的指针。以下代码展示了如何具体实现caffe2::serialize::ReadAdapterInterface类,该类封装了std::vector<char>对象:

class ReadAdapter
    : public caffe2::serialize::ReadAdapterInterface {
 public:
  explicit ReadAdapter(const std::vector<char>& buf)
      : buf_(&buf) {}
  size_t size() const override { return buf_->size(); }
  size_t read(uint64_t pos,
              void* buf,
              size_t n,
              const char* what) const override {
    std::copy_n(buf_->begin() + pos, n,
                reinterpret_cast<char*>(buf));
    return n;
  }
 private:
  const std::vector<char>* buf_;
};

ReaderAdapter 类重写了 caffe2::serialize::ReadAdapterInterface 基类中的两个方法,sizeread。它们的实现相当明显:size 方法返回底层向量对象的大小,而 read 方法使用标准算法函数 std::copy_nn 字节(字符)从向量复制到目标缓冲区。

为了加载类信息,我们使用了 VectorStreamBuf 适配器类将 std::vector<char> 转换为 std::istream 类型对象。这样做是因为 YOLO::load_classes 方法需要一个 std::istream 类型的对象。VectorStreamBuf 的实现如下:

template<typename CharT, typename TraitsT = std::char_traits<CharT> >
struct VectorStreamBuf : public std::basic_streambuf<CharT, TraitsT> {
  explicit VectorStreamBuf(std::vector<CharT>& vec) {
    this->setg(vec.data(), vec.data(),
               vec.data() + vec.size());
  }
}

我们从 std::basic_streambuf 类继承,并在构造函数中,使用输入向量的字符值初始化 streambuf 内部数据。然后,我们使用此适配器类的对象作为常规 C++ 输入流。您可以在以下代码片段中看到它,这是 load_classes 方法实现的一部分:

void YOLO::load_classes(std::istream& stream) {
  LOGI("Init classes start OK");
  classes_.clear();
  if (stream) {
    std::string line;
    std::string id;
    std::string label;
    size_t idx = 0;
    while (std::getline(stream, line)) {
      auto pos = line.find_first_of(':');
      id = line.substr(0, pos);
      label = line.substr(pos + 1);
      classes_.insert({idx, label});
      ++idx;
    }
  }
  LOGI("Init classes finish OK");
}

classes.txt 中的行格式如下:

[ID] space character [class name]

因此,我们逐行读取此文件,并在第一个空格字符的位置拆分每一行。每一行的第一部分是类标识符,而第二部分是类名。为了将模型的评估结果与正确的类名匹配,我们创建了一个字典(映射)对象,其键是 id 值,值是 label(例如,类名)。

YOLO 检测推理

YOLO 类的 detect 方法是我们进行实际对象检测的地方。此方法以表示 RGB 图像的 OpenCV 矩阵对象为参数,其实现如下:

std::vector<YOLOResult> YOLO::detect(const cv::Mat& image) {
  constexpr int input_width = 640;
  constexpr int input_height = 640;
  cv::cvtColor(image, rgb_img_, cv::COLOR_RGBA2RGB);
  cv::resize(rgb_img_, rgb_img_,
             cv::Size(input_width, input_height));
  auto img_scale_x =
      static_cast<float>(image.cols) / input_width;
  auto img_scale_y =
      static_cast<float>(image.rows) / input_height;
  auto input_tensor = mat2tensor(rgb_img_);
  std::vector<torch::jit::IValue> inputs;
  inputs.emplace_back(input_tensor);
  auto output = model_.forward(inputs).toTuple() - >
                elements()[0].toTensor().squeeze(0);
  output2results(output, img_scale_x, img_scale_y);
  return non_max_suppression();
}

我们定义了代表模型输入宽度和高度的常量;它是 640 x 640,因为 YOLO 模型是在这种大小的图像上训练的。使用这些常量,我们调整了输入图像的大小。此外,我们移除了 alpha 通道并制作了 RGB 图像。我们计算了图像尺寸的缩放因子,因为它们将被用来将检测到的对象边界重新缩放到原始图像大小。在调整了图像大小后,我们使用 mat2tensor 函数将 OpenCV 矩阵转换为 PyTorch Tensor 对象,我们将在稍后讨论其实现。我们将 PyTorch Tensor 对象添加到 torch::jit::IValue 值的容器中时,对象类型转换是自动完成的。在这个 inputs 容器中只有一个元素,因为 YOLO 模型需要一个 RGB 图像输入。

然后,我们使用 YOLO model_ 对象的 forward 函数进行推理。PyTorch API 脚本模块返回 torch::jit::Tuple 类型。因此,我们显式地将返回的 torch::jit::IValue 对象转换为元组,并取第一个元素。该元素被转换为 PyTorch Tensor 对象,并使用 squeeze 方法从其中移除批维度。因此,我们得到了大小为 25200 x 85torch::Tensor 类型 output 对象。其中,25200 是检测到的对象数量,我们将应用非极大值抑制算法以获得最终减少的输出。85 表示 80 个类别分数、4 个边界框位置(x, y, 宽度,高度)和 1 个置信度分数。结果张量在 output2results 方法中解析为 YOLOResult 结构。正如我们所说的,我们使用了 non_max_suppression 方法来选择最佳检测结果。

让我们看看我们用于推理的所有中间函数的详细信息。

将 OpenCV 矩阵转换为 torch::Tensor

mat2tensor 函数将 OpenCV mat 对象转换为 torch::Tensor 对象,其实现如下:

torch::Tensor mat2tensor(const cv::Mat& image) {
  ASSERT(image.channels() == 3, "Invalid image format");
  torch::Tensor tensor_image = torch::from_blob(
      image.data,
      {1, image.rows, image.cols, image.channels()},
      at::kByte);
  tensor_image = tensor_image.to(at::kFloat) / 255.;
  tensor_image = torch::transpose(tensor_image, 1, 2);
  tensor_image = torch::transpose(tensor_image, 1, 3);
  return tensor_image;
}

我们使用 torch::from_blob 函数从原始数据创建 torch Tensor 对象。数据指针是我们从 OpenCV 对象的 data 属性中获取的。我们使用的形状 [HEIGHT, WIDTH, CHANNELS] 遵循 OpenCV 内存布局,其中最后一个维度是通道号维度。然后,我们将张量转换为浮点数并归一化到 [0,1] 区间。PyTorch 和 YOLO 模型使用不同的形状布局 [CHANNELS, HEIGHT, WIDTH]。因此,我们适当地转置张量通道。

处理模型输出张量

我们使用的下一个函数是 output2results,它将输出 Tensor 对象转换为 YOLOResult 结构的向量。其实现如下:

void YOLO::output2results(const torch::Tensor &output,
                          float img_scale_x,
                          float img_scale_y) {
  auto outputs = output.accessor<float, 2>();
  auto output_row = output.size(0);
  auto output_column = output.size(1);
  results_.clear();
  for (int64_t i = 0; i < output_row; i++) {
    auto score = outputs[i][4];
    if (score > threshold) {
      // read the bounding box
      // calculate the class id
      results_.push_back(YOLOResult{
          .class_index = cls,
          .class_name = classes_[cls],
          .score = score,
          .rect = cv::Rect(left, top, bw, bh),
      });
    }
  }

在开始时,我们使用 torch Tensor 对象的 accessor<float, 2> 方法来获取一个对张量非常有用的 accessor。这个 accessor 允许我们使用方括号运算符访问多维张量中的元素。数字 2 表示张量是 2D 的。然后,我们对张量行进行循环,因为每一行对应一个单独的检测结果。在循环内部,我们执行以下步骤:

  1. 我们从行索引 4 的元素中读取置信度分数。

  2. 如果置信度分数大于阈值,我们继续处理结果行。

  3. 我们读取 0, 1, 2, 3 元素,这些是边界矩形的 [x, y, 宽度, 高度] 坐标。

  4. 使用先前计算的比例因子,我们将这些坐标转换为 [左, 上, 宽度, 高度] 格式。

  5. 我们读取元素 5-84,这些是类别概率,并选择最大值的类别。

  6. 我们使用计算出的值创建 YOLOResult 结构并将其插入到 results_ 容器中。

边界框计算如下:

float cx = outputs[i][0];
float cy = outputs[i][1];
float w = outputs[i][2];
float h = outputs[i][3];
int left = static_cast<int>(img_scale_x * (cx - w / 2));
int top = static_cast<int>(img_scale_y * (cy - h / 2));
int bw = static_cast<int>(img_scale_x * w);
int bh = static_cast<int>(img_scale_y * h);

YOLO 模型返回矩形的中心 X 和 Y 坐标,因此我们将它们转换为图像(屏幕)坐标系:到左上角点。

类别 ID 选择实现如下:

float max = outputs[i][5];
int cls = 0;
for (int64_t j = 0; j < output_column - 5; j++) {
if (outputs[i][5 + j] > max) {
  max = outputs[i][5 + j];
  cls = static_cast<int>(j);
}
}

我们使用遍历代表 79 个类别概率的最后元素来选择最大值的索引。这个索引被用作类别 ID。

NMS 和 IoU

非极大值抑制NMS)和 交并比IoU)是 YOLO 中用于精炼和过滤输出预测以获得最佳结果的两个关键算法。

非极大值抑制(NMS)用于抑制或消除相互重叠的重复检测。它通过比较网络预测的边界框并移除与其他边界框重叠度高的那些来实现。例如,如果有两个边界框被预测为同一对象,NMS 将只保留置信度评分最高的那个,其余的将被丢弃。以下图片展示了 NMS 的工作原理:

图 14.2 – NMS

图 14.2 – NMS

IoU 是另一种与 NMS 结合使用的算法,用于测量边界框之间的重叠。IoU 计算两个框之间交集面积与并集面积的比率。范围在 01 之间,其中 0 表示没有重叠,1 表示完全重叠。以下图片展示了 IoU 的工作原理:

图 14.3 – IoU

图 14.3 – IoU

我们在 non_max_suppression 方法中实现了 NMS,如下所示:

std::vector<YOLOResult> YOLO::non_max_suppression() {
  // do an sort on the confidence scores, from high to low.
    std::sort(results_.begin(), results_.end(), [](
          auto &r1, auto &r2) {
        return r1.score > r2.score;
    });
    std::vector<YOLOResult> selected;
    std::vector<bool> active(results_.size(), true);
    int num_active = static_cast<int>(active.size());
    bool done = false;
    for (size_t i = 0; i < results_.size() && !done; i++) {
  if (active[i]) {
    const auto& box_a = results_[i];
    selected.push_back(box_a);
    if (selected.size() >= nms_limit)
      break;
    for (size_t j = i + 1; j < results_.size(); j++) {
      if (active[j]) {
        const auto& box_b = results_[j];
        if (IOU(box_a.rect, box_b.rect) > threshold) {
          active[j] = false;
          num_active -= 1;
          if (num_active <= 0) {
            done = true;
            break;
          }
        }
      }
    }
  }
}
  return selected;
}

首先,我们按置信度分数降序对所有检测结果进行排序。我们将所有结果标记为活动状态。如果一个检测结果是活动的,那么我们可以将其与另一个结果进行比较,否则,该结果已被抑制。然后,每个活动检测结果依次与后续的活动结果进行比较;记住容器是排序的。比较是通过计算边界框的 IoU 值并与阈值进行比较来完成的。如果 IoU 值大于阈值,我们将置信度值较低的结果标记为非活动状态;我们抑制了它。因此,我们定义了嵌套比较循环。在外层循环中,我们也忽略了被抑制的结果。此外,这个嵌套循环还有检查允许的最大结果数量的检查;请参阅 nms_limit 值的使用。

两个边界框的 IoU 算法在 IOU 函数中实现如下:

float IOU(const cv::Rect& a, const cv::Rect& b) {
  if (a.empty() <= 0.0)
    return 0.0f;
  if (b.empty() <= 0.0)
    return 0.0f;
  auto min_x = std::max(a.x, b.x);
  auto min_y = std::max(a.y, b.y);
  auto max_x = std::min(a.x + a.width, b.x + b.width);
  auto max_y = std::min(a.y + a.height, b.y + b.height);
  auto area = std::max(max_y - min_y, 0) *
              std::max(max_x - min_x, 0);
  return static_cast<float>(area) /
         static_cast<float>(a.area() + b.area() - area);
}

首先,我们检查边界框是否为空;如果一个框为空,则 IoU 值为零。然后,我们计算交集面积。这是通过找到 X 和 Y 的最小和最大值,考虑两个边界框,然后取这些值之间的差异的乘积来完成的。并集面积是通过将两个边界框的面积相加减去交集面积来计算的。您可以在返回语句中看到这个计算,我们在其中计算了面积比率。

NMS 和 IoU 共同帮助通过丢弃假阳性并确保只有相关的检测被包含在最终输出中,从而提高了 YOLO 的准确性和精确度。

在本节中,我们探讨了 Android 系统对象检测应用的实现。我们学习了如何将预训练模型从 Python 程序导出为 PyTorch 脚本文件。然后,我们深入开发了一个使用 Android Studio IDE 和 PyTorch C++库移动版本的移动应用程序。在下面的图中,你可以看到一个应用程序输出窗口的示例:

图 14.4 – 对象检测应用输出

图 14.4 – 对象检测应用输出

在这张图中,你可以看到我们的应用成功地在智能手机摄像头前检测到了一台笔记本电脑和一只鼠标。每一个检测结果都用边界框和相应的标签进行了标记。

摘要

在本章中,我们讨论了如何部署机器学习模型,特别是神经网络,到移动平台。我们探讨了在这些平台上,我们通常需要为我们项目使用的机器学习框架的定制构建。移动平台使用不同的 CPU,有时,它们有专门的神经网络加速器设备,因此你需要根据这些架构编译你的应用程序和机器学习框架。这些架构与开发环境不同,你通常用于两个不同的目的。第一种情况是使用配备 GPU 的强大机器配置来加速机器学习训练过程,因此你需要构建你的应用程序时考虑到一个或多个 GPU 的使用。另一种情况是仅使用设备进行推理。在这种情况下,你通常根本不需要 GPU,因为现代 CPU 在很多情况下可以满足你的性能要求。

在本章中,我们为 Android 平台开发了一个对象检测应用。我们学习了如何通过 JNI 将 Kotlin 模块与原生 C++库连接起来。然后,我们探讨了如何使用 NDK 构建 Android 平台的 PyTorch C++库,并看到了使用移动版本的限制。

这本书的最后一章;我希望你喜欢这本书,并觉得它在你掌握使用 C++进行机器学习的过程中有所帮助。我希望到现在为止,你已经对如何利用 C++的力量来构建稳健和高效的机器学习模型有了坚实的理解。在整个书中,我旨在提供复杂概念的清晰解释、实用示例和逐步指南,帮助你开始使用 C++进行机器学习。我还包括了一些提示和最佳实践,帮助你避免常见的陷阱并优化你的模型以获得更好的性能。我想提醒你,在使用 C++进行机器学习时,可能性是无限的。

不论你是初学者还是有经验的开发者,总有新的东西可以学习和探索。考虑到这一点,我鼓励你继续挑战自己的极限,尝试不同的方法和技巧。机器学习的世界不断在发展,通过跟上最新的趋势和发展,你可以保持领先,构建能够解决复杂问题的尖端模型。

再次感谢您选择我的书籍,并抽出时间学习如何使用 C++ 进行机器学习。我希望您能觉得这是一份有价值的资源,并帮助您在成为熟练且成功的机器学习开发者之路上取得进步。

进一步阅读