在科大讯飞疫情新闻中的地理位置识别挑战赛中,使用 w2ner 模型加载 uer/roberta-base-finetuned-cluener2020-chinese 预训练权重,在赛题数据集上进行训练,之后对测试集进行推理,最终测试集上的 jaccard_score 指标平均 0.909 左右,最高 0.9216,排行榜 TOP1

代码开源地址:https://github.com/itlubber/iflytek-geographical-identification-top1

微信公众号推文:https://mp.weixin.qq.com/s/hxv7HwWF3RSm70n5P22fPA

图1: 疫情新闻中的地理位置识别挑战赛排行榜

赛题简介

赛题链接

https://challenge.xfyun.cn/topic/info?type=geographical-identification

赛题背景

疫情期间地理位置信息是快速识别并防范疫情的关键,而地理位置信息不仅包含城市、街道,也包含具体的公司名称和位置。在本次赛题中我们需要选手构建自然语言处理模型,识别出文本中的位置单词。

赛题任务

构建自然语言处理模型,对文本中的位置信息进行识别。案例如下:

输入:密接案例出现在恒盛地产北京合天和信房地产开发有限公司;

输出:恒盛地产北京合天和信房地产开发有限公司。

数据情况

赛题数据由训练集和测试集组成,分别使用 csv 进行读取。训练集包含 6000 条数据,测试集包含 2600+ 条数据。

tree -L 2
.
└── 疫情新闻中的地理位置信息识别挑战赛公开数据
    ├── sample_submit.csv
    ├── test.csv
    └── train.csv

1 directory, 3 files

评审规则

每支参赛队伍每天最多提交 3次 测试集预测结果,采用 jaccard_score 指标来评判提交结果的好坏,评估代码参考:

# sklearn.metrics.jaccard_score(y_true, y_pred, *, labels=None, pos_label=1, average='binary', sample_weight=None)
def jaccard_score(pred, label):
    return len(set(pred) & set(label)) / len(set(pred) | set(label))

赛题分析

数据分析

文本分析

图2: 测试集和训练集文本长度分布情况

  • 分析训练集和测试集中数据,发现文本长度最大值为 50,赛题提供的新闻文本应该是最大长度为 50 截断后的文本,大部分都不是完整的句子
  • 赛题提供的句子中的标点符号全角和半角都存在,可能先把半角符号都转为全角符号之后输入模型比较好
  • 很多句子的最后一个字符是标点符号,但非中文句子的结尾字符,部分为单个括号,可能对句子语义有一定的破坏,可以考虑去掉句尾的标点符号

标注实体分析

  • 查看训练集数据中的标注数据,分析可以发现,实体类型不止包含地理位置信息,还包含公司名称、组织机构名称、游戏名称、人名等类别,故本题采用腾讯 UER-py 预训练模型仓库中的 uer/roberta-base-finetuned-cluener2020-chinese 作为预训练模型,在此基础上微调可以很好的匹配当前任务
表1: 赛题训练集数据标准情况

序号 新闻内容 实体标注 实体类型
1 会安博物馆等,漫步会安古镇各精致的工艺品店、品尝路边的小吃摊,体验当地的风土民情。 [‘会安古镇’] 景点
8 联达四方经纪公司总经理杨少峰 [‘联达四方经纪公司’] 公司名称
14 银行业协会今年理财产品销量将首破1万亿 [‘银行业协会’] 组织机构
371 RTS星战之路首月将举行《魔兽争霸3》项目 [‘RTS星战之路’] 游戏

模型分析

图3: Chinese Named Entity Recognition on MSRA

  • 目前 NLP 相关的赛题,如果不是对模型训练和推理有资源限制,基本上都可以无脑用 bert 类的模型,效果通常都还可以
  • 预训练模型选择方面,可以考虑 Hugging Face 上面的预训练模型,也可以在 github 或者 paperwithcode 上找相关的预训练模型。考虑到本题是序列标注的问题,根据以往经验来看,预训练模型选 roberta largenezha large 或者 uer 里面的一些预训练模型,效果应该都还行,时间和资源都充裕的情况下也可以尝试下澜舟的 mengzi 或者封神榜里面的开源的预训练模型

