项目地址:GitHub - thornbsj/DS_PAIR: 黑暗之魂 物品描述生成

hugging-face发布的transformers库,将数据准备-模型准备-训练-推理的一些列流程标准化,能够让用户极为方便地条用transformer型地预训练模型以及对模型进行微调。本篇就跟随hugging-face transformers的标准流程,微调处能够输出指定语言风格上下文的模型。

hugging-face中文镜像地址:HF-Mirror - Huggingface 镜像站 

一、任务概括

游戏“黑暗之魂”系列中一直以其隐晦的剧情以及其“碎片化叙事”著称。除去一般游戏中采用的环境叙事以及NPC的语言叙事外,还会将部分剧情背景通过物品描述的形式糅合进游戏中。通过“魂式文风”的物品描述来推测背景故事也是玩家们乐此不疲的一件事。本文就希望能够借助于预训练好的语言模型,输入物品描述,让模型输出一个相关的背景故事。

像这样直接通过上文输出下文的任务,可以直接使用因果语言模型来完成。在进行数据预处理以及建模之前,我们先来看一下训练用文本的特点:

 在黑暗之魂系列游戏中,大多数的物品描述都会遵循这样的一个规律:

上半段先介绍一下这个物品/魔法是什么,能够干什么:比如图中的雷电柱(是一种奇迹(黑魂中一类法术派别),能够刺入雷电柱)

下半段会将这个物品的相关背景展开叙述,阐述了这个“奇迹”是失传的远古猎龙的方法。

我们的任务便是,将上半段输入给模型,希望模型能够输出相关背景。

二、数据获取与增强

已经有人将黑暗之魂系列的各个物品描述整理放到了github上:GitHub - LeMinerva/Dark-Souls-Documents: 黑暗之魂文本与数据档案

我们期望的是根据中文文本输出对应中文文本,而且和后续会有手动调整,所以只会提取中文官方译名的原文,只需用正则表达式提取出来即可。

但是需要注意的是,游戏本身翻译会有一部分“异译”,会以如图所示的样子出现,我们此时将这2种翻译都考虑进去,分为2条样本。因为本身我们的数据量对于模型Fine-Tune而言过少,此处也是顺带做数据增强。特别的,当一条物品描述中有多个“异译”时,我们对其进行两辆组合以此增加数据量。

除此以外,1代里的武器中标有的“武器种类”和3代武器标明的“武器战技”,不在我们的训练目标内,此处将它们舍去了。

paths = [
    "Dark-Souls-Documents-master/text/chn1/",
    "Dark-Souls-Documents-master/text/chn2/",
    "Dark-Souls-Documents-master/text/chn3/"
]

res = []
des_set = set()
for path in paths:
    for file in os.listdir(path):
        if "desc" in file:
            with open(path+file,encoding="utf-8") as f:
                data = f.read()
            desc_sets = re.findall("<text id=.*?>(.*?)</text>",data,re.S)
            for d in desc_sets:
                d = d.replace("\n\n","\t").replace("\n","")
                d = d.replace("\t","\n")
                if "\n" in d:
                    if d.split("\n")[-1] not in des_set:
                        des_set.add(d.split("\n")[-1])
                        res.append(d)
                    else:
                        continue

def get_combinatorial(choices):
    res = []
    def dfs(i,sub):
        if i == len(choices):
            return sub
        res.append(dfs(i+1,sub+[0]))
        res.append(dfs(i+1,sub+[1]))
    dfs(0,[])
    keys = list(choices.keys())
    replace_dicts = []
    for i in range(len(res)):
        if not res[i]:
            continue #不知道为什么,res中回莫名插入None
        replace_dict = {}
        for j in range(len(keys)):
            replace_dict[keys[j]] = choices[keys[j]][res[i][j]]
        replace_dicts.append(replace_dict)
    return replace_dicts
