我在之前的文章中使用HuggingFace的Transformers库训练了一个可以根据上文生成下文的黑暗之魂物品描述的模型。现在,我将尝试将其部署到dot NET项目中。
完整的项目可以参考:GitHub地址
exe下载:链接:https://pan.baidu.com/s/1Zl8Uj7J8ZYVj3lMtNpndAQ?pwd=q7tl
提取码:q7tl
零、准备ONNX模型
首先,将我们的模型打包成ONNX模型。可以使用python中的optimum包。
pip install optimum[exporters]
使用以下命令将HuggingFace模型转换为ONNX模型。
optimum-cli export onnx --model ./bigscience-bloomz-560m_32bit_False_3 DS_Desc_ONNX_Model/ --task text-generation
这个命令中的–model参数可以是自己本地训练好的模型路径也可以是HuggingFace上模型的路径(会自动下载到本地,如果不能访问的话最好修改一下HuggingFace包中的路径到HF-mirror上);之后的参数是你想要导出的模型路径。最后的一个task函数最好指明一下,我的模型在没有指明的情况下导出了需要输入一万多个参数的ONNX模型。
一、定义Tokenizer
尽管可以使用optimum将模型存为ONNX模型放入dot NET项目中,但是Tokenizer无法转化为ONNX,我们必须手动在项目中定义使用的Tokenizer。
我们使用的Bloom模型,分词策略为BPE,可以在ML.NET的官方库中使用对应的方法进行Tokenizer定义。而对于WordPiece策略,我只找到一个相关库:FastBertTokenizer
而对于BPE策略的定义方法,可以参照此处的代码进行实现:BPE Tokenizer导出
在定义之前,我们首先需要将模型的词典表以及词典对应InputId的信息导出:
from transformers import AutoTokenizer,AutoModelForCausalLM
import torch
import json
model = AutoModelForCausalLM.from_pretrained("./bigscience-bloomz-560m_32bit_False_3")
tokenizer = AutoTokenizer.from_pretrained("bigscience/bloomz-560m")
with open("vocab.json", "w", encoding="utf-8") as f:
json.dump(data["model"]["vocab"],f)
with open("merges.txt", "w", encoding="utf-8") as f:
f.write("\n".join(data["model"]["merges"]))
其中的vocab.json以及merges.txt就是我们所需要的词典文件。
现在让我们在dot NET项目里进行Tokenizer定义:
dotnet add package Microsoft.ML.Tokenizers
public static Bpe tokenizer = new Bpe("./vocab.json", "./merges.txt");
public static Dictionary<int, char> hf_encoder = new Dictionary<int, char>();
public static Dictionary<char, int> hf_decoder = new Dictionary<char, int>();
//此处的代码我没看懂,应该是将字符转为Unicode做字典?
for (int c = '!'; c <= '~'; c++) hf_encoder.Add(c, (char)c);
for (int c = '¡'; c <= '¬'; c++) hf_encoder.Add(c, (char)c);
for (int c = '®'; c <= 'ÿ'; c++) hf_encoder.Add(c, (char)c);
int n = 0;
for (int c = 0; c < 256; c++)
{
if (!hf_encoder.ContainsKey(c))
hf_encoder.Add(c, (char)(256 + n++));
}
hf_decoder = hf_encoder.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
private static IReadOnlyList<Token> Encoding_input(string s)
{
s = new string(Encoding.UTF8.GetBytes(s).Select(b => hf_encoder[b]).ToArray());
return tokenizer.Tokenize(s);
}
private static string Decoding_output(long[] res_token)
{
string decoded = Bpe.Decoder.Decode(res_token.Select(id => tokenizer.IdToToken((int)id)!));
decoded = Encoding.UTF8.GetString(decoded.Select(c => (byte)hf_decoder[c]).ToArray());
return decoded;
}
二、定义模型
现在让我们将输出的ONNX模型导入到dot NET项目中。
首先,让我们在dot Net中引入需要使用的包:
dotnet add package Microsoft.ML
dotnet add package Microsoft.ML.OnnxRuntime
dotnet add package Microsoft.ML.OnnxTransformer
现在使用netron查看ONNX的模型输入与输出,并定义在模型中。
在右侧可以只看模型的输入与输出的数据类型、变量名。
public class OnnxInput
{
[VectorType]
[ColumnName("input_ids")]
public long[] InputIds { get; set; }
[VectorType]
[ColumnName("attention_mask")]
public long[] AttentionMask { get; set; }
}
public class OnnxOutput
{
[VectorType]
[ColumnName("logits")]
public float[] logits { get; set; }
}
之后,根据输入域输出,定义模型的预测器。
public static MLContext mlContext = new MLContext();
public static PredictionEngine<OnnxInput, OnnxOutput>onnxPredictionEngine = null;
static ITransformer GetPredictionPipeline(MLContext mlContext)
{
var inputColumns = new string[]{
"input_ids", "attention_mask"
};
var outputColumns = new string[] { "logits" };
var onnxPredictionPipeline =mlContext.Transforms.ApplyOnnxModel(
outputColumnNames:outputColumns,
inputColumnNames:inputColumns,
modelFile: "./DS_Desc_ONNX_Model/model.onnx"
);
var emptyDv = mlContext.Data.LoadFromEnumerable(new OnnxInput[] { });
return onnxPredictionPipeline.Fit(emptyDv);
}
public static void Initial_Model()
{
var onnxPredictionPipeline = GetPredictionPipeline(mlContext);
onnxPredictionEngine = mlContext.Model.CreatePredictionEngine<OnnxInput, OnnxOutput>(onnxPredictionPipeline);
}
三、模型生成方法定义
注意此处的ONNX模型实际上是将当原模型做了一个多分类任务,最终会输出一个250880长的向量,向量中数字最大所对应的下标,就是对应的TokenId。Transformers中的Generate方法并没有被封装在ONNX中,想要实现还需要自己定义。
此处仅定义贪婪算法,其他如BeamSearch算法定义暂时没有定义。
贪婪算法,顾名思义就是每一次将句子输入模型中,取最大可能得下一个Token,再将其加入到模型输入中,重复输入模型直到预见EOS Token或Token数量达到预设的最大值。
优点:简单快捷
缺点:虽然是局部最优,但是未必全局最优。
static long GenNextToken(PredictionEngine<OnnxInput,OnnxOutput> onnxPredictionEngine, List<long> InputIds,List<long> attention_masks){
int token_size = 250880;
var ipt = new OnnxInput
{
InputIds = InputIds.ToArray(),
AttentionMask = attention_masks.ToArray()
};
float[] res_all = onnxPredictionEngine.Predict(ipt).logits;
float[] res = res_all.Skip(res_all.Length - token_size).ToArray();
int res_token = -1;
float maxValueInGroup = float.MinValue;
for (int j = 0; j < res.Length; j++)
{
if (res[j] > maxValueInGroup)
{
maxValueInGroup = res[j];
res_token = j;
}
}
return res_token;
}
四、整个模型生成文本的步骤
现在,让我们将整个模型生成文本的步骤打包。
public static string Generate_result(string s)
{
var tokenizerEncodedResult = Encoding_input(s);
List<long> InputIds = new List<long>(tokenizerEncodedResult.Select(x => (long)x.Id));
List<long> attention_masks = new List<long>(Enumerable.Repeat(1L, tokenizerEncodedResult.Count));
long res_token=-1;
int EOS_TOKEN = 2;
int MAX_LENGTH = 256;
while (res_token != EOS_TOKEN && InputIds.Count<MAX_LENGTH){
res_token = GenNextToken(onnxPredictionEngine, InputIds, attention_masks);
InputIds.Add(res_token);
attention_masks.Add(1);
}
s = Decoding_output(InputIds.Take(InputIds.Count-1).ToArray());//去除EOS Token
//每句话进行换行
HashSet<string> wrap_chrs = new HashSet<string>();
wrap_chrs.Add("……");
wrap_chrs.Add("——");
HashSet<char> ignore_chr = new HashSet<char>{ '\"', '“', '”', '…', '—' };
for (int i=0;i<s.Length;i++) {
char c= s[i];
if(!Regex.IsMatch(c.ToString(), @"^[\u4e00-\u9fff]+$") && !ignore_chr.Contains(c))
{
wrap_chrs.Add(c.ToString());
}
}
foreach (string c in wrap_chrs) {
s = s.Replace(c,c+"\n");
}
return s;
}
最终得到了“输入文本–>序列化–>贪婪搜索循环输入模型–>反序列化–>输出文本”的操作。
总结
想要将Transformers训练好的NLP生成任务模型放到dot NET项目中,需要进行以下的几个步骤:
一、定义Tokenizer
二、定义模型
三、定义模型的生成策略
其中,第二步在微软官网中有明确的教程,第一步花了我一段时间在github上找到了BPE策略对应教程,但是WordPierce没有找到相应教程。第三步生成策略也没有进行对应的封装,此处只做了贪婪算法的实现。
需要注意的是,导出为ONNX模型后,会与原来模型的输出有些差异,此处的模型输出就与python中的输出不一样了。