Tutorial_Cpp.md 11 KB
Newer Older
1
2
# Bert

liucong's avatar
liucong committed
3
本示例主要通过Bert模型说明如何使用MIGraphX C++ API进行自然语言处理模型的推理,包括数据准备、预处理、模型推理以及数据后处理。
4
5
6

## 模型简介

liucong's avatar
liucong committed
7
自然语言处理(Natural Language Processing,NLP )是能够实现人与计算机之间用自然语言进行有效沟通的理论和方法,是计算机科学领域与人工智能领域中的一个重要方向。本次采用经典的Bert模型完成问题回答任务,模型和分词文件下载链接:https://pan.baidu.com/s/1yc30IzM4ocOpTpfFuUMR0w, 提取码:8f1a, 下载bertsquad-10.onnx文件和uncased_L-12_H-768_A-12分词文件保存在Resource/文件夹下。整体模型结构如下图所示,也可以通过netron工具:https://netron.app/ 查看Bert模型结构。
8

liucong's avatar
liucong committed
9
<img src="./Images/Bert_01.png" style="zoom:100%;" align=middle>
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

问题回答任务是指输入一段上下文文本的描述和一个问题,模型从给定的文本中预测出答案。例如:

```
1.描述:My name is Li Ming
2.问题:What's your name?
3.答案:Li Ming
```

## 数据准备

在自然语言处理领域中,首先需要准备文本数据,如下所示,通常需要提供问题(question)和上下文文本(context),自己可以根据需求准备相应的问题和上下文文本作为输入数据,进行模型推理。

```json
{
  "context": "ROCm is the first open-source exascale-class platform for accelerated computing that’s also programming-language independent. It brings a philosophy of choice, minimalism and modular software development to GPU computing. You are free to choose or even develop tools and a language run time for your application. ROCm is built for scale, it supports multi-GPU computing and has a rich system run time with the critical features that large-scale application, compiler and language-run-time development requires. Since the ROCm ecosystem is comprised of open technologies: frameworks (Tensorflow / PyTorch), libraries (MIOpen / Blas / RCCL), programming model (HIP), inter-connect (OCD) and up streamed Linux® Kernel support – the platform is continually optimized for performance and extensibility.",
  "question": "What is ROCm?"
}
```

## 预处理

提供的问题和上下文文本并不能直接输入到模型中执行推理,需要对数据做如下预处理:

1.滑动窗口操作,当问题和上下文文本的字符超过256时,执行该操作,否则不执行。

2.数据拼接,将原始问题和上下文文本拼接成一个序列,作为模型的输入数据。

### 滑动窗口操作

对于问题回答型任务,关键的是如何构建输入数据。通过对上下文文本和问题的长度做判断,如果上下文文本加问题不超过256个字符时,直接进行后续的数据拼接操作,否则先进行滑动窗口操作构建输入序列,再进行后续的数据拼接。

如下图所示,为滑动窗口的具体操作:

liucong's avatar
liucong committed
44
<img src="./Images/Bert_03.png" style="zoom:80%;" align=middle>
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97

从图中可以看出,通过指定窗口大小为256,进行滑动窗口处理可以将上下文文本分成多个子文本,用于后续的数据拼接。

具体滑动窗口的过程由如下代码实现:

```c++
ErrorCode Bert::Preprocessing(...)
{
    ...

    // 当上下文文本加问题文本的长度大于规定的最大长度,采用滑动窗口操作
    if(tokens_text.size() + tokens_question.size() > max_seq_length - 5)
    {
        int windows_len = max_seq_length - 5 -tokens_question.size();
        std::vector<std::string> tokens_text_window(windows_len);
        std::vector<std::vector<std::string>> tokens_text_windows;
        
        // 规定起始位置,通过滑动窗口操作将子文本存储在tokens_text_window中
        int start_offset = 0;
        int position = 0;
        int n;
        while (start_offset < tokens_text.size())
        {
            n = 0;
            if(start_offset + windows_len > tokens_text.size())
            {
                for(int i = start_offset; i < tokens_text.size(); ++i)
                {
                    tokens_text_window[n] = tokens_text[i];
                    ++n;
                }
            }
            else
            {
                for(int i = start_offset; i < start_offset + windows_len; ++i)
                {
                    tokens_text_window[n] = tokens_text[i];
                    ++n;
                }
            }
            tokens_text_windows.push_back(tokens_text_window);
            start_offset += 256;   
            ++position;
        }
    }
        
   ...
}
```

### 数据拼接
当获得指定的问题和上下文文本时,对问题和上下文文本进行拼接操作,具体过程如下图所示:

liucong's avatar
liucong committed
98
<img src="./Images/Bert_02.png" style="zoom:80%;" align=middle>
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194

从图中可以看出,是将问题和上下文文本拼接成一个序列,问题和上下文文本用[SEP]符号隔开,完成数据拼接后再输入到模型中进行特征提取。其中,“[CLS]”是一个分类标志,表示后面的内容属于问题文本,“[SEP]”字符是一个分割标志,用来将问题和上下文文本分开。

具体过程由如下代码实现:

```c++
ErrorCode Bert::Preprocessing(...)
{
    ...

        for(int i=0; i < position; ++i)
        {
            // 将问题和上下文文本进行拼接处理
            input_id[0] = tokenizer.convert_token_to_id("[CLS]"); 
            segment_id[0] = 0; 

            input_id[1] = tokenizer.convert_token_to_id("[CLS]");
            segment_id[1] = 0;

            for (int j = 0; j < tokens_question.size(); ++j)
            {
                input_id[j + 2] = tokenizer.convert_token_to_id(tokens_question[j]);
                segment_id[j + 2] = 0;
            }

            input_id[tokens_question.size() + 2] = tokenizer.convert_token_to_id("[SEP]");
            segment_id[tokens_question.size() + 2] = 0;

            input_id[tokens_question.size() + 3] = tokenizer.convert_token_to_id("[SEP]");
            segment_id[tokens_question.size() + 3] = 0;
        
            for (int j = 0; j < tokens_question.size(); ++j)
            {
                input_id[j + tokens_text_windows[i].size() + 4] = tokenizer.convert_token_to_id(tokens_text_windows[i][j]);
                segment_id[j + tokens_text_windows[i].size() + 4] = 1; 
            }
            input_id[tokens_question.size() + tokens_text_windows[i].size() + 4] = tokenizer.convert_token_to_id("[SEP]");
            segment_id[tokens_question.size() + tokens_text_windows[i].size() + 4] = 1;

            int len = tokens_text_windows[i].size() + tokens_question.size() + 5;

            // 掩码为1的表示为真实标记,0表示为填充标记。
            std::fill(input_mask.begin(), input_mask.begin() + len, 1);
            std::fill(input_mask.begin() + len, input_mask.begin() + max_seq_length, 0);
            std::fill(input_id.begin() + len, input_id.begin() + max_seq_length, 0);
            std::fill(segment_id.begin() + len, segment_id.begin() + max_seq_length, 0);
        }
    
    ...
}
```

在自然语言处理领域中,不能对文本数据直接处理,需要对文本进行编码后再输入到模型中。因此,Bert模型中的输入数据主要由input_id、segment_id以及input_mask组成,其中input_id主要存储了对文本进行编码后的数值型数据,segment_id主要存储了用于区问题和上下文文本的信息(问题标记为0,上下文文本标记为1),input_mask主要存储了掩码信息(序列长度固定为256,当文本长度不足256时,对应的文本标记为1,无文本的标记为0),标记模型需要关注的地方。

## 推理

完成数据预处理后,就可以执行推理,得到推理结果。

```c++
ErrorCode Bert::Inference(...)
{
    ...
    
    for(int i=0;i<input_ids.size();++i)
    {
        // 输入数据
        inputData[inputName1]=migraphx::argument{inputShape1, (long unsigned int*)position_id[i]};
        inputData[inputName2]=migraphx::argument{inputShape2, (long unsigned int*)segment_id[i]};
        inputData[inputName3]=migraphx::argument{inputShape3, (long unsigned int*)input_mask[i]};
        inputData[inputName4]=migraphx::argument{inputShape4, (long unsigned int*)input_id[i]};

        // 推理
        results = net.eval(inputData);

        // 获取输出节点的属性
        start_prediction = results[1];                        // 答案的开始位置
        start_data = (float *)start_prediction.data();        // 开始位置的数据指针
        end_prediction = results[0];                          // 答案的结束位置
        end_data = (float *)end_prediction.data();            // 结束位置的数据指针

        // 将推理结果保存在vector容器中
        for(int i=0;i<256;++i)
        {
            start_position.push_back(start_data[i]);         
            end_position.push_back(end_data[i]);
        }
    }

    return SUCCESS;
}
```

1.通过数据预处理操作,得到三组输入参数input_id、segment_id以及input_mask,分别表示了文本信息、Segment信息(区分问题和上下文文本)、掩码信息。另外,将position_id默认设置为1。由于问题加上下文文本可能超过256字符,可能出现多个子序列,所以采用for循环操作,依次将子序列的四组参数输入到migraphx::argument{}函数中保存输入数据,并执行net.eval()函数进行模型推理,得到推理结果。

2.模型的推理结果是对输入序列中的每个词预测开始位置和结束位置的概率值,因此,分别采用start_position和end_position保存开始位置的概率值和结束位置的概率值,用于后续的数据后处理操作。

liucong's avatar
liucong committed
195
196
197
198
199
200
201
202
203
204
205
206
另外,如果想要指定输出节点,可以在eval()方法中通过提供outputNames参数来实现:

```c++
...
// 推理
std::vector<std::string> outputNames = {"unstack:0","unique_ids:0","unstack:1"};
std::vector<migraphx::argument> results = net.eval(inputData, outputNames);
...
```

如果没有指定outputName参数,则默认输出所有输出节点,此时输出节点的顺序与ONNX中输出节点顺序保持一致,可以通过netron查看ONNX文件的输出节点的顺序。

207
208
209
210
## 数据后处理

获得模型的推理结果后,并不能直接作为问题回答任务的结果显示,如下图所示,还需要进一步数据处理,得到最终的预测结果。

liucong's avatar
liucong committed
211
<img src="./Images/Bert_04.png" style="zoom:80%;" align=middle>
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229

从图中可以看出,数据后处理主要包含如下操作:

1.获取预测输出,根据模型推理结果,取前K个概率值最大的开始位置和结束位置。

2.筛选与过滤,根据过滤规则筛选开始位置和结束位置。

3.排序并输出结果,对开始位置加结束位置的概率值和进行排序,取概率值最大的组合作为最终的预测结果。

过滤规则:

1.开始位置和结束位置不能大于输入序列的长度。

2.开始位置和结束位置必须位于上下文文本中。

3.开始位置必须位于结束位置前。

本示例数据后处理代码,详见Src/NLP/Bert/Bert.cpp脚本中的Postprocessing()函数。