res_cleared = []
for i in res:
    if "#3#2" in i:
        choices = re.findall("#1.*?#3#2.*?#3",i,re.S)
        choices = {j:j.replace("#3#2","##5##").replace("#1","").replace("#3","").split("##5##") for j in re.findall("#1.*?#3#2.*?#3",i,re.S)}
        for d in get_combinatorial(choices):
            tmp = i
            for k,v in d.items():
                tmp = tmp.replace(k,v)
            res_cleared.append(tmp)
    else:
        res_cleared.append(i)


for i,s in enumerate(res_cleared):
    if "#" in s:#最后还有一部分带有井号的描述代表游戏废案
        res_cleared[i] = re.sub("#\d","",s)

import pandas as pd
import re

for i in range(data.shape[0]):
    if "武器种类:" in data.loc[i,"description"]:
        data.loc[i,"description"] = re.sub("武器种类:.*?\n","",data.loc[i,"description"])
        print(data.loc[i,"description"])

for i in range(data.shape[0]):
    if "战技:" in data.loc[i,"description"]:
        data.loc[i,"description"] = re.sub("战技:.*","",data.loc[i,"description"])
        print(data.loc[i,"description"])

for i in range(data.shape[0]):
    data.loc[i,"description"] = data.loc[i,"description"].replace("\n","")

data.to_excel("data_for_casual.xlsx",index=None)

 之后,笔者手动检查了每一条数据,将其中少量“将物品作用写在最后”的那些文本进行了调整,使得每一条数据都符合“先描述物品,再描述背景故事”的规律,并且删去了一些“没有背景描述”的物品描述。最后获得了2179条数据。

这样的数据量实际上还远远不够给模型进行fine tune。因此,笔者还对数据进行了数据增强操作。笔者使用了下面的这个中文数据增强包,分别作了回译、数据翻转、随机词替换三类数据增强的操作,以此增大数据量。GitHub - selfDiscipline/nlpcda: 一键中文数据增强包 ; NLP数据增强、bert数据增强、EDA:pip install nlpcda

注意这个包中的simbert笔者没有成功调用,有可能是因为它的依赖包(bert4keras)需要有TensorFlow1.x的环境。 

1、回译

笔者将“回译”步骤放在第一步,是因为笔者所有的数据增强方法都会进行“交叉”处理(即某些数据做完回译之后还会进行数据翻转与随机词替换)。由于“回译”需要借助百度翻译的api,所以需要让它在第一步完成,因为此时数据量最小,不会产生不必要的费用。

import pandas as pd
from collections import Counter

data = pd.read_excel("data_for_casual.xlsx")
data["type"]="None"

from nlpcda import baidu_translate

appid = "xxx"
secret_key = "xxx"

for i,x in enumerate(data["description"]):
    en = baidu_translate(content=x, appid=appid, secretKey=secret_key,t_from='zh', t_to='en')
    res = baidu_translate(content=en, appid=appid, secretKey=secret_key,t_from='en', t_to='zh')
    if res != x:
        data = pd.concat([data,pd.DataFrame({"description":[res],"type":[data.loc[i,"type"]+"+Spa_translated"]})],axis=0).reset_index(drop=True)
    if i%100 == 0:
        print(data.shape)

2、数据翻转

对于4句话及以上的描述,笔者将前2句视为物品描述,后面的句子顺序翻转作为数据增强。

Counter([len(i.split("。"))>=5 for i in data["description"]])
# Counter({False: 3168, True: 1170})
for i,x in enumerate(data["description"]):
    if len(x.split("。"))>=5:
        x = x.split("。")
        x = x[0:2]+x[2:][::-1]
        res = "。".join([j for j in x if j!=''])+"。"
        data = pd.concat([data,pd.DataFrame({"description":[res],"type":[data.loc[i,"type"]+"+Flip"]})],axis=0).reset_index(drop=True)

3、随机词替换

随即将文本中的某些词语替换为同义词,使得样本量翻倍。