解题方案

特别说明

本次比赛的主要代码来自 xiangking 大佬开源的 ark-nlp 库,库中收集和复现了部分常见的 NLP 模型,包括文本分类、文本匹配、序列标注、关系抽取等模型。

ark-nlp 仓库链接:https://github.com/xiangking/ark-nlp

解题思路

本题在 ark-nlp 库中的 w2ner_bert 代码的基础上,进行了部分小改动,使得模型能够直接加载 uer/roberta-base-finetuned-cluener2020-chinese 预训练模型权重进行训练,并在训练过程中,加入对抗训练FGM,对模型中的 word_embedding 层的参数注入扰动,结合EMA策略,提高了模型的鲁棒性。

同时,考虑到本次赛题数据集较少,且模型测试集上 jaccard_score 指标超过0.90,故使用了伪标签的方式扩充训练数据,并且在验证时依旧采用原有训练集中的数据来保证模型的准确性和泛化能力,有效的提高了模型的指标。

前期把 ark-nlp 库中的 ner 方案基本都试了一遍,也实验了 bert4torch 里面的一些模型,比如 UIE, 也实验了下在文本首尾添加 promot 的方式,最终根据提交效果选择了最优的 word2ner 模型,同时对比了不同的预训练模型,包括 nezharobertabert 以及其他在 huggingface 上的其他预训练模型,无论是 baselarge 还是模型融合, 基本上都不如 uer/roberta-base-finetuned-cluener2020-chinese 预训练模型微调直接预测结果好。

代码结构

.
├── requirements.txt                                # 相关环境依赖
├── README.md                                       # 说明文档
├── code                        
│   ├── clear_cache.sh                              # 清除代码运行过程中生成的 pyc、pyd文件
│   ├── additional.py                               # 比赛页面显示的测试集前16条答案, 在推理时可以选择是否使用
│   ├── train.sh                                    # 模型训练脚本, cd ./code 后 chmod +x train.sh 再 ./train.sh
│   ├── test.sh                                     # 模型训练脚本, cd ./code 后 chmod +x test.sh 再 ./test.sh, 修改脚本中的 additional_tags 为 True 可使用页面上的标签
│   ├── ark_nlp                                     # ark_nlp 代码, 参考 https://github.com/xiangking/ark-nlp/tree/main/ark_nlp
│   │   ├── __init__.py
│   │   ├── dataset
│   │   ├── factory
│   │   ├── model                                   # 模型代码
│   │   ├── nn
│   │   └── processor
│   ├── test.py                                     # 推理代码
│   ├── test_pseudo.py                              # 使用伪标签训练模型后的推理代码
│   ├── train_all.py                                # 全量数据训练模型代码
│   └── train_all_pseudo.py                         # 全量数据 + 伪标签 训练模型代码
├── prediction_result
├── user_data
│   ├── outputs                                     # 模型训练完保存的文件夹
│   ├── roberta-base-finetuned-cluener2020-chinese  # UER.py 提供的预训练模型
│   │   ├── config.json
│   │   ├── pytorch_model.bin
│   │   ├── special_tokens_map.json
│   │   ├── tokenizer_config.json
│   │   └── vocab.txt
│   ├── examples                                    # 比赛过程中的一些尝试,调整代码结构前的
│   │   ├── 0.w2ner_all.ipynb
│   │   ├── 0.w2ner_all_pseudo.ipynb
│   │   ├── 2.w2ner_pseudo.ipynb
│   │   ├── biaffine.ipynb
│   │   ├── gpbaseline.ipynb
│   │   ├── spanbert.ipynb
│   │   ├── w2ner.ipynb
│   │   └── w2ner_cv.ipynb
│   ├── scores.jpg                                  # 比赛过程中提交结果的得分记录
│   └── 页面显示的测试集前16条答案.png
└── xfdata
    └── 疫情新闻中的地理位置信息识别挑战赛公开数据          # 比赛提供的数据集
        ├── sample_submit.csv
        ├── test.csv                                # 测试集
        └── train.csv                               # 训练集