from nlpcda import Similarword
smw = Similarword(create_num=4, change_rate=0.3)
for i,x in enumerate(data["description"]):
    res = smw.replace(x)[1:]
    tpe = [data.loc[i,"type"]+"+SimilarReplace"]*3
    data = pd.concat([data,pd.DataFrame({"description":res,"type":tpe})],axis=0).reset_index(drop=True)

data.to_excel("augumentation.xlsx",index=None)

最终,经过了一系列的数据增强,我们获得了总共22032条数据。尽管数据量翻了将近10倍,但是这也带来了一定的隐患:

回译有可能造成某些语句变得不是那么通顺并且某些英文译名也随之改变;随机词替换可能会造成某些句子与本意有所偏差;数据翻转也有可能使得原本的上下文因果关系混乱。虽然解决了原本“数据量过小”的问题,但是本身也牺牲了一定的数据集质量,对于后续的fine-tune而言形成了挑战。

三、建模策略

由于笔者对于hugging-face上的各个预训练语言模型没有一个整体的把握,并且不是NLP从业人员,外加自身的设备能力有限,故而此处选择了几个参数量有限的预训练模型。它们分别是:

模型名称参数量
bigscience/bloomz-560m5亿6千万
facebook/xglm-564M5亿6千万
bigscience/bloom-1b111亿
bigscience/bloom-7b171亿

 下面我们来看一下笔者设定的训练主要参数:

model_name:从hugging-face上要下载用的模型名称

tokenizer_name:从hugging-face上要下载用的文本tokenizer名称,通常和model_name相同

model_load_type:是否要调整模型精度为16bit/8bit/4bit(qlora)

use_lora:是否要使用LoRa方法进行微调

lora_r:LoRa中的秩数

lora_drop:LoRa的dropout率

target_modules:要对哪些部分进行LoRa微调,应该输入一个正则表达式。默认为"transformer.*query_key_value"

num_epochs:模型微调轮数(通常设为1即可)

per_device_train_batch_size、gradient_accumulation_steps:GPU上每次正向传播多少样本,样本累计多少轮才做反向传播。

save_total_limit:最终要保存多少个模型(checkpoint)

train_min_length:文本长度至少要达到多少才能进入训练集

learning_rate:学习率,默认1e-5

对于因果语言模型而言,hugging-face已经做好了封装,我们只需要做调包就可以了。唯一需要注意的一个地方就是需要在每一段文本后面加上eos token(end of sentence)。大致的流程就是:

1、加载模型

2、给文本数据加上eos token,并tokenize化

3、(可选)进行微调设置,如LoRa

4、设定训练参数

5、训练并保存模型

import argparse
from datasets import Dataset
import pandas as pd
from transformers import AutoTokenizer,AutoModelForCausalLM,DataCollatorForLanguageModeling,TrainingArguments,Trainer
import torch
from peft import LoraConfig, TaskType, get_peft_model

def getLoraConfig(target_modules=["q","k"],lora_dropout=0.0,r=8):
    lora_config = LoraConfig(
        r=r,
        target_modules=target_modules,
        lora_dropout=lora_dropout,
        task_type=TaskType.CAUSAL_LM
    )
    return lora_config

if __name__ == '__main__':
    try:
        parser = argparse.ArgumentParser(description='DSPairGen')
        data = pd.read_excel("augumentation.xlsx")
        ds = Dataset.from_pandas(data)
        parser.add_argument('--model_name', type=str, default='bigscience/bloomz-560m',
                            help='model name from huggingface')
        
        parser.add_argument('--tokenizer_name', type=str, default='bigscience/bloomz-560m',
                            help='tokenizer name from huggingface')
        
        parser.add_argument('--model_load_type', type=str, default='32bit',
                            help='if you need to load model in 16bit/8bit/4bit(qLora)...')

        parser.add_argument('--use_lora', type=bool, default=False,
                            help='if you need to use LoRa (will be forced to use when load with 8bit/4bit)')
        
        parser.add_argument('--lora_r', type=int, default=8,
                            help='the parameter rank of LoRa')
        
        parser.add_argument('--lora_drop', type=float, default=0.0,
                            help='dropout parameter of LoRa')
        
        parser.add_argument('--target_modules', type=str, default="transformer.*query_key_value",
                            help='target parameters of LoRa')
        
        parser.add_argument('--num_epochs', type=int, default=1,
                            help='epoch of training')

        parser.add_argument('--per_device_train_batch_size', type=int, default=4,
                            help='per device train batch size')

        parser.add_argument('--gradient_accumulation_steps', type=int, default=8,
                            help='per_device_train_batch_size*gradient_accumulation_steps=one BP batch of training')

        parser.add_argument('--save_total_limit', type=int, default=1,
                            help='total save models')
        
        parser.add_argument('--train_min_length', type=int, default=50,
                            help='min length of sentences in training set')
        
        parser.add_argument('--learning_rate', type=float, default=1e-5)
        args = parser.parse_args()
        print(args)
        
        ds = ds.select([i for i,x in enumerate(data["description"]) if len(x)>=args.train_min_length])

        model_name = args.model_name
        tokenizer_name = args.tokenizer_name

        model_param_dict = {
            "16bit":[("torch_dtype",torch.half)],
            "8bit":[("torch_dtype",torch.half),("load_in_8bit",True)],
            "4bit":[("torch_dtype",torch.half),("load_in_4bit",True),("bnb_4bit_compute_dtype",torch.half),("bnb_4bit_quant_type","nf4"),("bnb_4bit_use_double_quant",True)]
        }
        
        model_kwargs = {}
        

        model_load_type = args.model_load_type
        if model_load_type in model_param_dict.keys():
            for k,v in model_param_dict[model_load_type]:
                model_kwargs[k] = v
        
        model = AutoModelForCausalLM.from_pretrained(model_name,**model_kwargs)
        model.enable_input_require_grads()
        tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)

        def preprocess(examples):
            if tokenizer.eos_token is not None:
                contents = [e + tokenizer.eos_token for e in examples["description"]]
            else:
                contents = examples["description"]
            return tokenizer(contents,max_length = 200)

        tokenized_ds = ds.map(preprocess,batched=True,remove_columns = ds.column_names)
        if args.model_load_type in ("8bit","4bit"):
            args.use_lora = True
        if args.use_lora:
            lora_config = getLoraConfig(target_modules=args.target_modules,lora_dropout=args.lora_drop,r=args.lora_r)
            model = get_peft_model(model,lora_config)
            model.print_trainable_parameters()

        train_args = TrainingArguments(
            output_dir=f"{model_name}_{model_load_type}_{args.use_lora}_{args.num_epochs}",
            per_device_train_batch_size=args.per_device_train_batch_size,
            gradient_accumulation_steps=args.gradient_accumulation_steps,
            logging_steps=10,
            num_train_epochs=args.num_epochs,
            gradient_checkpointing=True,
            save_total_limit=args.save_total_limit,
            learning_rate=args.learning_rate
        )
        trainer = Trainer(
            args = train_args,
            model = model,
            train_dataset = tokenized_ds,
            data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer,mlm=False)
        )
        trainer.train()
    except Exception as e:
        print(e)