数据预处理

原始文本中的半角标点符号转换为全角标点符号

def E_trans_to_C(string):
    E_pun = u',.!?[]()<>"\''
    C_pun = u',。!?【】()《》“‘'
    table= {ord(f):ord(t) for f,t in zip(E_pun,C_pun)}
    return string.translate(table)

去除文本末尾无意义的标点符号

train["text"] = train["text"].apply(lambda line: E_trans_to_C(re.sub("[\(《:→;,。、\-”]+$", "", line.strip())))
train["tag"] = train["tag"].apply(lambda x: [E_trans_to_C(i) for i in eval(str(x))])

将输出转换为标准的数据格式

train["entities"] = train.progress_apply(lambda row: [["LOC", *i.span()] for tag in row["tag"] for i in re.finditer(tag, row["text"])], axis=1)

datalist = []

for _, row in train.iterrows():
    entity_labels = []
    for _type, _start_idx, _end_idx in row["entities"]:
        entity_labels.append({
            'start_idx': _start_idx,
            'end_idx': _end_idx,
            'type': _type,
            'entity': row["text"][_start_idx: _end_idx]
    })

    datalist.append({
        'text': row["text"],
        'entities': entity_labels
    })

train_data_df = pd.DataFrame(datalist)


def get_label(x):
    
    entities = []
    for entity in x:
        entity_ = {}
        idx = list(range(entity['start_idx'], entity['end_idx']))
        entity_['idx'] = idx
        entity_['type'] = entity['type']
        entity_['entity'] = entity['entity']
        entities.append(entity_)
    
    return entities


train_data_df['label'] = train_data_df['entities'].apply(lambda x: get_label(x))
train_data_df = train_data_df.loc[:, ['text', 'label']]
train_data_df['label'] = train_data_df['label'].apply(lambda x: str(x))

将处理好的数据转换为bert模型的输入

ner_train_dataset = Dataset(train_data_df)

prompt = None
tokenizer = Tokenizer(vocab='../user_data/roberta-base-finetuned-cluener2020-chinese', max_seq_len=52)
ner_train_dataset.convert_to_ids(tokenizer, prompt=prompt)

模型训练

模型加载

torch.cuda.empty_cache()
config = W2NERBertConfig.from_pretrained('../user_data/roberta-base-finetuned-cluener2020-chinese', num_labels=len(ner_train_dataset.cat2id))
dl_module = W2NERBert.from_pretrained('../user_data/roberta-base-finetuned-cluener2020-chinese', config=config)

选择优化器和学习率调整策略

# 设置运行次数
num_epoches, batch_size = 40, 256
optimizer = get_default_w2ner_optimizer(dl_module, lr=1e-2, bert_lr=5e-5, weight_decay=0.01)

# 注意lr衰减轮次的设定
show_step = len(ner_train_dataset) // batch_size + 2
t_total = len(ner_train_dataset) // batch_size * num_epoches
scheduler = get_default_cosine_schedule_with_warmup(optimizer, t_total, warmup_ratio=0.2)

模型训练

model = Task(dl_module, optimizer, 'ce', cude_device=2, scheduler=scheduler, grad_clip=5.0, ema_decay=0.995, fgm_attack=True, save_path="../user_data/outputs/roberta-finetuned", )
model.fit(ner_train_dataset, epochs=num_epoches, batch_size=batch_size, show_step=show_step)

模型推理

训练集数据推理