python ./casual_train.py --model_name=bigscience/bloomz-560m --tokenizer_name=bigscience/bloomz-560m --per_device_train_batch_size=4 --gradient_accumulation_steps=8 --num_epochs=1
python ./casual_train.py --model_name=bigscience/bloomz-560m --tokenizer_name=bigscience/bloomz-560m --per_device_train_batch_size=4 --gradient_accumulation_steps=8 --num_epochs=3
python ./casual_train.py --model_name=facebook/xglm-564M --tokenizer_name=facebook/xglm-564M --per_device_train_batch_size=4 --gradient_accumulation_steps=8 --num_epochs=1
python ./casual_train.py --model_name=facebook/xglm-564M --tokenizer_name=facebook/xglm-564M --per_device_train_batch_size=4 --gradient_accumulation_steps=8 --num_epochs=3
python ./casual_train.py --model_name=bigscience/bloom-7b1 --tokenizer_name=bigscience/bloom-7b1 --per_device_train_batch_size=1 --gradient_accumulation_steps=32 --num_epochs=1 --use_lora=True --model_load_type=4bit

四、模型推理

训练好模型之后,即可使用它们进行推理。笔者手动创建了几个用以输入模型推理下文的用例:

咒术师恩吉在伊扎里斯领悟的咒术,能投掷出毒火球
能投掷出毒火球
伊扎里斯魔女在猎龙战争期间创造的咒术,能投掷出火焰枪
能投掷出火焰枪
引起大帽子罗根疯狂的魔法,能够连续喷射结晶吐息
伊扎里斯咒术中最为可怖的一个。牺牲自己的生命将身躯化为火焰钻入敌人身体内部
牺牲自己的生命将身躯化为火焰钻入敌人身体内部
将火焰纳入连同自身在内,周遭多人的体内
被称为“邪妖”的剑,随着斩杀敌人数量的增多而变强
能够随着斩杀敌人数量的增多而变强的剑
被称为“邪妖”的剑,主人受伤程度越大反而越强
自身受伤程度越大攻击力就越大的剑
被人们冠以“正义”的剑,能够将黑暗驱散并治疗自身
能够将黑暗驱散并治疗自身的剑
被称为“宝可梦球”的神器,能够捕捉敌人并将其转化为盟友
能够捕捉敌人并将其转化为盟友

 其中部分是“物品陈述+物品用途”的写法,另一部分则是单纯“物品用途”的写法。

模型推理相较于模型训练而言,只新增了如下的几个参数:

1、model_name:此处的模型是自身训练好的模型路径

2、do_sample:文本生成时是否采用“抽样”策略

3、no_repeat_ngram_size:文本生成中限制“不要让词连续出现n次”,默认为1

4、num_beams:文本生成时是否采用“波束搜索”策略,并限定波束数量,默认为1(大于1时才是波束搜索)。

5、max_length:模型输出文本的最大长度(包括eos token)。

import argparse
from datasets import Dataset
import pandas as pd
from transformers import AutoTokenizer,AutoModelForCausalLM,DataCollatorForLanguageModeling,TrainingArguments,Trainer
import torch
from peft import LoraConfig, TaskType, get_peft_model

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='DSPairGen')
    tests = pd.read_excel("For_generation.xlsx")
    parser.add_argument('--model_name', type=str, default='bigscience/bloomz-560m',
                            help='fine-tuned model name')
        
    parser.add_argument('--tokenizer_name', type=str, default='bigscience/bloomz-560m',
                        help='tokenizer name from huggingface')
    
    parser.add_argument('--use_GPU', type=bool, default=False,
                            help='if generate on GPU')
    
    parser.add_argument('--do_sample', type=bool, default=False,
                            help='if do sample while generation')
    
    parser.add_argument('--no_repeat_ngram_size', type=int, default=1,
                            help='limit repeat n_gram')
    
    parser.add_argument('--num_beams', type=int, default=1,
                            help='beam numbers of beam search')
    
    parser.add_argument('--model_load_type', type=str, default='32bit',
                            help='if you need to load model in 16bit/8bit/4bit(qLora)...')
    
    parser.add_argument('--max_length', type=int, default=150,
                            help='max_length to predict')
    args = parser.parse_args()

    model_param_dict = {
                "16bit":[("torch_dtype",torch.half)],
                "8bit":[("torch_dtype",torch.half),("load_in_8bit",True)],
                "4bit":[("torch_dtype",torch.half),("load_in_4bit",True),("bnb_4bit_compute_dtype",torch.half),("bnb_4bit_quant_type","nf4"),("bnb_4bit_use_double_quant",True)]
            }
        
    model_kwargs = {}
        

    model_load_type = args.model_load_type
    if model_load_type in model_param_dict.keys():
        for k,v in model_param_dict[model_load_type]:
            model_kwargs[k] = v

    model = AutoModelForCausalLM.from_pretrained(args.model_name,**model_kwargs)
    tokenizer = AutoTokenizer.from_pretrained(args.tokenizer_name)
    if args.use_GPU and args.model_load_type not in ("4bit","8bit"):
        model = model.cuda()

    res = []
    for i in tests["des_above"]:
        ipt = tokenizer(i,return_tensors="pt").to(model.device)
        ipt["do_sample"] = args.do_sample
        ipt["no_repeat_ngram_size"] = args.no_repeat_ngram_size
        if args.num_beams>1:
            ipt["num_beams"] = args.num_beams
        res.append(tokenizer.decode(model.generate(**ipt,max_length=args.max_length)[0]))
    tests["res"]=res
    tests.to_excel(args.model_name+'/result.xlsx')
python ./casual_predict.py --model_name=bigscience/bloomz-560m_32bit_False_1/checkpoint-500 --tokenizer_name=bigscience/bloomz-560m
python ./casual_predict.py --model_name=bigscience/bloomz-560m_32bit_False_3/checkpoint-1500 --tokenizer_name=bigscience/bloomz-560m
python ./casual_predict.py --model_name=facebook/xglm-564M_32bit_False_1/checkpoint-500 --tokenizer_name=facebook/xglm-564M --use_GPU=True
python ./casual_predict.py --model_name=facebook/xglm-564M_32bit_False_3/checkpoint-1500 --tokenizer_name=facebook/xglm-564M --use_GPU=True
python ./casual_predict.py --model_name=bigscience/bloom-7b1_4bit_True_1/checkpoint-500 --tokenizer_name=bigscience/bloom-7b1 --use_GPU=True

而最终的结果,笔者截取部分给读者看一下:

对于71亿参数的模型而言,大部分的输入没有产生下文就直接输出了。

 而facebook/xglm模型而言,尽管输出了一部分的物品背景描述,但是更多的则是在输出模型在预训练的语料数据。

 而对于有5亿参数的bigscience/bloomz而言,训练1个epoch已经能够根据上文编出故事背景了,尽管有些文本和上文无法完全匹配,如明明是魔法,下文却说它是武器,但是实际上有部分的背景叙述已经有些“魂味儿”在里面了,比如下面这个:

被称为“邪妖”的剑,主人受伤程度越大反而越强。在斩击时会产生巨大声响并造成伤害的效果很好理解:攻击后会有声音传来表示仇敌受到重伤的征兆——这正是“咆哮”之名而来

而对于训练了3个epoch的bigscience/bloomz模型而言,语言风格更贴合我们的训练集。

咒术师恩吉在伊扎里斯领悟的咒术,能投掷出毒火球。这也可以说是诞生了从地底燃烧过来火焰的一环事态吧──对魔法师长而言那是生命的枷锁又或是戒律之眼眉目呢……

被称为“邪妖”的剑,随着斩杀敌人数量的增多而变强。在称为“罪业之都”肇端之地的话下足会诞生这种武器吧……这把刀具有的魔力劲儿会导致对手大量血崩

需要说明的是,此处的训练结果并不能表明各个模型的好坏以及是否更加适合做这一人物的fine-tune。因为笔者本身NLP知识并不多以及设备资源有限,因此效果不好更有可能是训练时的错误方法导致的。

除了以上的因果语言模型训练之外,笔者还尝试过使用seq2seq模型,根据物品名称以及效果生成物品背景描述。但是效果不佳,也同样放在github上了。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