class IFW2NERPredictor(Predictor):
    def E_trans_to_C(self, string):
        E_pun = u',.!?[]()<>"\''
        C_pun = u',。!?【】()《》“‘'
        table= {ord(f):ord(t) for f,t in zip(E_pun,C_pun)}

        return string.translate(table)

    def predict_one_sample(self, text='', prompt=None, cv=False):
        text = text.strip()
        
        features = self._get_input_ids(E_trans_to_C(re.sub("[\(《:;→,。、\-”]+$", "", text)), prompt=prompt)
        self.module.eval()

        with torch.no_grad():
            inputs = self._get_module_one_sample_inputs(features)
            logit = self.module(**inputs)

        preds = torch.argmax(logit, -1)

        instance, l = preds.cpu().numpy()[0], int(inputs['input_lengths'].cpu().numpy()[0])

        forward_dict = {}
        head_dict = {}
        ht_type_dict = {}
        for i in range(l):
            for j in range(i + 1, l):
                if instance[i, j] == 1:
                    if i not in forward_dict:
                        forward_dict[i] = [j]
                    else:
                        forward_dict[i].append(j)
        for i in range(l):
            for j in range(i, l):
                if instance[j, i] > 1:
                    ht_type_dict[(i, j)] = instance[j, i]
                    if i not in head_dict:
                        head_dict[i] = {j}
                    else:
                        head_dict[i].add(j)

        predicts = []

        def find_entity(key, entity, tails):
            entity.append(key)
            if key not in forward_dict:
                if key in tails:
                    predicts.append(entity.copy())
                entity.pop()
                return
            else:
                if key in tails:
                    predicts.append(entity.copy())
            for k in forward_dict[key]:
                find_entity(k, entity, tails)
            entity.pop()

        for head in head_dict:
            find_entity(head, [], head_dict[head])

        entities = []
        for entity_ in predicts:
            entities.append({
                "idx": entity_,
                "entity": ''.join([text[i] for i in entity_]),
                "type": self.id2cat[ht_type_dict[(entity_[0], entity_[-1])]]
            })

        if cv:
            return text, int(inputs['input_lengths'].cpu().numpy()[0]), logit.cpu().numpy()

        return entities
        

test = pd.read_csv("../xfdata/疫情新闻中的地理位置信息识别挑战赛公开数据/test.csv", sep="\t")
tokenizer = Tokenizer(vocab='../user_data/roberta-base-finetuned-cluener2020-chinese', max_seq_len=52)
ner_predictor_instance = IFW2NERPredictor(torch.load("../user_data/outputs/roberta-finetuned-all.pkl"), tokenizer, {'<none>': 0, '<suc>': 1, 'LOC': 2})


predict_results = []

for _line in tqdm(test["text"].tolist()):
    label = set()
    for _preditc in ner_predictor_instance.predict_one_sample(_line, prompt=prompt):
        label.add(_preditc["entity"])


with open('../prediction_result/result.csv', 'w', encoding='utf-8') as f:
    f.write("tag\n")
    for _result in predict_results:
       f.write(f"{str(_result)}\n")

模型trick

伪标签数据增广

# 伪标签数据
test = pd.read_csv("../xfdata/疫情新闻中的地理位置信息识别挑战赛公开数据/test.csv", sep="\t")
test["tag"] = pd.read_csv("../prediction_result/result.csv", sep="\t")["tag"]
test["text"] = test["text"].apply(lambda line: E_trans_to_C(re.sub("[\(《:→;,。、\-”]+$", "", line.strip())))
test["tag"] = test["tag"].apply(lambda x: [E_trans_to_C(i) for i in eval(str(x))])
test = test[test["tag"].apply(len) > 0

train = pd.read_csv("../xfdata/疫情新闻中的地理位置信息识别挑战赛公开数据/train.csv", sep="\t")
train = pd.concat([train[["text", "tag"]], test[["text", "tag"]]]).reset_index(drop=True)

FGM对抗学习

# code/ark_nlp/factory/task/base/_sequence_classification.py line: 108
# fgm attack
self._on_train_attack(inputs, **kwargs)

# code/ark_nlp/factory/task/base/_sequence_classification.py line: 269
def _on_train_attack(self, inputs, gradient_accumulation_steps=1, **kwargs):
    if self.fgm_attack:
        self.fgm.attack()
        _, attck_loss = self._get_train_loss(inputs, self.module(**inputs), **kwargs)
        
        # 如果GPU数量大于1
        if self.n_gpu > 1:
            attck_loss = attck_loss.mean()
        # 如果使用了梯度累积,除以累积的轮数
        if gradient_accumulation_steps > 1:
            attck_loss = attck_loss / gradient_accumulation_steps

        attck_loss.backward()
        self.fgm.restore()

优化方向